mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-20 05:12:05 +00:00
add details modal
This commit is contained in:
parent
9e555c99ea
commit
02b1b5a7dc
26 changed files with 1503 additions and 425 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
855
src/components/overlays/DetailsModal.tsx
Normal file
855
src/components/overlays/DetailsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface PlayerMetaEpisode {
|
|||
number: number;
|
||||
tmdbId: string;
|
||||
title: string;
|
||||
air_date?: string;
|
||||
}
|
||||
|
||||
export interface PlayerMeta {
|
||||
|
|
|
|||
|
|
@ -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
12
src/utils/hasProxy.ts
Normal 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
274
src/utils/imdbScraper.ts
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue