add details modal

This commit is contained in:
Pas 2025-04-17 13:02:31 -06:00
parent 9e555c99ea
commit 02b1b5a7dc
26 changed files with 1503 additions and 425 deletions

View file

@ -678,9 +678,9 @@
"discover": "Discover section",
"discoverDescription": "Show the Discover section on the Homepage below your bookmarked media. Enabled by default.",
"discoverLabel": "Discover section",
"hover": "Details popup",
"hoverDescription": "Show a popup with details when hovering over a media item. Disabled by default. (Only available on desktop)",
"hoverLabel": "Details popup"
"modal": "Details modal",
"modalDescription": "Show the details modal when you click on a media card instead of going to the watch page. Proxy or Extension required for trailer. Disabled by default.",
"modalLabel": "Details modal"
}
},
"connections": {

View file

@ -264,15 +264,43 @@ type MediaDetailReturn<T extends TMDBContentTypes> =
? TMDBShowData
: never;
export function getMediaDetails<
export async function getMediaDetails<
T extends TMDBContentTypes,
TReturn = MediaDetailReturn<T>,
>(id: string, type: T): Promise<TReturn> {
if (type === TMDBContentTypes.MOVIE) {
return get<TReturn>(`/movie/${id}`, { append_to_response: "external_ids" });
return get<TReturn>(`/movie/${id}`, {
append_to_response: "external_ids,credits,release_dates",
});
}
if (type === TMDBContentTypes.TV) {
return get<TReturn>(`/tv/${id}`, { append_to_response: "external_ids" });
const showData = await get<TReturn>(`/tv/${id}`, {
append_to_response: "external_ids,credits,content_ratings",
});
// Fetch episodes for each season
const showDetails = showData as TMDBShowData;
const episodePromises = showDetails.seasons.map(async (season) => {
const seasonData = await get<TMDBSeason>(
`/tv/${id}/season/${season.season_number}`,
);
return seasonData.episodes.map((episode) => ({
id: episode.id,
name: episode.name,
episode_number: episode.episode_number,
overview: episode.overview,
still_path: episode.still_path,
air_date: episode.air_date,
season_number: season.season_number,
}));
});
const allEpisodes = (await Promise.all(episodePromises)).flat();
return {
...showData,
episodes: allEpisodes,
} as TReturn;
}
throw new Error("Invalid media type");
}

View file

@ -130,6 +130,27 @@ export interface TMDBShowData {
external_ids: {
imdb_id: string | null;
};
credits?: {
cast: Array<{
id: number;
name: string;
character: string;
profile_path: string | null;
}>;
crew: Array<{
id: number;
name: string;
job: string;
department: string;
profile_path: string | null;
}>;
};
content_ratings?: {
results: Array<{
iso_3166_1: string;
rating: string;
}>;
};
}
export interface TMDBMovieData {
@ -181,6 +202,30 @@ export interface TMDBMovieData {
external_ids: {
imdb_id: string | null;
};
credits?: {
cast: Array<{
id: number;
name: string;
character: string;
profile_path: string | null;
}>;
crew: Array<{
id: number;
name: string;
job: string;
department: string;
profile_path: string | null;
}>;
};
release_dates?: {
results: Array<{
iso_3166_1: string;
release_dates: Array<{
certification: string;
release_date: string;
}>;
}>;
};
}
export interface TMDBEpisodeResult {

View file

@ -76,6 +76,8 @@ export enum Icons {
UNPLUG = "unplug",
TIP_JAR = "tip_jar",
SUPPORT = "support",
TMDB = "tmdb",
IMDB = "imdb",
}
export interface IconProps {
@ -169,6 +171,8 @@ const iconList: Record<Icons, string> = {
unplug: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-unplug"><path d="m19 5 3-3"/><path d="m2 22 3-3"/><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z"/><path d="M7.5 13.5 10 11"/><path d="M10.5 16.5 13 14"/><path d="m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z"/></svg>`,
tip_jar: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-tip-jar"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 10h-2.5a1.5 1.5 0 0 0 0 3h1a1.5 1.5 0 0 1 0 3h-2.5" /><path d="M12 9v1" /><path d="M12 16v1" /><path d="M17 4v1.882c0 .685 .387 1.312 1 1.618s1 .933 1 1.618v8.882a3 3 0 0 1 -3 3h-8a3 3 0 0 1 -3 -3v-8.882c0 -.685 .387 -1.312 1 -1.618s1 -.933 1 -1.618v-1.882" /><path d="M6 4h12z" /></svg>`,
support: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-message-circle-question"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15.02 19.52c-2.341 .736 -5 .606 -7.32 -.52l-4.7 1l1.3 -3.9c-2.324 -3.437 -1.426 -7.872 2.1 -10.374c3.526 -2.501 8.59 -2.296 11.845 .48c1.649 1.407 2.575 3.253 2.742 5.152" /><path d="M19 22v.01" /><path d="M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" /></svg>`,
tmdb: `<svg width="2em" height="2em" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 190.24 81.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"/></g></g></svg>`,
imdb: `<svg width="2em" height="2em" fill="currentColor" viewBox="0 0 32 32" id="Camada_1" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M8.4,21.1H5.9V9.9h3.8l0.7,4.7h0.1L11,9.9h3.8v11.2h-2.5v-6.7h-0.1l-0.9,6.7H9.4l-1-6.7h0L8.4,21.1L8.4,21.1z"></path> <path d="M15.8,9.8c0.4,0,3.2-0.1,4.7,0.1c1.2,0.1,1.8,1.1,1.9,2.3c0.1,2.2,0.1,4.4,0.1,6.6c0,0.2,0,0.5-0.1,0.8 c-0.2,0.9-0.7,1.4-1.9,1.5c-1.5,0.1-3,0.1-4.4,0.1c0,0-0.1,0-0.2,0V9.8z M18.8,11.9v7.2c0.5,0,0.8-0.2,0.8-0.7c0-1.9,0-3.9,0-5.9 C19.6,12,19.4,11.8,18.8,11.9z"></path> <path d="M2,21.1V9.9h2.9v11.2H2z"></path> <path d="M29.9,14.1c-0.1-0.8-0.6-1.2-1.4-1.4c-0.8-0.1-1.6,0-2.3,0.7V9.9h-2.8v11.2H26c0.1-0.2,0.1-0.4,0.2-0.5c0,0,0,0,0.1,0 c0.1,0.1,0.2,0.2,0.3,0.3c0.7,0.5,1.5,0.6,2.3,0.3c0.7-0.3,1-0.9,1-1.6c0-0.8,0.1-1.7,0.1-2.6C30,16,30,15,29.9,14.1L29.9,14.1z M27.1,19.1c0,0.2-0.2,0.4-0.4,0.4s-0.4-0.2-0.4-0.4v-4.3c0-0.2,0.2-0.4,0.4-0.4s0.4,0.2,0.4,0.4V19.1z"></path> </g> </g></svg>`,
};
function ChromeCastButton() {

View file

@ -1,5 +1,5 @@
import c from "classnames";
import { forwardRef, useEffect, useRef, useState } from "react";
import { forwardRef, useRef, useState } from "react";
import { Flare } from "@/components/utils/Flare";

View file

@ -1,262 +0,0 @@
import classNames from "classnames";
import { useCallback, useEffect, useRef, useState } from "react";
import { getMediaBackdrop, getMediaDetails } from "@/backend/metadata/tmdb";
import {
TMDBContentTypes,
TMDBMovieData,
TMDBShowData,
} from "@/backend/metadata/types/tmdb";
import { usePreferencesStore } from "@/stores/preferences";
import { MediaItem } from "@/utils/mediaTypes";
interface InfoPopoutProps {
media: MediaItem;
visible: boolean;
}
// Add interface for storing additional details
interface AdditionalDetails {
runtime?: number | null;
genres?: { id: number; name: string }[];
language?: string;
episodes?: number;
seasons?: number;
voteAverage?: number;
voteCount?: number;
}
function InfoSkeleton() {
return (
<div className="animate-pulse">
<div className="relative h-40">
<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>
<div className="px-4 pb-4 mt-[-30px]">
<div className="h-7 w-3/4 bg-white/10 rounded mb-2" /> {/* Title */}
<div className="space-y-2 mb-4">
{/* 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-2 mb-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-1 mt-4">
<div className="h-5 w-16 bg-white/10 rounded-full" />
<div className="h-5 w-20 bg-white/10 rounded-full" />
<div className="h-5 w-14 bg-white/10 rounded-full" />
</div>
</div>
</div>
);
}
export function InfoPopout({ media, visible }: InfoPopoutProps) {
const [description, setDescription] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const [backdrop, setBackdrop] = useState<string | undefined>();
const [dataLoaded, setDataLoaded] = useState(false);
const [shouldShow, setShouldShow] = useState(false);
const [additionalDetails, setAdditionalDetails] = useState<AdditionalDetails>(
{},
);
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
const enablePopDetails = usePreferencesStore((s) => s.enablePopDetails);
const fetchData = useCallback(async () => {
if (!enablePopDetails) return;
if (dataLoaded) return; // Skip if already loaded
setIsLoading(true);
try {
const type =
media.type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV;
const details = await getMediaDetails(media.id, type);
const backdropUrl = getMediaBackdrop(details.backdrop_path);
setDescription(details.overview || undefined);
setBackdrop(backdropUrl);
if (type === TMDBContentTypes.MOVIE) {
const movieDetails = details as TMDBMovieData;
setAdditionalDetails({
runtime: movieDetails.runtime,
genres: movieDetails.genres,
language: movieDetails.original_language,
voteAverage: movieDetails.vote_average,
voteCount: movieDetails.vote_count,
});
} else {
const showDetails = details as TMDBShowData;
setAdditionalDetails({
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,
});
}
setDataLoaded(true);
} catch (err) {
console.error("Failed to fetch media details:", err);
} finally {
setIsLoading(false);
}
}, [enablePopDetails, dataLoaded, media.type, media.id]);
// Start loading data when user hovers
useEffect(() => {
if (visible && !dataLoaded && !isLoading && enablePopDetails) {
fetchData();
}
}, [visible, dataLoaded, isLoading, enablePopDetails, fetchData]);
useEffect(() => {
// Start timer when user hovers
if (visible && !shouldShow) {
hoverTimerRef.current = setTimeout(() => {
setShouldShow(true);
}, 200); // 0.2s
}
if (!visible && shouldShow) {
setShouldShow(false);
}
return () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
};
}, [visible, shouldShow]);
const showPopout = visible && shouldShow;
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 formatVoteCount = (count?: number) => {
if (!count) return "0";
if (count >= 1000) {
return `${Math.floor(count / 1000)}K+`;
}
return count.toString();
};
return (
<div
className={classNames(
"absolute left-[calc(100%+12px)] top-1/2 -translate-y-1/2 ml-1 w-[280px] rounded-xl overflow-hidden transition-all duration-300",
"backdrop-blur-md bg-mediaCard-hoverBackground border border-mediaCard-hoverAccent/40",
"z-[999]",
showPopout
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-4 pointer-events-none",
)}
onMouseEnter={() => media.onHoverInfoEnter?.()}
onMouseLeave={() => media.onHoverInfoLeave?.()}
>
<div className="p-0">
{isLoading ? (
<InfoSkeleton />
) : (
<div className="relative">
{backdrop && (
<div className="absolute top-0 left-0 right-0 h-full z-0">
<img
src={backdrop}
alt={media.title}
className="w-full h-40 object-cover"
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>
)}
<div className="relative z-10">
<div className="h-40" /> {/* Spacer for backdrop height */}
<div className="px-4 pb-4 mt-[-30px]">
<h3 className="text-lg font-bold text-white mb-2">
{media.title}
</h3>
{description && (
<p className="text-sm text-white/90 mb-4 line-clamp-4">
{description}
</p>
)}
{/* Additional Details Section */}
<div className="grid grid-cols-2 gap-2 text-xs">
{additionalDetails.runtime && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Runtime:</span>{" "}
{formatRuntime(additionalDetails.runtime)}
</div>
)}
{additionalDetails.language && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Language:</span>{" "}
{additionalDetails.language.toUpperCase()}
</div>
)}
{additionalDetails.voteAverage !== undefined &&
additionalDetails.voteCount !== undefined &&
additionalDetails.voteCount > 0 && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Rating:</span>{" "}
{additionalDetails.voteAverage.toFixed(1)}/10
<span className="text-white/60 text-[10px]">
({formatVoteCount(additionalDetails.voteCount)})
</span>
</div>
)}
</div>
{/* Genres */}
{additionalDetails.genres &&
additionalDetails.genres.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1">
{additionalDetails.genres.slice(0, 3).map((genre) => (
<span
key={genre.id}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-white/10 text-white/70"
>
{genre.name}
</span>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -38,10 +38,17 @@ export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
media.year === undefined ? "hover:opacity-100" : "hover:opacity-95";
return (
<IconPatch
onClick={toggleBookmark}
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE}
className={`${buttonOpacityClass} p-2 opacity-75 transition-opacity transition-transform duration-300 hover:scale-110 hover:cursor-pointer`}
/>
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleBookmark();
}}
>
<IconPatch
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE}
className={`${buttonOpacityClass} p-2 opacity-75 transition-opacity transition-transform duration-300 hover:scale-110 hover:cursor-pointer`}
/>
</div>
);
}

View file

@ -17,7 +17,8 @@ import { MediaBookmarkButton } from "./MediaBookmark";
import { Button } from "../buttons/Button";
import { IconPatch } from "../buttons/IconPatch";
import { Icon, Icons } from "../Icon";
import { InfoPopout } from "./InfoPopout";
import { DetailsModal } from "../overlays/DetailsModal";
import { useModal } from "../overlays/Modal";
export interface MediaCardProps {
media: MediaItem;
@ -31,6 +32,7 @@ export interface MediaCardProps {
percentage?: number;
closable?: boolean;
onClose?: () => void;
onShowDetails?: (media: MediaItem) => void;
}
function checkReleased(media: MediaItem): boolean {
@ -64,6 +66,7 @@ function MediaCardContent({
handleMouseLeave,
link,
isHoveringCard,
onShowDetails,
}: MediaCardProps & {
overlayVisible: boolean;
setOverlayVisible: React.Dispatch<React.SetStateAction<boolean>>;
@ -102,20 +105,6 @@ function MediaCardContent({
dotListContent.push(t("media.unreleased"));
}
const handleMoreInfoClick = (
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
e.preventDefault();
const searchParam = encodeURIComponent(encodeURI(media.id));
const url =
media.type === "movie"
? `https://www.themoviedb.org/movie/${searchParam}`
: `https://www.themoviedb.org/tv/${searchParam}`;
window.open(url, "_blank");
};
const handleCopyClick = (
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
@ -267,12 +256,16 @@ function MediaCardContent({
<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", // Button Size & Hover
"text-md text-white flex items-center justify-center", // Centering Content
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md", // Background
"border-2 border-gray-400 border-opacity-20", // Border
"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",
)}
onClick={handleMoreInfoClick}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (onShowDetails) onShowDetails(media);
}}
>
More Info
</Button>
@ -281,10 +274,10 @@ function MediaCardContent({
<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", // Button Size & Hover
"text-md text-white flex items-center justify-center", // Centering Content
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md", // Background
"border-2 border-gray-400 border-opacity-20", // Border
"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",
)}
href={link}
onClick={handleCopyClick}
@ -303,10 +296,10 @@ function MediaCardContent({
<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", // Button Size & Hover
"text-md text-white flex items-center justify-center", // Centering Content
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md", // Background
"border-2 border-gray-400 border-opacity-20", // Border
"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",
)}
onClick={() => setOverlayVisible(false)}
>
@ -368,23 +361,19 @@ function MediaCardContent({
}
export function MediaCard(props: MediaCardProps) {
const { media, onShowDetails } = props;
const [overlayVisible, setOverlayVisible] = useState(false);
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
const [showHoverInfo, setShowHoverInfo] = useState(false);
const hoverTimer = useRef<NodeJS.Timeout>();
const [isHoveringCard, setIsHoveringCard] = useState(false);
const [isHoveringInfo, setIsHoveringInfo] = useState(false);
const [isBigScreen, setIsBigScreen] = useState(false);
const enablePopDetails = usePreferencesStore((s) => s.enablePopDetails);
useEffect(() => {
const checkScreenSize = () => {
setIsBigScreen(window.innerWidth >= 768); // md breakpoint
};
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", checkScreenSize);
}, []);
const [detailsData, setDetailsData] = useState<{
id: number;
type: "movie" | "show";
} | null>(null);
const detailsModal = useModal("details");
const enableDetailsModal = usePreferencesStore(
(state) => state.enableDetailsModal,
);
const handleMouseEnter = () => {
setIsHoveringCard(true);
@ -397,12 +386,6 @@ export function MediaCard(props: MediaCardProps) {
if (hoverTimer.current) {
clearTimeout(hoverTimer.current);
}
if (isBigScreen && !overlayVisible) {
hoverTimer.current = setTimeout(() => {
setShowHoverInfo(true);
}, 200); // 0.2 second delay
}
};
const handleMouseLeave = () => {
@ -411,22 +394,16 @@ export function MediaCard(props: MediaCardProps) {
clearTimeout(hoverTimer.current);
}
if (!isHoveringInfo) {
setShowHoverInfo(false);
}
const id = setTimeout(() => {
setOverlayVisible(false);
}, 2000); // 2 seconds
setTimeoutId(id);
};
const shouldShowHoverInfo =
showHoverInfo &&
!overlayVisible &&
isBigScreen &&
enablePopDetails &&
!props.closable;
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setOverlayVisible(true);
};
const isReleased = useCallback(
() => checkReleased(props.media),
@ -448,34 +425,58 @@ export function MediaCard(props: MediaCardProps) {
}
}
const hoverMedia = {
...props.media,
onHoverInfoEnter: () => setIsHoveringInfo(true),
onHoverInfoLeave: () => {
setIsHoveringInfo(false);
if (!isHoveringCard && !overlayVisible) {
setShowHoverInfo(false);
}
},
const handleShowDetails = useCallback(async () => {
if (onShowDetails) {
onShowDetails(media);
return;
}
setDetailsData({
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
});
detailsModal.show();
}, [media, detailsModal, onShowDetails]);
const handleCardClick = (e: React.MouseEvent) => {
if (enableDetailsModal && canLink) {
e.preventDefault();
handleShowDetails();
} else if (overlayVisible || e.defaultPrevented) {
e.preventDefault();
}
};
const content = (
<MediaCardContent
{...props}
overlayVisible={overlayVisible}
setOverlayVisible={setOverlayVisible}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
link={link}
isHoveringCard={isHoveringCard}
/>
<>
<MediaCardContent
{...props}
overlayVisible={overlayVisible}
setOverlayVisible={setOverlayVisible}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
link={link}
isHoveringCard={isHoveringCard}
onShowDetails={handleShowDetails}
/>
{detailsData && <DetailsModal id="details" data={detailsData} />}
</>
);
if (!canLink)
return (
<span className="relative">
<span
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onContextMenu={handleContextMenu}
onClick={(e) => {
if (overlayVisible || e.defaultPrevented) {
e.preventDefault();
}
}}
>
{content}{" "}
<InfoPopout media={hoverMedia} visible={shouldShowHoverInfo} />
</span>
);
return (
@ -490,8 +491,19 @@ export function MediaCard(props: MediaCardProps) {
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onContextMenu={handleContextMenu}
onClick={handleCardClick}
>
{content}
<MediaCardContent
{...props}
overlayVisible={overlayVisible}
setOverlayVisible={setOverlayVisible}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
link={link}
isHoveringCard={isHoveringCard}
onShowDetails={handleShowDetails}
/>
</Link>
) : (
<div
@ -502,14 +514,20 @@ export function MediaCard(props: MediaCardProps) {
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onContextMenu={handleContextMenu}
>
{content}
<MediaCardContent
{...props}
overlayVisible={overlayVisible}
setOverlayVisible={setOverlayVisible}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
link={link}
isHoveringCard={isHoveringCard}
onShowDetails={handleShowDetails}
/>
</div>
)}
{shouldShowHoverInfo && (
<InfoPopout media={hoverMedia} visible={shouldShowHoverInfo} />
)}
</div>
);
}

View file

@ -23,6 +23,7 @@ export interface WatchedMediaCardProps {
media: MediaItem;
closable?: boolean;
onClose?: () => void;
onShowDetails?: (media: MediaItem) => void;
}
export function WatchedMediaCard(props: WatchedMediaCardProps) {
@ -46,6 +47,7 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
percentage={percentage}
onClose={props.onClose}
closable={props.closable}
onShowDetails={props.onShowDetails}
/>
);
}

View file

@ -0,0 +1,855 @@
import classNames from "classnames";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { Link } from "react-router-dom";
import { getMediaBackdrop, getMediaDetails } from "@/backend/metadata/tmdb";
import {
TMDBContentTypes,
TMDBMovieData,
TMDBShowData,
} from "@/backend/metadata/types/tmdb";
import { Dropdown } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { hasAired } from "@/components/player/utils/aired";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { scrapeIMDb } from "@/utils/imdbScraper";
import { useModal } from "./Modal";
import { OverlayPortal } from "./OverlayDisplay";
import { Button } from "../buttons/Button";
import { IconPatch } from "../buttons/IconPatch";
import { Flare } from "../utils/Flare";
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>
);
}
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;
}>;
};
}
function DetailsContent({
data,
minimal = false,
}: {
data: DetailsContent;
minimal?: boolean;
}) {
const [imdbData, setImdbData] = useState<any>(null);
const [, setIsLoadingImdb] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const progress = useProgressStore((s) => s.items);
const carouselRef = useRef<HTMLDivElement>(null);
const activeEpisodeRef = useRef<HTMLAnchorElement>(null);
const addBookmark = useBookmarkStore((s) => s.addBookmark);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const isBookmarked = !!bookmarks[data.id?.toString() ?? ""];
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
const [selectedSeason, setSelectedSeason] = useState<number>(() => {
if (showProgress?.season?.number) {
return showProgress.season.number;
}
return 1;
});
// Update selected season when showProgress changes
useEffect(() => {
if (showProgress?.season?.number) {
setSelectedSeason(showProgress.season.number);
}
}, [showProgress]);
const toggleBookmark = useCallback(() => {
if (!data.id) return;
if (isBookmarked) {
removeBookmark(data.id.toString());
} else {
addBookmark({
tmdbId: data.id.toString(),
type: data.type ?? "movie",
title: data.title,
releaseYear: data.releaseDate
? new Date(data.releaseDate).getFullYear()
: 0,
poster: data.backdrop,
});
}
}, [data, isBookmarked, addBookmark, removeBookmark]);
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !videoRef.current.muted;
setIsMuted(!isMuted);
}
};
const togglePlay = () => {
if (videoRef.current) {
if (videoRef.current.paused) {
videoRef.current.play();
setIsPaused(false);
} else {
videoRef.current.pause();
setIsPaused(true);
}
}
};
useEffect(() => {
const fetchImdbData = async () => {
if (!data.imdbId) return;
setIsLoadingImdb(true);
try {
const imdbMetadata = await scrapeIMDb(data.imdbId);
setImdbData(imdbMetadata);
} catch (error) {
console.error("Failed to fetch IMDb data:", error);
} finally {
setIsLoadingImdb(false);
}
};
fetchImdbData();
}, [data.imdbId]);
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 formatVoteCount = (count?: number) => {
if (!count) return "0";
if (count >= 1000) {
return `${Math.floor(count / 1000)}K+`;
}
return count.toString();
};
const formatDate = (dateString?: string) => {
if (!dateString) return null;
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
// Function to get color based on rating
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";
};
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",
});
};
const currentSeasonEpisodes = data.seasonData?.episodes.filter(
(ep) => ep.season_number === selectedSeason,
);
// Function to generate the episode URL
const getEpisodeUrl = (episode: any) => {
// Find the season ID for the current season
const season = data.seasonData?.seasons.find(
(s) => s.season_number === selectedSeason,
);
if (!season || !data.id) return "#";
// Create the URL in the format: /media/tmdb-tv-{showId}-{showName}/{seasonId}/{episodeId}
return `/media/tmdb-tv-${data.id}-${data.title.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",
});
}
}
}, [currentSeasonEpisodes, showProgress]);
return (
<div className="relative h-full flex flex-col">
{/* Backdrop - Even taller */}
<div className="h-64 lg:h-80 xl:h-96 relative -mt-12">
{imdbData?.trailer_url ? (
<div
className="absolute inset-0"
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)",
}}
>
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover cursor-pointer"
autoPlay
loop
muted
playsInline
poster={data.backdrop}
onClick={togglePlay}
>
<source src={imdbData.trailer_url} type="video/mp4" />
</video>
<button
type="button"
onClick={toggleMute}
className="absolute top-4 left-4 z-10 p-2 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
title={isMuted ? "Unmute" : "Mute"}
>
<Icon
icon={isMuted ? Icons.VOLUME_X : Icons.VOLUME}
className="text-white"
/>
</button>
{isPaused && (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center z-10"
title="Play"
>
<Icon icon={Icons.PLAY} className="text-white text-4xl" />
</button>
)}
</div>
) : (
<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-[-100px] flex-grow">
{/* Title and Genres Row */}
<div className="pb-4">
{!minimal && (
<Button
onClick={() => {
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, "-")}`,
);
}
}
}}
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",
)}
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white text-sm pr-1">
{showProgress ? "Resume" : "Play"}
</span>
</Button>
)}
</div>
<div className="flex flex-col sm:flex-row justify-between items-start mb-6">
<div className="flex items-center gap-4">
<h3 className="text-2xl font-bold text-white z-[999]">
{data.title}
</h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleBookmark}
className="p-2 pt-3 hover:scale-110 transition-transform"
title={isBookmarked ? "Remove Bookmark" : "Add Bookmark"}
>
<Icon
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE}
className="text-white"
/>
</button>
</div>
</div>
{data.genres && data.genres.length > 0 && (
<div className="flex flex-wrap gap-2 justify-start sm:justify-end z-[999] items-center pt-2">
{data.genres.map((genre) => (
<span
key={genre.id}
className="text-[11px] px-2 py-0.5 rounded-full bg-white/20 text-white/80"
>
{genre.name}
</span>
))}
</div>
)}
</div>
{/* 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">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">Cast:</span>{" "}
<span className="text-white/70">
{data.actors.join(", ")}
</span>
</div>
)}
</div>
</div>
{/* Right Column - Details */}
<div className="md:col-span-1">
<div className="space-y-3 text-xs">
{data.runtime && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Runtime:</span>{" "}
{formatRuntime(data.runtime)}
</div>
)}
{data.language && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Language:</span>{" "}
{data.language.toUpperCase()}
</div>
)}
{data.releaseDate && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Release Date:</span>{" "}
{formatDate(data.releaseDate)}
</div>
)}
{data.rating && (
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Rating:</span> {data.rating}
</div>
)}
{data.voteAverage !== undefined &&
data.voteCount !== undefined &&
data.voteCount > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-white/80">
<span className="font-medium">Rating:</span>{" "}
<span className="text-white/90">
{imdbData?.imdb_rating
? `${imdbData.imdb_rating.toFixed(1)}/10 (IMDb)`
: `${data.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 || data.voteAverage)} transition-all duration-500`}
style={{
width: `${((imdbData?.imdb_rating || data.voteAverage) / 10) * 100}%`,
}}
/>
</div>
<div className="text-white/60 text-[10px] text-right">
{formatVoteCount(imdbData?.votes || data.voteCount)} votes
</div>
{/* External Links */}
<div className="flex gap-3 mt-2">
{data.id && (
<a
href={`https://www.themoviedb.org/${data.type === "show" ? "tv" : "movie"}/${data.id}`}
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="View on TMDB"
>
<Icon icon={Icons.TMDB} className="text-white" />
</a>
)}
{data.imdbId && (
<a
href={`https://www.imdb.com/title/${data.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="View on IMDB"
>
<Icon icon={Icons.IMDB} className="text-black" />
</a>
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Episodes Carousel for TV Shows */}
{data.type === "show" && data.seasonData && !minimal && (
<div className="mt-6 md:mt-0">
{/* Season Selector */}
<div className="flex justify-between items-center mb-3">
<h4 className="text-lg font-semibold text-white">Episodes</h4>
<Dropdown
options={data.seasonData.seasons.map((season) => ({
id: season.season_number.toString(),
name: `Season ${season.season_number}`,
}))}
selectedItem={{
id: selectedSeason.toString(),
name: `Season ${selectedSeason}`,
}}
setSelectedItem={(item) => setSelectedSeason(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"
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[data.id?.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-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-video-context-hoverColor/50 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">
E{episode.episode_number}
</span>
{!isAired && (
<span className="text-video-context-type-main/70 text-sm">
{episode.air_date
? `(Airs - ${new Date(episode.air_date).toLocaleDateString()})`
: "(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>
)}
</div>
</div>
);
}
export function DetailsModal(props: {
id: string;
data?: {
id: number;
type: "movie" | "show";
};
minimal?: boolean;
}) {
const modal = useModal(props.id);
const [detailsData, setDetailsData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchDetails = async () => {
if (!props.data?.id || !props.data?.type) return;
setIsLoading(true);
try {
const type =
props.data.type === "movie"
? TMDBContentTypes.MOVIE
: TMDBContentTypes.TV;
const details = await getMediaDetails(props.data.id.toString(), type);
const backdropUrl = getMediaBackdrop(details.backdrop_path);
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,
});
} 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,
},
});
}
} catch (err) {
console.error("Failed to fetch media details:", err);
} finally {
setIsLoading(false);
}
};
if (modal.isShown && props.data?.id) {
fetchDetails();
}
}, [modal.isShown, props.data]);
useEffect(() => {
if (modal.isShown && !props.data?.id && !isLoading) {
modal.hide();
}
}, [modal, props.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",
detailsData?.type === "movie" || props.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={500}
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">
<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"
onClick={modal.hide}
>
<IconPatch icon={Icons.X} />
</button>
</div>
<div className="pt-12">
{isLoading || !detailsData ? (
<DetailsSkeleton />
) : (
<DetailsContent data={detailsData} minimal={props.minimal} />
)}
</div>
</Flare.Child>
</div>
</Flare.Base>
</div>
</OverlayPortal>
);
}

View file

@ -40,11 +40,13 @@ export function usePlayerMeta() {
number: v.number,
title: v.title,
tmdbId: v.id,
air_date: v.air_date,
})),
episode: {
number: ep.number,
title: ep.title,
tmdbId: ep.id,
air_date: ep.air_date,
},
season: {
number: m.meta.seasonData.number,

View file

@ -1,27 +1,41 @@
import { useState } from "react";
import { Icons } from "@/components/Icon";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import { usePlayerStore } from "@/stores/player/store";
import { VideoPlayerButton } from "./Button";
export function InfoButton() {
const meta = usePlayerStore((s) => s.meta);
const modal = useModal("player-details");
const [detailsData, setDetailsData] = useState<{
id: number;
type: "movie" | "show";
} | null>(null);
const handleClick = async () => {
if (!meta?.tmdbId) return;
setDetailsData({
id: Number(meta.tmdbId),
type: meta.type === "movie" ? "movie" : "show",
});
modal.show();
};
return (
<VideoPlayerButton
icon={Icons.CIRCLE_QUESTION}
iconSizeClass="text-base"
className="p-2 !-mr-1"
onClick={() => {
if (!meta?.tmdbId) return;
const id = meta.tmdbId;
let url;
if (meta.type === "movie") {
url = `https://www.themoviedb.org/movie/${id}`;
} else {
url = `https://www.themoviedb.org/tv/${id}`;
}
window.open(url, "_blank");
}}
/>
<>
<VideoPlayerButton
icon={Icons.CIRCLE_QUESTION}
iconSizeClass="text-base"
className="p-2 !-mr-2"
onClick={handleClick}
/>
{detailsData && (
<DetailsModal id="player-details" data={detailsData} minimal />
)}
</>
);
}

