perf(render): refactor promise chain orchestration to enable progressive rendering

This commit is contained in:
Jordan Fischer
2024-02-14 00:01:26 +10:00
parent 6c60de7ebc
commit b68e53e733
6 changed files with 172 additions and 111 deletions

View File

@@ -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', {

View File

@@ -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 + "');"}

View File

@@ -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>

View File

@@ -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', {

View File

@@ -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 />

View File

@@ -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 />