refactor: MoviePage

This commit is contained in:
Aleksi Lassila
2023-09-01 16:28:18 +03:00
parent b44d794c08
commit 69482e1ded
3 changed files with 192 additions and 200 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
import type { TitleType } from '$lib/types';
import type { TitleId, TitleType } from '$lib/types';
import classNames from 'classnames';
import { ChevronLeft, Cross2, DotFilled, ExternalLink } from 'radix-icons-svelte';
import Carousel from '../Carousel/Carousel.svelte';
@@ -11,15 +11,17 @@
export let isModal = false;
export let handleCloseModal: () => void = () => {};
export let tmdbId: number;
export let type: TitleType;
export let backdropUriCandidates: string[];
export let posterPath: string;
export let title: string;
export let tagline: string;
export let overview: string;
export let titleInformation:
| {
tmdbId: number;
type: TitleType;
title: string;
tagline: string;
overview: string;
backdropUriCandidates: string[];
posterPath: string;
}
| undefined = undefined;
let topHeight: number;
let bottomHeight: number;
@@ -42,9 +44,14 @@
fixed: !isModal
})}
>
<LazyImg src={TMDB_IMAGES_ORIGINAL + getBackdropUri(backdropUriCandidates)} class="h-full">
<div class="absolute inset-0 bg-darken" />
</LazyImg>
{#if titleInformation}
<LazyImg
src={TMDB_IMAGES_ORIGINAL + getBackdropUri(titleInformation.backdropUriCandidates)}
class="h-full"
>
<div class="absolute inset-0 bg-darken" />
</LazyImg>
{/if}
</div>
<!-- Mobile -->
@@ -52,38 +59,13 @@
style={'height: ' + imageHeight.toFixed() + 'px'}
class="sm:hidden fixed inset-x-0 bg-center bg-cover bg-stone-950"
>
<LazyImg src={TMDB_IMAGES_ORIGINAL + posterPath} class="h-full">
<div class="absolute inset-0 bg-darken" />
</LazyImg>
{#if titleInformation}
<LazyImg src={TMDB_IMAGES_ORIGINAL + titleInformation.posterPath} class="h-full">
<div class="absolute inset-0 bg-darken" />
</LazyImg>
{/if}
</div>
<!-- <div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
getBackdropUri(backdropUriCandidates) +
"'); height: " +
imageHeight.toFixed() +
'px'}
class={classNames('hidden sm:block inset-x-0 bg-center bg-cover bg-stone-950', {
absolute: isModal,
fixed: !isModal
})}
>
<div class="absolute inset-0 bg-darken" />
</div> -->
<!-- <div
style={"background-image: url('" +
TMDB_IMAGES_ORIGINAL +
posterPath +
"'); height: " +
imageHeight.toFixed() +
'px'}
class="sm:hidden fixed inset-x-0 bg-center bg-cover bg-stone-950"
>
<div class="absolute inset-0 bg-darken" />
</div> -->
<div
class={classNames('flex flex-col relative z-[1]', {
'h-[85vh] sm:h-screen': !isModal,
@@ -97,11 +79,16 @@
bind:clientHeight={topHeight}
>
{#if isModal}
<a href={`/${type}/${tmdbId}`} class="absolute top-8 right-4 sm:right-8 z-10">
<IconButton>
<ExternalLink size={20} />
</IconButton>
</a>
{#if titleInformation}
<a
href={`/${titleInformation.type}/${titleInformation.tmdbId}`}
class="absolute top-8 right-4 sm:right-8 z-10"
>
<IconButton>
<ExternalLink size={20} />
</IconButton>
</a>
{/if}
<div class="absolute top-8 left-4 sm:left-8 z-10">
<button class="flex items-center sm:hidden font-medium" on:click={handleCloseModal}>
<ChevronLeft size={20} />
@@ -116,26 +103,26 @@
{/if}
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-30%" />
<div class="z-[1] flex-1 flex justify-end gap-8 items-end max-w-screen-2xl mx-auto">
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
style={"background-image: url('" + TMDB_POSTER_SMALL + posterPath + "')"}
/>
{#if titleInformation}
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
style={"background-image: url('" + TMDB_POSTER_SMALL + titleInformation.posterPath + "')"}
/>
{:else}
<div class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block placeholder" />
{/if}
<div class="flex-1 flex gap-4 justify-between flex-col lg:flex-row lg:items-end">
<div>
<div class="text-zinc-300 text-sm uppercase font-semibold flex items-center gap-1">
<p class="flex-shrink-0">
<slot name="title-info-1" />
</p>
<DotFilled />
<p class="flex-shrink-0">
<slot name="title-info-2" />
</p>
<DotFilled />
<p class="flex-shrink-0"><slot name="title-info-3" /></p>
<!-- <DotFilled />
<p class="line-clamp-1">{series?.genres?.map((g) => g.name).join(', ')}</p> -->
<slot name="title-info">
<div class="placeholder-text">Placeholder Long</div>
</slot>
</div>
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{title}</h1>
{#if titleInformation}
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{titleInformation.title}</h1>
{:else}
<h1 class="text-4xl sm:text-5xl md:text-6xl placeholder-text mt-2">Placeholder</h1>
{/if}
</div>
<div class="flex-shrink-0">
<slot name="title-right" />
@@ -162,9 +149,10 @@
<div
class="flex flex-col gap-3 max-w-5xl row-span-3 col-span-4 sm:col-span-6 lg:col-span-3 mb-4 lg:mr-8"
>
<div class="flex gap-4 justify-between">
<h1 class="font-semibold text-xl sm:text-2xl">{tagline}</h1>
<!-- <div class="flex items-center gap-4">
{#if titleInformation}
<div class="flex gap-4 justify-between">
<h1 class="font-semibold text-xl sm:text-2xl">{titleInformation.tagline}</h1>
<!-- <div class="flex items-center gap-4">
<a
target="_blank"
href={'https://www.themoviedb.org/tv/' + tmdbId}
@@ -192,8 +180,22 @@
<Globe size={15} />
</a>
{/if} -->
</div>
<p class="pl-4 border-l-2 text-sm sm:text-base">{overview}</p>
</div>
<p class="pl-4 border-l-2 text-sm sm:text-base">{titleInformation.overview}</p>
{:else}
<div class="flex gap-4 justify-between">
<h1 class="font-semibold text-xl sm:text-2xl placeholder-text">Placeholder</h1>
</div>
<div class="flex">
<div class="mr-4 placeholder w-1 flex-shrink-0 rounded" />
<p class="text-sm sm:text-base placeholder-text">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sit amet sem eget
dolor lobortis mollis. Aliquam semper imperdiet mi nec viverra. Praesent ac ligula
congue, aliquam diam nec, ullamcorper libero. Nunc mattis rhoncus justo, ac pretium
urna vehicula et.
</p>
</div>
{/if}
</div>
</slot>
<slot name="info-components" />

View File

@@ -24,41 +24,51 @@
import { settings } from '$lib/stores/settings.store';
import { formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronRight, Plus } from 'radix-icons-svelte';
import { Archive, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
export let tmdbId: number;
export let isModal = false;
export let handleCloseModal: () => void = () => {};
const tmdbUrl = 'https://www.themoviedb.org/movie/' + tmdbId;
const data = loadInitialPageData();
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
const radarrMovieStore = createRadarrMovieStore(tmdbId);
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
const jellyfinItem = $jellyfinItemStore.item;
const radarrMovie = $radarrMovieStore.item;
async function loadInitialPageData() {
const tmdbMoviePromise = getTmdbMovie(tmdbId);
const tmdbMoviePromise = getTmdbMovie(tmdbId);
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
Promise.all(
m?.credits?.cast?.slice(0, 20).map((m) => ({
tmdbId: m.id || 0,
backdropUri: m.profile_path || '',
name: m.name || '',
subtitle: m.character || m.known_for_department || ''
})) || []
)
);
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
.then((r) => r.filter((p) => p.backdropUrl));
const castPropsPromise: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
Promise.all(
m?.credits?.cast?.slice(0, 20).map((m) => ({
tmdbId: m.id || 0,
backdropUri: m.profile_path || '',
name: m.name || '',
subtitle: m.character || m.known_for_department || ''
})) || []
)
);
return {
tmdbMovie: await tmdbMoviePromise,
tmdbRecommendationProps: await tmdbRecommendationProps,
tmdbSimilarProps: await tmdbSimilarProps,
castProps: await castPropsPromise
};
}
function play() {
if (jellyfinItem?.Id) playerState.streamJellyfinId(jellyfinItem?.Id);
if ($jellyfinItemStore.item?.Id) playerState.streamJellyfinId($jellyfinItemStore.item?.Id);
}
async function refreshRadarr() {
@@ -74,42 +84,45 @@
}
function openRequestModal() {
if (!radarrMovie?.id) return;
if (!$radarrMovieStore.item?.id) return;
modalStack.create(RequestModal, {
radarrId: radarrMovie?.id
radarrId: $radarrMovieStore.item?.id
});
}
</script>
{#await tmdbMoviePromise then movie}
{#await data}
<TitlePageLayout {isModal} {handleCloseModal} />
{:then { tmdbMovie, tmdbRecommendationProps, tmdbSimilarProps, castProps }}
{@const movie = tmdbMovie}
<TitlePageLayout
titleInformation={{
tmdbId,
type: 'movie',
title: movie?.title || 'Movie',
backdropUriCandidates: movie?.images?.backdrops?.map((b) => b.file_path || '') || [],
posterPath: movie?.poster_path || '',
tagline: movie?.tagline || movie?.title || '',
overview: movie?.overview || ''
}}
{isModal}
{tmdbId}
{handleCloseModal}
type="movie"
title={movie?.title || 'Movie'}
backdropUriCandidates={movie?.images?.backdrops?.map((b) => b.file_path || '') || []}
posterPath={movie?.poster_path || ''}
tagline={movie?.tagline || movie?.title || ''}
overview={movie?.overview || ''}
>
<svelte:fragment slot="title-info-1"
>{new Date(movie?.release_date || Date.now()).getFullYear()}</svelte:fragment
>
<svelte:fragment slot="title-info-2">
{@const progress = jellyfinItem?.UserData?.PlayedPercentage}
<svelte:fragment slot="title-info">
{new Date(movie?.release_date || Date.now()).getFullYear()}
<DotFilled />
{@const progress = $jellyfinItemStore.item?.UserData?.PlayedPercentage}
{#if progress}
{progress.toFixed()} min left
{:else}
{movie?.runtime} min
{/if}
</svelte:fragment>
<svelte:fragment slot="title-info-3">
<DotFilled />
<a href={tmdbUrl} target="_blank">{movie?.vote_average?.toFixed(1)} TMDB</a>
</svelte:fragment>
<svelte:fragment slot="episodes-carousel">
{@const progress = jellyfinItem?.UserData?.PlayedPercentage}
{@const progress = $jellyfinItemStore.item?.UserData?.PlayedPercentage}
{#if progress}
<div
class={classNames('px-2 sm:px-4 lg:px-8', {
@@ -128,6 +141,8 @@
{#if $jellyfinItemStore.loading || $radarrMovieStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else}
{@const jellyfinItem = $jellyfinItemStore.item}
{@const radarrMovie = $radarrMovieStore.item}
<OpenInButton title={movie?.title} {jellyfinItem} {radarrMovie} type="movie" {tmdbId} />
{#if jellyfinItem}
<Button type="primary" on:click={play}>
@@ -203,6 +218,7 @@
</svelte:fragment>
<svelte:fragment slot="servarr-components">
{@const radarrMovie = $radarrMovieStore.item}
{#if radarrMovie}
{#if radarrMovie?.movieFile?.quality}
<div class="col-span-2 lg:col-span-1">
@@ -284,11 +300,3 @@
</svelte:fragment>
</TitlePageLayout>
{/await}
<!-- {#if requestModalVisible} -->
<!-- {@const radarrMovie = $itemStore.item?.radarrMovie} -->
<!-- {#if radarrMovie && radarrMovie.id} -->
<!-- <RequestModal modalProps={requestModalProps} radarrId={radarrMovie.id} /> -->
<!-- {/if} -->
<!-- {/if} -->
<!-- -->

View File

@@ -31,7 +31,7 @@
import type { TitleId } from '$lib/types';
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronLeft, ChevronRight, Plus } from 'radix-icons-svelte';
import { Archive, ChevronLeft, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { get } from 'svelte/store';
@@ -46,9 +46,43 @@
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
let seasonSelectVisible = false;
let visibleSeasonNumber: number | undefined = undefined;
let visibleSeasonNumber: number = 1;
let visibleEpisodeIndex: number | undefined = undefined;
let jellyfinEpisodeData: {
[key: string]: {
jellyfinId: string | undefined;
progress: number;
watched: boolean;
};
} = {};
let episodeComponents: HTMLDivElement[] = [];
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
// Refresh jellyfin episode data
jellyfinItemStore.subscribe(async (value) => {
const item = value.item;
if (!item?.Id) return;
const episodes = await getJellyfinEpisodes(item.Id);
episodes?.forEach((episode) => {
const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`;
if (!nextJellyfinEpisode && episode?.UserData?.Played === false) {
nextJellyfinEpisode = episode;
}
jellyfinEpisodeData[key] = {
jellyfinId: episode?.Id,
progress: episode?.UserData?.PlayedPercentage || 0,
watched: episode?.UserData?.Played || false
};
});
if (!nextJellyfinEpisode) nextJellyfinEpisode = episodes?.[0];
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber;
});
async function loadInitialPageData() {
const tmdbId = await (titleId.provider === 'tvdb'
? getTmdbIdFromTvdbId(titleId.id)
@@ -107,74 +141,6 @@
};
}
let jellyfinEpisodeData: {
[key: string]: {
jellyfinId: string | undefined;
progress: number;
watched: boolean;
};
} = {};
let episodeComponents: HTMLDivElement[] = [];
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
// Refresh jellyfin episode data
jellyfinItemStore.subscribe(async (value) => {
const item = value.item;
if (!item?.Id) return;
const episodes = await getJellyfinEpisodes(item.Id);
episodes?.forEach((episode) => {
const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`;
if (!nextJellyfinEpisode && episode?.UserData?.Played === false) {
nextJellyfinEpisode = episode;
}
jellyfinEpisodeData[key] = {
jellyfinId: episode?.Id,
progress: episode?.UserData?.PlayedPercentage || 0,
watched: episode?.UserData?.Played || false
};
});
if (!nextJellyfinEpisode) nextJellyfinEpisode = episodes?.[0];
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
});
// jellyfinItemStore.promise.then(async (jellyfinItem) => {
// const jellyfinEpisodes = jellyfinItem?.Id ? await getJellyfinEpisodes(jellyfinItem?.Id) : [];
// const tmdbSeasons = await tmdbSeasonsPromise;
// tmdbSeasons.forEach((season) => {
// const episodes: ComponentProps<EpisodeCard>[] = [];
// season?.episodes?.forEach((tmdbEpisode) => {
// const jellyfinEpisode = jellyfinEpisodes?.find(
// (e) =>
// e?.IndexNumber === tmdbEpisode?.episode_number &&
// e?.ParentIndexNumber === tmdbEpisode?.season_number
// );
// if (!nextJellyfinEpisode && jellyfinEpisode?.UserData?.Played === false) {
// nextJellyfinEpisode = jellyfinEpisode;
// }
// episodes.push({
// title: tmdbEpisode?.name || '',
// subtitle: `Episode ${tmdbEpisode?.episode_number}`,
// backdropUrl: TMDB_BACKDROP_SMALL + tmdbEpisode?.still_path || '',
// progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0,
// watched: jellyfinEpisode?.UserData?.Played || false,
// jellyfinId: jellyfinEpisode?.Id,
// airDate: tmdbEpisode.air_date ? new Date(tmdbEpisode.air_date) : undefined
// });
// });
// episodeProps[season?.season_number || 0] = episodes;
// });
// if (!nextJellyfinEpisode) nextJellyfinEpisode = jellyfinEpisodes?.[0];
// visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
// });
function playNextEpisode() {
if (nextJellyfinEpisode?.Id) playerState.streamJellyfinId(nextJellyfinEpisode?.Id || '');
}
@@ -231,23 +197,39 @@
}
</script>
{#await data then { tmdbSeries, tmdbId, ...data }}
{#await data}
<TitlePageLayout {isModal} {handleCloseModal}>
<div slot="episodes-carousel">
<Carousel
gradientFromColor="from-stone-950"
class={classNames('px-2 sm:px-4 lg:px-8', {
'2xl:px-0': !isModal
})}
heading="Episodes"
>
<CarouselPlaceholderItems />
</Carousel>
</div>
</TitlePageLayout>
{:then { tmdbSeries, tmdbId, ...data }}
<TitlePageLayout
{tmdbId}
type="series"
titleInformation={{
tmdbId,
type: 'series',
backdropUriCandidates: tmdbSeries?.images?.backdrops?.map((b) => b.file_path || '') || [],
posterPath: tmdbSeries?.poster_path || '',
title: tmdbSeries?.name || '',
tagline: tmdbSeries?.tagline || tmdbSeries?.name || '',
overview: tmdbSeries?.overview || ''
}}
{isModal}
{handleCloseModal}
backdropUriCandidates={tmdbSeries?.images?.backdrops?.map((b) => b.file_path || '') || []}
posterPath={tmdbSeries?.poster_path || ''}
title={tmdbSeries?.name || ''}
tagline={tmdbSeries?.tagline || tmdbSeries?.name || ''}
overview={tmdbSeries?.overview || ''}
>
<svelte:fragment slot="title-info-1">
<svelte:fragment slot="title-info">
{new Date(tmdbSeries?.first_air_date || Date.now()).getFullYear()}
</svelte:fragment>
<svelte:fragment slot="title-info-2">{tmdbSeries?.status}</svelte:fragment>
<svelte:fragment slot="title-info-3">
<DotFilled />
{tmdbSeries?.status}
<DotFilled />
<a href={data.tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
</svelte:fragment>
@@ -295,7 +277,7 @@
<UiCarousel slot="title" class="flex gap-6">
{#each [...Array(tmdbSeries?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber}
{@const season = tmdbSeries?.seasons?.find((s) => s.season_number === seasonNumber)}
{@const isSelected = season?.season_number === (visibleSeasonNumber || 1)}
{@const isSelected = season?.season_number === visibleSeasonNumber}
<button
class={classNames(
'font-medium tracking-wide transition-colors flex-shrink-0 flex items-center gap-1',
@@ -329,7 +311,7 @@
{/each}
</UiCarousel>
{#key visibleSeasonNumber}
{#each data.tmdbEpisodeProps[(visibleSeasonNumber || 1) - 1] || [] as props, i}
{#each data.tmdbEpisodeProps[visibleSeasonNumber - 1] || [] as props, i}
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
<div bind:this={episodeComponents[i]}>
<EpisodeCard