View file

@ -54,7 +54,7 @@ export function useSettingsState(
enableThumbnails: boolean,
enableAutoplay: boolean,
enableDiscover: boolean,
enablePopDetails: boolean,
enableDetailsModal: boolean,
sourceOrder: string[],
enableSourceOrder: boolean,
proxyTmdb: boolean,
@ -117,11 +117,11 @@ export function useSettingsState(
enableDiscoverChanged,
] = useDerived(enableDiscover);
const [
enablePopDetailsState,
setEnablePopDetailsState,
resetEnablePopDetails,
enablePopDetailsChanged,
] = useDerived(enablePopDetails);
enableDetailsModalState,
setEnableDetailsModalState,
resetEnableDetailsModal,
enableDetailsModalChanged,
] = useDerived(enableDetailsModal);
const [
sourceOrderState,
setSourceOrderState,
@ -151,7 +151,7 @@ export function useSettingsState(
resetEnableAutoplay();
resetEnableSkipCredits();
resetEnableDiscover();
resetEnablePopDetails();
resetEnableDetailsModal();
resetSourceOrder();
resetEnableSourceOrder();
resetProxyTmdb();
@ -170,7 +170,7 @@ export function useSettingsState(
enableAutoplayChanged ||
enableSkipCreditsChanged ||
enableDiscoverChanged ||
enablePopDetailsChanged ||
enableDetailsModalChanged ||
sourceOrderChanged ||
enableSourceOrderChanged ||
proxyTmdbChanged;
@ -238,10 +238,10 @@ export function useSettingsState(
set: setEnableDiscoverState,
changed: enableDiscoverChanged,
},
enablePopDetails: {
state: enablePopDetailsState,
set: setEnablePopDetailsState,
changed: enablePopDetailsChanged,
enableDetailsModal: {
state: enableDetailsModalState,
set: setEnableDetailsModalState,
changed: enableDetailsModalChanged,
},
sourceOrder: {
state: sourceOrderState,

View file

@ -4,6 +4,8 @@ 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 { useModal } from "@/components/overlays/Modal";
import { useDebounce } from "@/hooks/useDebounce";
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
import { useSearchQuery } from "@/hooks/useSearchQuery";
@ -15,6 +17,7 @@ import { WatchingPart } from "@/pages/parts/home/WatchingPart";
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
import { usePreferencesStore } from "@/stores/preferences";
import { MediaItem } from "@/utils/mediaTypes";
import { Button } from "./About";
@ -50,7 +53,9 @@ export function HomePage() {
const s = useSearch(search);
const [showBookmarks, setShowBookmarks] = useState(false);
const [showWatching, setShowWatching] = useState(false);
// const modal = useModal("notice");
const [detailsData, setDetailsData] = useState<any>();
// const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const detailsModal = useModal("details");
const handleClick = (path: To) => {
window.scrollTo(0, 0);
@ -59,6 +64,14 @@ export function HomePage() {
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
const handleShowDetails = async (media: MediaItem) => {
setDetailsData({
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
});
detailsModal.show();
};
// const { loggedIn } = useAuth(); // Adjust padding for popup show button based on logged in state
return (
@ -184,11 +197,20 @@ export function HomePage() {
{s.loading ? (
<SearchLoadingPart />
) : s.searching ? (
<SearchListPart searchQuery={search} />
<SearchListPart
searchQuery={search}
onShowDetails={handleShowDetails}
/>
) : (
<div className="flex flex-col gap-8">
<WatchingPart onItemsChange={setShowWatching} />
<BookmarksPart onItemsChange={setShowBookmarks} />
<WatchingPart
onItemsChange={setShowWatching}
onShowDetails={handleShowDetails}
/>
<BookmarksPart
onItemsChange={setShowBookmarks}
onShowDetails={handleShowDetails}
/>
</div>
)}
{!(showBookmarks || showWatching) && !enableDiscover ? (
@ -213,6 +235,8 @@ export function HomePage() {
</div>
</div>
)}
{detailsData && <DetailsModal id="details" data={detailsData} />}
</HomeLayout>
);
}

View file

@ -151,8 +151,10 @@ export function SettingsPage() {
const enableDiscover = usePreferencesStore((s) => s.enableDiscover);
const setEnableDiscover = usePreferencesStore((s) => s.setEnableDiscover);
const enablePopDetails = usePreferencesStore((s) => s.enablePopDetails);
const setEnablePopDetails = usePreferencesStore((s) => s.setEnablePopDetails);
const enableDetailsModal = usePreferencesStore((s) => s.enableDetailsModal);
const setEnableDetailsModal = usePreferencesStore(
(s) => s.setEnableDetailsModal,
);
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
const setEnableSourceOrder = usePreferencesStore(
@ -199,7 +201,7 @@ export function SettingsPage() {
enableThumbnails,
enableAutoplay,
enableDiscover,
enablePopDetails,
enableDetailsModal,
sourceOrder,
enableSourceOrder,
proxyTmdb,
@ -277,7 +279,7 @@ export function SettingsPage() {
setEnableAutoplay(state.enableAutoplay.state);
setEnableSkipCredits(state.enableSkipCredits.state);
setEnableDiscover(state.enableDiscover.state);
setEnablePopDetails(state.enablePopDetails.state);
setEnableDetailsModal(state.enableDetailsModal.state);
setSourceOrder(state.sourceOrder.state);
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
@ -311,7 +313,7 @@ export function SettingsPage() {
setEnableAutoplay,
setEnableSkipCredits,
setEnableDiscover,
setEnablePopDetails,
setEnableDetailsModal,
setSourceOrder,
setAppLanguage,
setTheme,
@ -380,8 +382,8 @@ export function SettingsPage() {
setTheme={setThemeWithPreview}
enableDiscover={state.enableDiscover.state}
setEnableDiscover={state.enableDiscover.set}
enablePopDetails={state.enablePopDetails.state}
setEnablePopDetails={state.enablePopDetails.set}
enableDetailsModal={state.enableDetailsModal.state}
setEnableDetailsModal={state.enableDetailsModal.set}
/>
</div>
<div id="settings-captions" className="mt-28">

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { Category, Genre, Media } from "@/pages/discover/common";
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
import { useLazyTMDBData } from "@/pages/discover/hooks/useTMDBData";
import { MediaItem } from "@/utils/mediaTypes";
import { MediaCarousel } from "./MediaCarousel";
@ -16,6 +17,7 @@ interface LazyMediaCarouselProps {
}>;
preloadedMedia?: Media[];
title?: string;
onShowDetails?: (media: MediaItem) => void;
}
export function LazyMediaCarousel({
@ -26,6 +28,7 @@ export function LazyMediaCarousel({
carouselRefs,
preloadedMedia,
title,
onShowDetails,
}: LazyMediaCarouselProps) {
const [medias, setMedias] = useState<Media[]>([]);
@ -71,6 +74,7 @@ export function LazyMediaCarousel({
isTVShow={mediaType === "tv"}
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={onShowDetails}
/>
) : (
<div className="relative overflow-hidden carousel-container">

View file

@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { MediaCard } from "@/components/media/MediaCard";
import { Media } from "@/pages/discover/common";
import { MediaItem } from "@/utils/mediaTypes";
import { CarouselNavButtons } from "./CarouselNavButtons";
@ -13,6 +14,7 @@ interface MediaCarouselProps {
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
onShowDetails?: (media: MediaItem) => void;
}
function MediaCardSkeleton() {
@ -32,6 +34,7 @@ export function MediaCarousel({
isTVShow,
isMobile,
carouselRefs,
onShowDetails,
}: MediaCarouselProps) {
const { t } = useTranslation();
const categorySlug = `${category.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
@ -139,6 +142,7 @@ export function MediaCarousel({
? parseInt(media.release_date.split("-")[0], 10)
: undefined,
}}
onShowDetails={onShowDetails}
/>
</div>
))

View file

@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { get } from "@/backend/metadata/tmdb";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import { useIsMobile } from "@/hooks/useIsMobile";
import {
Genre,
@ -11,6 +13,7 @@ import {
tvCategories,
} from "@/pages/discover/common";
import { conf } from "@/setup/config";
import { MediaItem } from "@/utils/mediaTypes";
import "./discover.css";
import { CategoryButtons } from "./components/CategoryButtons";
@ -113,6 +116,8 @@ export function DiscoverContent() {
const [providerTVShows, setProviderTVShows] = useState<any[]>([]);
const [editorPicksMovies, setEditorPicksMovies] = useState<Movie[]>([]);
const [editorPicksTVShows, setEditorPicksTVShows] = useState<any[]>([]);
const [detailsData, setDetailsData] = useState<any>();
const detailsModal = useModal("discover-details");
// Refs
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
@ -314,6 +319,14 @@ export function DiscoverContent() {
}
};
const handleShowDetails = async (media: MediaItem) => {
setDetailsData({
id: Number(media.id),
type: media.type === "movie" ? "movie" : "show",
});
detailsModal.show();
};
// Render Editor Picks content
const renderEditorPicksContent = () => {
return (
@ -324,6 +337,7 @@ export function DiscoverContent() {
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
<LazyMediaCarousel
preloadedMedia={editorPicksTVShows}
@ -331,6 +345,7 @@ export function DiscoverContent() {
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
</>
);
@ -348,6 +363,7 @@ export function DiscoverContent() {
isTVShow={false}
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
)}
@ -359,6 +375,7 @@ export function DiscoverContent() {
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
@ -370,6 +387,7 @@ export function DiscoverContent() {
mediaType="movie"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
</>
@ -388,6 +406,7 @@ export function DiscoverContent() {
isTVShow
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
)}
@ -399,6 +418,7 @@ export function DiscoverContent() {
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
@ -410,6 +430,7 @@ export function DiscoverContent() {
mediaType="tv"
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
))}
</>
@ -496,6 +517,8 @@ export function DiscoverContent() {
</div>
<ScrollToTopButton />
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
</div>
);
}

View file

@ -15,8 +15,10 @@ const LONG_PRESS_DURATION = 500; // 0.5 seconds
export function BookmarksPart({
onItemsChange,
onShowDetails,
}: {
onItemsChange: (hasItems: boolean) => void;
onShowDetails?: (media: MediaItem) => void;
}) {
const { t } = useTranslation();
const progressItems = useProgressStore((s) => s.items);
@ -102,20 +104,21 @@ export function BookmarksPart({
<MediaGrid ref={gridRef}>
{items.map((v) => (
<div
style={{ userSelect: "none" }} // Disable text selection
key={v.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
} // Prevent right-click context menu
onTouchStart={handleTouchStart} // Handle touch start
onTouchEnd={handleTouchEnd} // Handle touch end
onMouseDown={handleMouseDown} // Handle mouse down
onMouseUp={handleMouseUp} // Handle mouse up
}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<WatchedMediaCard
key={v.id}
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
/>
</div>
))}

View file

@ -15,8 +15,10 @@ const LONG_PRESS_DURATION = 500; // 0.5 seconds
export function WatchingPart({
onItemsChange,
onShowDetails,
}: {
onItemsChange: (hasItems: boolean) => void;
onShowDetails?: (media: MediaItem) => void;
}) {
const { t } = useTranslation();
const progressItems = useProgressStore((s) => s.items);
@ -94,20 +96,21 @@ export function WatchingPart({
<MediaGrid ref={gridRef}>
{sortedProgressItems.map((v) => (
<div
style={{ userSelect: "none" }} // Disable text selection
key={v.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
} // Prevent right-click context menu
onTouchStart={handleTouchStart} // Handle touch start
onTouchEnd={handleTouchEnd} // Handle touch end
onMouseDown={handleMouseDown} // Handle mouse down
onMouseUp={handleMouseUp} // Handle mouse up
}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<WatchedMediaCard
key={v.id}
media={v}
closable={editing}
onClose={() => removeItem(v.id)}
onShowDetails={onShowDetails}
/>
</div>
))}

View file

@ -57,7 +57,13 @@ function SearchSuffix(props: { failed?: boolean; results?: number }) {
);
}
export function SearchListPart({ searchQuery }: { searchQuery: string }) {
export function SearchListPart({
searchQuery,
onShowDetails,
}: {
searchQuery: string;
onShowDetails?: (media: MediaItem) => void;
}) {
const { t } = useTranslation();
const [results, setResults] = useState<MediaItem[]>([]);
@ -87,7 +93,11 @@ export function SearchListPart({ searchQuery }: { searchQuery: string }) {
/>
<MediaGrid>
{results.map((v) => (
<WatchedMediaCard key={v.id.toString()} media={v} />
<WatchedMediaCard
key={v.id.toString()}
media={v}
onShowDetails={onShowDetails}
/>
))}
</MediaGrid>
</div>

View file

@ -154,8 +154,8 @@ export function AppearancePart(props: {
enableDiscover: boolean;
setEnableDiscover: (v: boolean) => void;
enablePopDetails: boolean;
setEnablePopDetails: (v: boolean) => void;
enableDetailsModal: boolean;
setEnableDetailsModal: (v: boolean) => void;
}) {
const { t } = useTranslation();
@ -185,18 +185,23 @@ export function AppearancePart(props: {
</div>
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.hover")}
{t("settings.appearance.options.modal")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.hoverDescription")}
{t("settings.appearance.options.modalDescription")}
</p>
<div
onClick={() => props.setEnablePopDetails(!props.enablePopDetails)}
className="opacity-50 md:opacity-100 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"
onClick={() =>
props.setEnableDetailsModal(!props.enableDetailsModal)
}
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.enablePopDetails} />
<Toggle enabled={props.enableDetailsModal} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.hoverLabel")}
{t("settings.appearance.options.modalLabel")}
</p>
</div>
</div>

View file

@ -23,6 +23,7 @@ export interface PlayerMetaEpisode {
number: number;
tmdbId: string;
title: string;
air_date?: string;
}
export interface PlayerMeta {

View file

@ -7,7 +7,7 @@ export interface PreferencesStore {
enableAutoplay: boolean;
enableSkipCredits: boolean;
enableDiscover: boolean;
enablePopDetails: boolean;
enableDetailsModal: boolean;
sourceOrder: string[];
enableSourceOrder: boolean;
proxyTmdb: boolean;
@ -16,7 +16,7 @@ export interface PreferencesStore {
setEnableAutoplay(v: boolean): void;
setEnableSkipCredits(v: boolean): void;
setEnableDiscover(v: boolean): void;
setEnablePopDetails(v: boolean): void;
setEnableDetailsModal(v: boolean): void;
setSourceOrder(v: string[]): void;
setEnableSourceOrder(v: boolean): void;
setProxyTmdb(v: boolean): void;
@ -29,7 +29,7 @@ export const usePreferencesStore = create(
enableAutoplay: true,
enableSkipCredits: true,
enableDiscover: true,
enablePopDetails: false,
enableDetailsModal: false,
sourceOrder: [],
enableSourceOrder: false,
proxyTmdb: false,
@ -53,9 +53,9 @@ export const usePreferencesStore = create(
s.enableDiscover = v;
});
},
setEnablePopDetails(v) {
setEnableDetailsModal(v) {
set((s) => {
s.enablePopDetails = v;
s.enableDetailsModal = v;
});
},
setSourceOrder(v) {

12
src/utils/hasProxy.ts Normal file
View file

@ -0,0 +1,12 @@
import { isExtensionActive } from "@/backend/extension/messaging";
import { useAuthStore } from "@/stores/auth";
let hasExtension: boolean | null = null;
export async function hasProxyCheck(): Promise<boolean> {
if (hasExtension === null) {
hasExtension = await isExtensionActive();
}
const hasProxy = Boolean(useAuthStore.getState().proxySet);
return hasExtension || hasProxy;
}

274
src/utils/imdbScraper.ts Normal file
View file

@ -0,0 +1,274 @@
/* eslint-disable no-console */
import { isExtensionActive } from "@/backend/extension/messaging";
import { proxiedFetch } from "@/backend/helpers/fetch";
import { makeExtensionFetcher } from "@/backend/providers/fetchers";
import { useAuthStore } from "@/stores/auth";
interface IMDbMetadata {
title: string;
original_title: string;
title_type: string;
year: number | null;
end_year: number | null;
day: number | null;
month: number | null;
date: string;
runtime: number | null;
age_rating: string;
imdb_rating: number | null;
votes: number | null;
plot: string;
poster_url: string;
trailer_url: string;
url: string;
genre: string[];
cast: string[];
directors: string[];
writers: string[];
keywords: string[];
countries: string[];
languages: string[];
locations: string[];
season?: number;
episode?: number;
episode_title?: string;
episode_plot?: string;
episode_rating?: number;
episode_votes?: number;
}
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const userAgents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15",
];
function getRandomUserAgent(): string {
return userAgents[Math.floor(Math.random() * userAgents.length)];
}
function formatRuntime(seconds: number | null): string {
if (!seconds) return "";
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
function arrayToString(list: string[]): string {
return list.join(", ");
}
export async function scrapeIMDb(
imdbId: string,
season?: number,
episode?: number,
): Promise<IMDbMetadata> {
// Check if we have a proxy or extension
const hasExtension = await isExtensionActive();
const hasProxy = Boolean(useAuthStore.getState().proxySet);
if (!hasExtension && !hasProxy) {
throw new Error(
"IMDb scraping requires either the browser extension or a custom proxy to be set up. " +
"Please install the extension or set up a proxy in the settings.",
);
}
console.log(
`[IMDb Scraper] Using ${hasExtension ? "browser extension" : "custom proxy"} for requests`,
);
// Construct IMDb URL
let imdbUrl = `https://www.imdb.com/title/${imdbId}/`;
if (season && episode) {
imdbUrl += `episodes?season=${season}`;
}
// Add random delay to avoid rate limiting
const delay = Math.floor(Math.random() * (197 - 69) + 69);
await new Promise<void>((resolve) => {
setTimeout(resolve, delay);
});
// Fetch IMDb page using appropriate fetcher
let response: string;
if (hasExtension) {
const extensionFetcher = makeExtensionFetcher();
const result = await extensionFetcher(imdbUrl, {
headers: {
"User-Agent": getRandomUserAgent(),
},
method: "GET",
query: {},
readHeaders: [],
});
response = result.body as string;
} else {
response = await proxiedFetch<string>(imdbUrl, {
headers: {
"User-Agent": getRandomUserAgent(),
},
});
}
// Extract JSON data from the page
const jsonMatch = response.match(
/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/,
);
if (!jsonMatch) {
throw new Error("Could not find IMDb data on the page");
}
const data = JSON.parse(jsonMatch[1]);
const metadata: IMDbMetadata = {
title: "",
original_title: "",
title_type: "",
year: null,
end_year: null,
day: null,
month: null,
date: "",
runtime: null,
age_rating: "",
imdb_rating: null,
votes: null,
plot: "",
poster_url: "",
trailer_url: "",
url: imdbUrl,
genre: [],
cast: [],
directors: [],
writers: [],
keywords: [],
countries: [],
languages: [],
locations: [],
season,
episode,
};
try {
// Extract all the metadata
const aboveTheFold = data.props.pageProps.aboveTheFoldData;
const mainColumn = data.props.pageProps.mainColumnData;
metadata.title = aboveTheFold.titleText?.text || "";
metadata.original_title = aboveTheFold.originalTitleText?.text || "";
metadata.title_type = aboveTheFold.titleType?.text || "";
metadata.age_rating = aboveTheFold.certificate?.rating || "";
metadata.year = aboveTheFold.releaseYear?.year || null;
metadata.end_year = aboveTheFold.releaseYear?.endYear || null;
metadata.day = aboveTheFold.releaseDate?.day || null;
metadata.month = aboveTheFold.releaseDate?.month || null;
if (metadata.month && metadata.day && metadata.year) {
metadata.date = `${months[metadata.month - 1]} ${metadata.day}, ${metadata.year}`;
}
metadata.runtime = aboveTheFold.runtime?.seconds || null;
metadata.plot = aboveTheFold.plot?.plotText?.plainText || "";
metadata.imdb_rating = aboveTheFold.ratingsSummary?.aggregateRating || null;
metadata.votes = aboveTheFold.ratingsSummary?.voteCount || null;
metadata.poster_url = aboveTheFold.primaryImage?.url || "";
metadata.trailer_url =
aboveTheFold.primaryVideos?.edges?.[0]?.node?.playbackURLs?.[0]?.url ||
"";
// Extract arrays
metadata.genre = aboveTheFold.genres?.genres?.map((g: any) => g.text) || [];
metadata.cast =
aboveTheFold.castPageTitle?.edges?.map(
(e: any) => e.node.name.nameText.text,
) || [];
metadata.directors =
aboveTheFold.directorsPageTitle?.[0]?.credits?.map(
(c: any) => c.name.nameText.text,
) || [];
metadata.writers =
mainColumn.writers?.[0]?.credits?.map((c: any) => c.name.nameText.text) ||
[];
metadata.keywords =
aboveTheFold.keywords?.edges?.map((e: any) => e.node.text) || [];
metadata.countries =
mainColumn.countriesOfOrigin?.countries?.map((c: any) => c.text) || [];
metadata.languages =
mainColumn.spokenLanguages?.spokenLanguages?.map((l: any) => l.text) ||
[];
metadata.locations =
mainColumn.filmingLocations?.edges?.map((e: any) => e.node.text) || [];
// If season and episode are provided, get episode-specific data
if (season && episode) {
const episodeData =
data.props.pageProps.mainColumnData.episodes?.edges?.find(
(e: any) => e.node.episodeNumber === episode,
);
if (episodeData) {
metadata.episode_title = episodeData.node.titleText?.text || "";
metadata.episode_plot =
episodeData.node.plot?.plotText?.plainText || "";
metadata.episode_rating =
episodeData.node.ratingsSummary?.aggregateRating || null;
metadata.episode_votes =
episodeData.node.ratingsSummary?.voteCount || null;
}
}
} catch (error) {
console.error("Error parsing IMDb data:", error);
throw error;
}
return metadata;
}
// Helper function to print metadata (useful for debugging)
export function printIMDbMetadata(metadata: IMDbMetadata): void {
console.log("\nTitle:", metadata.title);
if (metadata.title !== metadata.original_title) {
console.log("Original Title:", metadata.original_title);
}
console.log("Type:", metadata.title_type);
console.log("Year:", metadata.year);
console.log("Runtime:", formatRuntime(metadata.runtime));
console.log("Date:", metadata.date);
console.log("Age Rating:", metadata.age_rating);
console.log("Genre:", arrayToString(metadata.genre));
console.log("Cast:", arrayToString(metadata.cast));
console.log("Directed by:", arrayToString(metadata.directors));
console.log("Writers:", arrayToString(metadata.writers));
console.log("Countries:", arrayToString(metadata.countries));
console.log("Filming Locations:", arrayToString(metadata.locations));
console.log("Languages:", arrayToString(metadata.languages));
console.log("Keywords:", arrayToString(metadata.keywords));
if (metadata.season && metadata.episode) {
console.log("\nEpisode Details:");
console.log("Season:", metadata.season);
console.log("Episode:", metadata.episode);
console.log("Title:", metadata.episode_title);
console.log("Plot:", metadata.episode_plot);
console.log("Rating:", metadata.episode_rating);
console.log("Votes:", metadata.episode_votes);
}
console.log("\n");
}