perf(render): refactor promise chain orchestration to enable progressive rendering
This commit is contained in:
@@ -135,10 +135,8 @@ export const getTmdbSeriesSeason = async (
|
||||
}
|
||||
}).then((res) => res.data);
|
||||
|
||||
export const getTmdbSeriesSeasons = async (tmdbId: number, seasons: number) =>
|
||||
Promise.all([...Array(seasons).keys()].map((i) => getTmdbSeriesSeason(tmdbId, i + 1))).then(
|
||||
(r) => r.filter((s) => s) as TmdbSeason[]
|
||||
);
|
||||
export const getTmdbSeriesSeasons = (tmdbId: number, seasons: number) =>
|
||||
[...Array(seasons).keys()].map((i) => getTmdbSeriesSeason(tmdbId, i + 1));
|
||||
|
||||
export const getTmdbSeriesImages = async (tmdbId: number) =>
|
||||
TmdbApiOpen.get('/3/tv/{series_id}/images', {
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
const ANIMATION_DURATION = $settings.animationDuration;
|
||||
|
||||
export let tmdbId: number;
|
||||
|
||||
export let trailerId: string | undefined = undefined;
|
||||
|
||||
export let lazyTrailerId: Promise<string | undefined>;
|
||||
export let backdropUri: string;
|
||||
|
||||
let scrollY: number;
|
||||
@@ -23,6 +21,9 @@
|
||||
export let UIVisible = true;
|
||||
$: UIVisible = !(hoverTrailer && trailerVisible);
|
||||
|
||||
let trailerId: string | undefined = undefined;
|
||||
lazyTrailerId.then((v) => (trailerId = v));
|
||||
|
||||
let trailerShowTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
$: {
|
||||
tmdbId;
|
||||
@@ -64,7 +65,7 @@
|
||||
|
||||
<svelte:window bind:scrollY on:scroll={handleWindowScroll} />
|
||||
|
||||
{#if !trailerVisible}
|
||||
{#if !trailerVisible || !trailerId}
|
||||
{#key tmdbId}
|
||||
<div
|
||||
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropUri + "');"}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { TitleType } from '$lib/types';
|
||||
import { openTitleModal } from '$lib/stores/modal.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { TMDB_MOVIE_GENRES } from '$lib/apis/tmdb/tmdbApi';
|
||||
|
||||
const ANIMATION_DURATION = $settings.animationDuration;
|
||||
|
||||
@@ -15,8 +16,8 @@
|
||||
export let type: TitleType;
|
||||
|
||||
export let title: string;
|
||||
export let genres: string[];
|
||||
export let runtime: number;
|
||||
export let genreIds: number[];
|
||||
export let lazyRuntime: Promise<number>;
|
||||
export let releaseDate: Date;
|
||||
export let tmdbRating: number;
|
||||
|
||||
@@ -24,7 +25,14 @@
|
||||
|
||||
export let hideUI = false;
|
||||
|
||||
let runtime = 0;
|
||||
let loadingAdditionalDetails = true;
|
||||
lazyRuntime.then((rn) => (runtime = rn)).finally(() => (loadingAdditionalDetails = false));
|
||||
|
||||
$: tmdbUrl = `https://www.themoviedb.org/${type}/${tmdbId}`;
|
||||
$: genres = genreIds
|
||||
.map((gId) => TMDB_MOVIE_GENRES.find((g) => g.id === gId)?.name)
|
||||
.filter<string>((g): g is string => typeof g === 'string');
|
||||
|
||||
function handleOpenTitle() {
|
||||
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
|
||||
@@ -60,7 +68,7 @@
|
||||
>
|
||||
<p class="flex-shrink-0">{releaseDate.getFullYear()}</p>
|
||||
<DotFilled />
|
||||
<p class="flex-shrink-0">{formatMinutesToTime(runtime)}</p>
|
||||
<p class="flex-shrink-0">{loadingAdditionalDetails ? 'LOADING' : formatMinutesToTime(runtime)}</p>
|
||||
<DotFilled />
|
||||
<p class="flex-shrink-0"><a href={tmdbUrl}>{tmdbRating.toFixed(1)} TMDB</a></p>
|
||||
</div>
|
||||
|
||||
@@ -65,11 +65,49 @@
|
||||
}
|
||||
});
|
||||
|
||||
const tmdbPopularMoviesPromise = getTmdbPopularMovies()
|
||||
.then((movies) => Promise.all(movies.map((movie) => getTmdbMovie(movie.id || 0))))
|
||||
.then((movies) => movies.filter((m) => !!m).slice(0, 10));
|
||||
let popularMovies: (
|
||||
| {
|
||||
movie: Awaited<ReturnType<typeof getTmdbPopularMovies>>[0];
|
||||
lazyRuntime: Promise<number>;
|
||||
lazyTrailerId: Promise<string | undefined>;
|
||||
}
|
||||
)[] = [];
|
||||
|
||||
/**
|
||||
* Here we load a list of popular movies:
|
||||
* * runtime & video data is not available as part of the initial request
|
||||
* * If an additional detail request fails, we unload the movie from the showcase
|
||||
*/
|
||||
const tmdbPopularMoviesPromise = getTmdbPopularMovies().then(
|
||||
(movies) =>
|
||||
(popularMovies = movies.map((movie) => {
|
||||
const movieDetails = getTmdbMovie(movie.id || 0);
|
||||
const movieDetailsPromise = movieDetails.then((fullMovie) => ({
|
||||
runtime: fullMovie?.runtime || 0,
|
||||
trailerId: fullMovie?.videos?.results?.find(
|
||||
(v) => v.site === 'YouTube' && v.type === 'Trailer'
|
||||
)?.key
|
||||
}));
|
||||
|
||||
movieDetails.catch(() => unloadMovie());
|
||||
movieDetails.then((md) => !md && unloadMovie());
|
||||
const unloadMovie = () => {
|
||||
const idx = popularMovies.findIndex((m) => m.movie === movie);
|
||||
popularMovies.splice(idx, 1);
|
||||
popularMovies = popularMovies;
|
||||
};
|
||||
|
||||
return {
|
||||
movie,
|
||||
lazyRuntime: movieDetailsPromise.then((fm) => fm.runtime),
|
||||
lazyTrailerId: movieDetailsPromise.then((fm) => fm.trailerId)
|
||||
};
|
||||
}))
|
||||
);
|
||||
|
||||
let showcaseIndex = 0;
|
||||
$: clampedPopularMovies = popularMovies.slice(0, 10);
|
||||
$: visibleShowcaseMovie = clampedPopularMovies[showcaseIndex];
|
||||
|
||||
async function onNext() {
|
||||
showcaseIndex = (showcaseIndex + 1) % (await tmdbPopularMoviesPromise).length;
|
||||
@@ -105,16 +143,16 @@
|
||||
PADDING
|
||||
)}
|
||||
>
|
||||
{#await tmdbPopularMoviesPromise then movies}
|
||||
{@const movie = movies[showcaseIndex]}
|
||||
{#if visibleShowcaseMovie}
|
||||
{@const { movie, lazyRuntime, lazyTrailerId } = visibleShowcaseMovie}
|
||||
|
||||
{#key movie?.id}
|
||||
<TitleShowcaseVisuals
|
||||
tmdbId={movie?.id || 0}
|
||||
type="movie"
|
||||
title={movie?.title || ''}
|
||||
genres={movie?.genres?.map((g) => g.name || '') || []}
|
||||
runtime={movie?.runtime || 0}
|
||||
genreIds={movie.genre_ids || []}
|
||||
{lazyRuntime}
|
||||
releaseDate={new Date(movie?.release_date || Date.now())}
|
||||
tmdbRating={movie?.vote_average || 0}
|
||||
posterUri={movie?.poster_path || ''}
|
||||
@@ -124,7 +162,13 @@
|
||||
<div
|
||||
class="md:relative self-stretch flex justify-center items-end row-start-2 row-span-1 col-start-1 col-span-2 md:row-start-1 md:row-span-2 md:col-start-2 md:col-span-2"
|
||||
>
|
||||
<PageDots index={showcaseIndex} length={movies.length} {onJump} {onPrevious} {onNext} />
|
||||
<PageDots
|
||||
index={showcaseIndex}
|
||||
length={clampedPopularMovies.length}
|
||||
{onJump}
|
||||
{onPrevious}
|
||||
{onNext}
|
||||
/>
|
||||
{#if !hideUI}
|
||||
<div class="absolute top-1/2 right-0 z-10">
|
||||
<IconButton on:click={onNext}>
|
||||
@@ -133,13 +177,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<TitleShowcase
|
||||
tmdbId={movie?.id || 0}
|
||||
trailerId={movie?.videos?.results?.find((v) => v.site === 'YouTube' && v.type === 'Trailer')
|
||||
?.key}
|
||||
backdropUri={movie?.backdrop_path || ''}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
{#key movie?.id}
|
||||
<TitleShowcase
|
||||
tmdbId={movie?.id || 0}
|
||||
{lazyTrailerId}
|
||||
backdropUri={movie?.backdrop_path || ''}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class={classNames('z-[1] transition-opacity', {
|
||||
|
||||
@@ -33,15 +33,14 @@
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
|
||||
const tmdbUrl = 'https://www.themoviedb.org/movie/' + tmdbId;
|
||||
const data = loadInitialPageData();
|
||||
const data = getTmdbMovie(tmdbId);
|
||||
const recommendationData = preloadRecommendationData();
|
||||
|
||||
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
|
||||
const radarrMovieStore = createRadarrMovieStore(tmdbId);
|
||||
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
|
||||
|
||||
async function loadInitialPageData() {
|
||||
const tmdbMoviePromise = getTmdbMovie(tmdbId);
|
||||
|
||||
async function preloadRecommendationData() {
|
||||
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
@@ -49,7 +48,7 @@
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
|
||||
const castPropsPromise: Promise<ComponentProps<PersonCard>[]> = tmdbMoviePromise.then((m) =>
|
||||
const castPropsPromise: Promise<ComponentProps<PersonCard>[]> = data.then((m) =>
|
||||
Promise.all(
|
||||
m?.credits?.cast?.slice(0, 20).map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
@@ -61,10 +60,9 @@
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbMovie: await tmdbMoviePromise,
|
||||
tmdbRecommendationProps: await tmdbRecommendationProps,
|
||||
tmdbSimilarProps: await tmdbSimilarProps,
|
||||
castProps: await castPropsPromise
|
||||
castProps: await castPropsPromise,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,8 +93,7 @@
|
||||
|
||||
{#await data}
|
||||
<TitlePageLayout {isModal} {handleCloseModal} />
|
||||
{:then { tmdbMovie, tmdbRecommendationProps, tmdbSimilarProps, castProps }}
|
||||
{@const movie = tmdbMovie}
|
||||
{:then movie }
|
||||
<TitlePageLayout
|
||||
titleInformation={{
|
||||
tmdbId,
|
||||
@@ -268,7 +265,7 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="carousels">
|
||||
{#await data}
|
||||
{#await recommendationData}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
<CarouselPlaceholderItems />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
|
||||
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
getTmdbIdFromTvdbId,
|
||||
getTmdbSeries,
|
||||
getTmdbSeriesRecommendations,
|
||||
getTmdbSeriesSeasons,
|
||||
getTmdbSeriesSimilar
|
||||
getTmdbSeriesSimilar,
|
||||
type TmdbSeriesFull2
|
||||
} from '$lib/apis/tmdb/tmdbApi';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Card from '$lib/components/Card/Card.svelte';
|
||||
@@ -39,7 +40,8 @@
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
|
||||
let data = loadInitialPageData();
|
||||
const data = loadInitialPageData();
|
||||
const recommendationData = preloadRecommendationData();
|
||||
|
||||
const jellyfinItemStore = createJellyfinItemStore(data.then((d) => d.tmdbId));
|
||||
const sonarrSeriesStore = createSonarrSeriesStore(data.then((d) => d.tmdbSeries?.name || ''));
|
||||
@@ -48,16 +50,16 @@
|
||||
let seasonSelectVisible = false;
|
||||
let visibleSeasonNumber: number = 1;
|
||||
let visibleEpisodeIndex: number | undefined = undefined;
|
||||
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
|
||||
|
||||
let jellyfinEpisodeData: {
|
||||
const jellyfinEpisodeData: {
|
||||
[key: string]: {
|
||||
jellyfinId: string | undefined;
|
||||
progress: number;
|
||||
watched: boolean;
|
||||
};
|
||||
} = {};
|
||||
let episodeComponents: HTMLDivElement[] = [];
|
||||
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
|
||||
const episodeComponents: HTMLDivElement[] = [];
|
||||
|
||||
// Refresh jellyfin episode data
|
||||
jellyfinItemStore.subscribe(async (value) => {
|
||||
@@ -87,65 +89,70 @@
|
||||
const tmdbId = await (titleId.provider === 'tvdb'
|
||||
? getTmdbIdFromTvdbId(titleId.id)
|
||||
: Promise.resolve(titleId.id));
|
||||
|
||||
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
|
||||
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
|
||||
getTmdbSeriesSeasons(s?.id || 0, s?.number_of_seasons || 0)
|
||||
);
|
||||
|
||||
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
|
||||
|
||||
const tmdbRecommendationPropsPromise = getTmdbSeriesRecommendations(tmdbId).then((r) =>
|
||||
Promise.all(r.map(fetchCardTmdbProps))
|
||||
);
|
||||
const tmdbSimilarPropsPromise = getTmdbSeriesSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
|
||||
const castPropsPromise: Promise<ComponentProps<PersonCard>[]> = tmdbSeriesPromise.then((s) =>
|
||||
Promise.all(
|
||||
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
|
||||
const tmdbEpisodePropsPromise: Promise<ComponentProps<EpisodeCard>[][]> =
|
||||
tmdbSeasonsPromise.then((seasons) =>
|
||||
seasons.map(
|
||||
(season) =>
|
||||
season?.episodes?.map((episode) => ({
|
||||
title: episode?.name || '',
|
||||
subtitle: `Episode ${episode?.episode_number}`,
|
||||
backdropUrl: TMDB_BACKDROP_SMALL + episode?.still_path || '',
|
||||
airDate:
|
||||
episode.air_date && new Date(episode.air_date) > new Date()
|
||||
? new Date(episode.air_date)
|
||||
: undefined
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
const tmdbSeries = await getTmdbSeries(tmdbId);
|
||||
|
||||
return {
|
||||
tmdbId,
|
||||
tmdbSeries: await tmdbSeriesPromise,
|
||||
tmdbSeasons: await tmdbSeasonsPromise,
|
||||
tmdbUrl,
|
||||
tmdbRecommendationProps: await tmdbRecommendationPropsPromise,
|
||||
tmdbSimilarProps: await tmdbSimilarPropsPromise,
|
||||
castProps: await castPropsPromise,
|
||||
tmdbEpisodeProps: await tmdbEpisodePropsPromise
|
||||
tmdbUrl: 'https://www.themoviedb.org/tv/' + tmdbId,
|
||||
tmdbSeries,
|
||||
seasonsData: preloadAndMapSeasonsData(tmdbSeries)
|
||||
};
|
||||
}
|
||||
|
||||
async function preloadRecommendationData() {
|
||||
const { tmdbId, tmdbSeries } = await data;
|
||||
const tmdbRecommendationProps = getTmdbSeriesRecommendations(tmdbId).then((r) =>
|
||||
Promise.all(r.map(fetchCardTmdbProps))
|
||||
);
|
||||
|
||||
const tmdbSimilarProps = getTmdbSeriesSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
|
||||
const castProps: ComponentProps<PersonCard>[] =
|
||||
tmdbSeries?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
tmdbRecommendationProps: await tmdbRecommendationProps,
|
||||
tmdbSimilarProps: await tmdbSimilarProps,
|
||||
castProps
|
||||
};
|
||||
}
|
||||
|
||||
function preloadAndMapSeasonsData(
|
||||
tmdbSeries: TmdbSeriesFull2 | undefined
|
||||
): Promise<ComponentProps<EpisodeCard>[]>[] {
|
||||
const tmdbSeasons = getTmdbSeriesSeasons(
|
||||
tmdbSeries?.id || 0,
|
||||
tmdbSeries?.number_of_seasons || 0
|
||||
);
|
||||
|
||||
return tmdbSeasons.map((season) =>
|
||||
season.then(
|
||||
(s) =>
|
||||
s?.episodes?.map((episode) => ({
|
||||
title: episode?.name || '',
|
||||
subtitle: `Episode ${episode?.episode_number}`,
|
||||
backdropUrl: TMDB_BACKDROP_SMALL + episode?.still_path || '',
|
||||
airDate:
|
||||
episode.air_date && new Date(episode.air_date) > new Date()
|
||||
? new Date(episode.air_date)
|
||||
: undefined
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function playNextEpisode() {
|
||||
if (nextJellyfinEpisode?.Id) playerState.streamJellyfinId(nextJellyfinEpisode?.Id || '');
|
||||
}
|
||||
|
||||
async function refreshSonarr() {
|
||||
async function refreshSonarr() {
|
||||
await sonarrSeriesStore.refreshIn();
|
||||
}
|
||||
|
||||
@@ -211,7 +218,7 @@
|
||||
</Carousel>
|
||||
</div>
|
||||
</TitlePageLayout>
|
||||
{:then { tmdbSeries, tmdbId, ...data }}
|
||||
{:then { tmdbId, tmdbUrl, tmdbSeries, seasonsData }}
|
||||
<TitlePageLayout
|
||||
titleInformation={{
|
||||
tmdbId,
|
||||
@@ -230,7 +237,7 @@
|
||||
<DotFilled />
|
||||
{tmdbSeries?.status}
|
||||
<DotFilled />
|
||||
<a href={data.tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
|
||||
<a href={tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="title-right">
|
||||
@@ -311,24 +318,28 @@
|
||||
{/each}
|
||||
</UiCarousel>
|
||||
{#key visibleSeasonNumber}
|
||||
{#each data.tmdbEpisodeProps[visibleSeasonNumber - 1] || [] as props, i}
|
||||
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
|
||||
<div bind:this={episodeComponents[i]}>
|
||||
<EpisodeCard
|
||||
{...props}
|
||||
{...jellyfinData
|
||||
? {
|
||||
watched: jellyfinData.watched,
|
||||
progress: jellyfinData.progress,
|
||||
jellyfinId: jellyfinData.jellyfinId
|
||||
}
|
||||
: {}}
|
||||
on:click={() => (visibleEpisodeIndex = i)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{#await seasonsData[visibleSeasonNumber - 1]}
|
||||
<CarouselPlaceholderItems />
|
||||
{/each}
|
||||
{:then seasonEpisodes}
|
||||
{#each seasonEpisodes || [] as props, i}
|
||||
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
|
||||
<div bind:this={episodeComponents[i]}>
|
||||
<EpisodeCard
|
||||
{...props}
|
||||
{...jellyfinData
|
||||
? {
|
||||
watched: jellyfinData.watched,
|
||||
progress: jellyfinData.progress,
|
||||
jellyfinId: jellyfinData.jellyfinId
|
||||
}
|
||||
: {}}
|
||||
on:click={() => (visibleEpisodeIndex = i)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<CarouselPlaceholderItems />
|
||||
{/each}
|
||||
{/await}
|
||||
{/key}
|
||||
</Carousel>
|
||||
</div>
|
||||
@@ -439,7 +450,7 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="carousels">
|
||||
{#await data}
|
||||
{#await recommendationData}
|
||||
<Carousel gradientFromColor="from-stone-950">
|
||||
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
|
||||
<CarouselPlaceholderItems />
|
||||
|
||||
Reference in New Issue
Block a user