make details modal load 10x faster

make each component load individually instead of waiting for them all
This commit is contained in:
Pas 2026-02-20 17:38:05 -07:00
parent 54e26c8a52
commit 4976d38830
4 changed files with 108 additions and 26 deletions

View file

@ -345,10 +345,36 @@ type MediaDetailReturn<T extends TMDBContentTypes> =
? TMDBShowData
: never;
export async function getSeasonDetails(
id: string,
season: number,
): Promise<
Array<{
id: number;
name: string;
episode_number: number;
overview: string;
still_path: string | null;
air_date: string;
season_number: number;
}>
> {
const seasonData = await get<TMDBSeason>(`/tv/${id}/season/${season}`);
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,
}));
}
export async function getMediaDetails<
T extends TMDBContentTypes,
TReturn = MediaDetailReturn<T>,
>(id: string, type: T): Promise<TReturn> {
>(id: string, type: T, fetchEpisodes: boolean = true): Promise<TReturn> {
if (type === TMDBContentTypes.MOVIE) {
return get<TReturn>(`/movie/${id}`, {
append_to_response: "external_ids,credits,release_dates",
@ -359,6 +385,13 @@ export async function getMediaDetails<
append_to_response: "external_ids,credits,content_ratings",
});
if (!fetchEpisodes) {
return {
...showData,
episodes: [],
} as TReturn;
}
// Fetch episodes for each season
const showDetails = showData as TMDBShowData;
const allEpisodesBySeason = new Array(showDetails.seasons.length);
@ -375,18 +408,8 @@ export async function getMediaDetails<
const item = seasonsQueue.shift();
if (!item) break;
const { season, index } = item;
const seasonData = await get<TMDBSeason>(
`/tv/${id}/season/${season.season_number}`,
);
allEpisodesBySeason[index] = 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 episodes = await getSeasonDetails(id, season.season_number);
allEpisodesBySeason[index] = episodes;
}
},
);

View file

@ -9,7 +9,12 @@ import { Icon, Icons } from "@/components/Icon";
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
import { hasAired } from "@/components/player/utils/aired";
import { useBookmarkStore } from "@/stores/bookmarks";
import { getProgressPercentage, useProgressStore } from "@/stores/progress";
// eslint-disable-next-line import/no-cycle
import {
ProgressEpisodeItem,
getProgressPercentage,
useProgressStore,
} from "@/stores/progress";
import { EpisodeCarouselProps } from "../../types";
@ -256,16 +261,15 @@ export function EpisodeCarousel({
const watchedStats = useMemo(() => {
if (!mediaId || !totalEpisodes) return { watched: 0, percentage: 0 };
const item = progress[mediaId.toString()];
if (!item?.episodes) return { watched: 0, percentage: 0 };
let watchedCount = 0;
episodes.forEach((episode) => {
const episodeProgress =
progress[mediaId.toString()]?.episodes?.[episode.id];
const percentage = episodeProgress
? getProgressPercentage(
episodeProgress.progress.watched,
episodeProgress.progress.duration,
)
: 0;
Object.values<ProgressEpisodeItem>(item.episodes).forEach((episode) => {
const percentage = getProgressPercentage(
episode.progress.watched,
episode.progress.duration,
);
if (percentage > 90) {
watchedCount += 1;
}
@ -274,7 +278,7 @@ export function EpisodeCarousel({
const percentage = Math.round((watchedCount / totalEpisodes) * 100);
return { watched: watchedCount, percentage };
}, [episodes, progress, mediaId, totalEpisodes]);
}, [progress, mediaId, totalEpisodes]);
// Load favorite episodes when favorites is selected
useEffect(() => {

View file

@ -2,6 +2,7 @@ import { t } from "i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCopyToClipboard } from "react-use";
import { getSeasonDetails } from "@/backend/metadata/tmdb";
import { getNetworkContent } from "@/backend/metadata/traktApi";
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
import { Icon, Icons } from "@/components/Icon";
@ -33,6 +34,12 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
const [showTrailer, setShowTrailer] = useState(false);
const [showCollection, setShowCollection] = useState(false);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [fetchedSeasons, setFetchedSeasons] = useState<Record<number, any[]>>(
{},
);
const [loadingSeasons, setLoadingSeasons] = useState<Record<number, boolean>>(
{},
);
const [, copyToClipboard] = useCopyToClipboard();
const [hasCopiedShare, setHasCopiedShare] = useState(false);
const [logoHeight, setLogoHeight] = useState<number>(0);
@ -69,6 +76,54 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
}
}, [showProgress]);
// Fetch episodes for selected season
useEffect(() => {
const fetchSeason = async (seasonNumber: number) => {
if (
!data.id ||
seasonNumber === -1 ||
fetchedSeasons[seasonNumber] ||
loadingSeasons[seasonNumber]
)
return;
setLoadingSeasons((prev) => ({ ...prev, [seasonNumber]: true }));
try {
const episodes = await getSeasonDetails(
data.id.toString(),
seasonNumber,
);
setFetchedSeasons((prev) => ({ ...prev, [seasonNumber]: episodes }));
} catch (err) {
console.error("Failed to fetch season details:", err);
} finally {
setLoadingSeasons((prev) => ({ ...prev, [seasonNumber]: false }));
}
};
if (data.type === "show") {
if (selectedSeason !== -1) {
fetchSeason(selectedSeason);
} else if (data.seasonData?.seasons) {
// Fetch all seasons for favorites
data.seasonData.seasons.forEach((season) => {
fetchSeason(season.season_number);
});
}
}
}, [
data.id,
data.type,
selectedSeason,
fetchedSeasons,
loadingSeasons,
data.seasonData,
]);
const allEpisodes = useMemo(() => {
return Object.values(fetchedSeasons).flat();
}, [fetchedSeasons]);
// Add effect to measure logo height
useEffect(() => {
if (logoRef.current) {
@ -403,7 +458,7 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
{/* Episodes Carousel for TV Shows */}
{data.type === "show" && data.seasonData && !minimal && (
<EpisodeCarousel
episodes={data.seasonData.episodes}
episodes={allEpisodes}
showProgress={showProgress}
progress={progress}
selectedSeason={selectedSeason}

View file

@ -55,7 +55,7 @@ export function DetailsModal({
try {
const type =
data.type === "movie" ? TMDBContentTypes.MOVIE : TMDBContentTypes.TV;
const details = await getMediaDetails(data.id.toString(), type);
const details = await getMediaDetails(data.id.toString(), type, false);
const backdropUrl = getMediaBackdrop(details.backdrop_path);
const logoUrl = await getMediaLogo(data.id.toString(), type);
if (type === TMDBContentTypes.MOVIE) {