Series page finalized

This commit is contained in:
Aleksi Lassila
2023-08-05 02:27:07 +03:00
parent 3092e1cc9d
commit 6809e20ed5
18 changed files with 584 additions and 296 deletions

View File

@@ -25,7 +25,7 @@ export interface TmdbMovieFull2 extends TmdbMovie2 {
export interface TmdbSeriesFull2 extends TmdbSeries2 {
videos: operations['tv-series-videos']['responses']['200']['content']['application/json'];
credits: operations['tv-series-credits']['responses']['200']['content']['application/json'];
aggregate_credits: operations['tv-series-credits']['responses']['200']['content']['application/json'];
external_ids: operations['tv-series-external-ids']['responses']['200']['content']['application/json'];
images: operations['tv-series-images']['responses']['200']['content']['application/json'];
}
@@ -75,7 +75,7 @@ export const getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | u
series_id: tmdbId
},
query: {
append_to_response: 'videos,credits,external_ids,images',
append_to_response: 'videos,aggregate_credits,external_ids,images',
...({ include_image_language: get(settings).language + ',en,null' } as any)
}
},
@@ -115,15 +115,17 @@ export const getTmdbSeriesImages = async (tmdbId: number) =>
}).then((res) => res.data);
export const getTmdbSeriesBackdrop = async (tmdbId: number) =>
getTmdbSeriesImages(tmdbId).then(
(r) =>
(
r?.backdrops?.find((b) => b.iso_639_1 === get(settings).language) ||
r?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
r?.backdrops?.find((b) => b.iso_639_1) ||
r?.backdrops?.[0]
)?.file_path
);
getTmdbSeries(tmdbId)
.then((s) => s?.images)
.then(
(r) =>
(
r?.backdrops?.find((b) => b.iso_639_1 === get(settings).language) ||
r?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
r?.backdrops?.find((b) => b.iso_639_1) ||
r?.backdrops?.[0]
)?.file_path
);
export const getTmdbMovieImages = async (tmdbId: number) =>
await TmdbApiOpen.get('/3/movie/{movie_id}/images', {
@@ -138,15 +140,17 @@ export const getTmdbMovieImages = async (tmdbId: number) =>
}).then((res) => res.data);
export const getTmdbMovieBackdrop = async (tmdbId: number) =>
getTmdbMovieImages(tmdbId).then(
(r) =>
(
r?.backdrops?.find((b) => b.iso_639_1 === get(settings).language) ||
r?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
r?.backdrops?.find((b) => b.iso_639_1) ||
r?.backdrops?.[0]
)?.file_path
);
getTmdbMovie(tmdbId)
.then((m) => m?.images)
.then(
(r) =>
(
r?.backdrops?.find((b) => b.iso_639_1 === get(settings).language) ||
r?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
r?.backdrops?.find((b) => b.iso_639_1) ||
r?.backdrops?.[0]
)?.file_path
);
export const getTmdbPopularMovies = () =>
TmdbApiOpen.get('/3/movie/popular', {
@@ -229,6 +233,33 @@ export const getTmdbGenreMovies = (genreId: number) =>
}
}).then((res) => res.data?.results || []);
export const getTmdbSeriesRecommendations = (tmdbId: number) =>
TmdbApiOpen.get('/3/tv/{series_id}/recommendations', {
params: {
path: {
series_id: tmdbId
}
}
}).then((res) => res.data?.results || []);
export const getTmdbSeriesSimilar = (tmdbId: number) =>
TmdbApiOpen.get('/3/tv/{series_id}/similar', {
params: {
path: {
series_id: String(tmdbId)
}
}
}).then((res) => res.data?.results || []);
export const getTmdbSeriesCredits = (tmdbId: number) =>
TmdbApiOpen.get('/3/tv/{series_id}/credits', {
params: {
path: {
series_id: tmdbId
}
}
}).then((res) => res.data?.cast || []);
// Deprecated hereon forward
/** @deprecated */

View File

@@ -12,31 +12,18 @@
export let target: string | undefined = '_self';
let buttonStyle: string;
// $: buttonStyle = classNames(
// 'border-2 border-white transition-all uppercase tracking-widest text-xs whitespace-nowrap',
// {
// 'bg-white text-zinc-900 font-extrabold': type === 'primary',
// 'hover:bg-amber-400 hover:border-amber-400': type === 'primary' && !disabled,
// 'font-semibold': type === 'secondary',
// 'hover:bg-white hover:text-black': type === 'secondary' && !disabled,
// 'px-8 py-3.5': size === 'lg',
// 'px-6 py-2.5': size === 'md',
// 'px-5 py-2': size === 'sm',
// 'opacity-70': disabled,
// 'cursor-pointer': !disabled
// }
// );
$: buttonStyle = classNames(
'flex items-center gap-1 py-3 px-6 rounded-xl font-medium select-none cursor-pointer selectable transition-all backdrop-blur-lg',
'flex items-center gap-1 rounded-xl font-medium select-none cursor-pointer selectable transition-all flex-shrink-0',
{
'bg-white text-zinc-900 font-extrabold': type === 'primary',
'bg-white text-zinc-900 font-extrabold backdrop-blur-lg': type === 'primary',
'hover:bg-amber-400 hover:border-amber-400': type === 'primary' && !disabled,
'text-zinc-200 bg-stone-800 bg-opacity-30': type === 'secondary',
'text-zinc-200 bg-zinc-500 bg-opacity-30 backdrop-blur-lg': type === 'secondary',
'focus-visible:bg-zinc-200 focus-visible:text-zinc-800 hover:bg-zinc-200 hover:text-zinc-800':
type === 'secondary' && !disabled,
(type === 'secondary' || type === 'tertiary') && !disabled,
'rounded-full': type === 'tertiary',
'py-3 px-6': size === 'lg',
// 'py-3 px-6': size === 'md',
'px-5 py-2': size === 'sm',
'py-2 px-6': size === 'md',
'py-1 px-3': size === 'sm',
'opacity-50': disabled,
'cursor-pointer': !disabled
}

View File

@@ -1,12 +1,5 @@
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
import {
fetchTmdbMovieImages,
getTmdbMovieBackdrop,
getTmdbMovieImages,
getTmdbSeriesBackdrop,
getTmdbSeriesImages
} from '$lib/apis/tmdb/tmdbApi';
import type { TmdbMovie, TmdbMovie2, TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
import type { TmdbMovie2, TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
import { getTmdbMovieBackdrop, getTmdbSeriesBackdrop } from '$lib/apis/tmdb/tmdbApi';
import type { ComponentProps } from 'svelte';
import type Card from './Card.svelte';

View File

@@ -40,13 +40,13 @@
{#if scrollX > 50}
<div
transition:fade={{ duration: 200 }}
class={'absolute inset-y-4 left-0 w-24 bg-gradient-to-r ' + gradientFromColor}
class={'absolute inset-y-4 left-0 w-8 sm:w-16 md:w-24 bg-gradient-to-r ' + gradientFromColor}
/>
{/if}
{#if carousel && scrollX < carousel?.scrollWidth - carousel?.clientWidth - 50}
<div
transition:fade={{ duration: 200 }}
class={'absolute inset-y-4 right-0 w-24 bg-gradient-to-l ' + gradientFromColor}
class={'absolute inset-y-4 right-0 w-8 sm:w-16 md:w-24 bg-gradient-to-l ' + gradientFromColor}
/>
{/if}
</div>

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import classNames from 'classnames';
import { TriangleRight } from 'radix-icons-svelte';
import IconButton from '../IconButton.svelte';
import { DotsHorizontal, TriangleRight } from 'radix-icons-svelte';
import { fade } from 'svelte/transition';
import IconButton from '../IconButton.svelte';
export let backdropPath: string;
@@ -19,7 +19,8 @@
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div
<button
on:click
class={classNames(
'aspect-video bg-center bg-cover bg-no-repeat rounded-lg overflow-hidden transition-all shadow-lg relative cursor-pointer selectable flex-shrink-0',
{
@@ -28,21 +29,18 @@
group: !!handlePlay
}
)}
tabindex="0"
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropPath + "');"}
transition:fade|global
in:fade|global={{ duration: 100, delay: 100 }}
out:fade|global={{ duration: 100 }}
>
<div
class={classNames(
'flex flex-col justify-between h-full group-hover:opacity-0 transition-opacity',
{
'px-2 lg:px-3 pt-2': true,
' pb-4 lg:pb-6': progress,
'pb-2': !progress,
'bg-gradient-to-t from-darken': !!handlePlay,
'bg-darken': !handlePlay
}
)}
class={classNames('flex flex-col justify-between h-full group-hover:opacity-0 transition-all', {
'px-2 lg:px-3 pt-2': true,
' pb-4 lg:pb-6': progress,
'pb-2': !progress,
'bg-gradient-to-t from-darken': !!handlePlay,
'bg-darken': !handlePlay
})}
>
<div class="flex justify-between items-center">
<div>
@@ -62,14 +60,14 @@
</slot>
</div>
</div>
<div class="flex items-bottom justify-between">
<div class="flex items-end justify-between">
<slot name="left-bottom">
<div class="flex flex-col">
<div class="flex flex-col items-start">
{#if subtitle}
<div class="text-zinc-300 text-sm font-medium">{subtitle}</div>
{/if}
{#if title}
<div class="font-medium">
<div class="font-medium text-left">
{title}
</div>
{/if}
@@ -94,4 +92,4 @@
<div style={'width: ' + progress + '%'} class="h-full bg-zinc-200" />
</div>
{/if}
</div>
</button>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { modalStack } from './Modal';
// export let visible = false;

View File

@@ -6,6 +6,6 @@
{#if $modalStack.top}
<div
class="fixed inset-0 bg-stone-900 bg-opacity-50 z-[19] overflow-hidden"
transition:fade={{ duration: 100 }}
transition:fade|global={{ duration: 100 }}
/>
{/if}

View File

@@ -4,7 +4,8 @@
<div
class="max-w-3xl self-start mt-[10vh] bg-[#33333388] backdrop-blur-xl rounded overflow-hidden flex flex-col flex-1 mx-4 sm:mx-16 lg:mx-24 drop-shadow-xl"
in:fly|global={{ y: 20, duration: 200 }}
in:fly|global={{ y: 20, duration: 200, delay: 200 }}
out:fly|global={{ y: 20, duration: 200 }}
>
<slot />
</div>

View File

@@ -24,7 +24,7 @@
{#if !!back}
<ChevronLeft size={20} />
{/if}
<h1>{text}</h1>
<h1 class="font-medium">{text}</h1>
</button>
{/if}
</slot>

View File

@@ -3,11 +3,11 @@
import classNames from 'classnames';
export let tmdbId: number;
export let knownFor: string[];
export let knownFor: string[] = [];
export let name: string;
export let backdropUri: string;
export let department: string;
export let size: 'dynamic' | 'md' | 'lg' = 'lg';
export let size: 'dynamic' | 'md' | 'lg' = 'md';
</script>
<a
@@ -29,13 +29,19 @@
{knownFor.join(', ')}
</h2> -->
</div>
<div class="bg-gradient-to-t from-darken from-20% to-transparent p-2 px-3 pt-8">
<div class="bg-gradient-to-t from-darken from-40% p-2 px-3 pt-8">
<h2
class="text-xs text-zinc-300 tracking-wider font-medium opacity-0 group-hover:opacity-100"
>
{department}
</h2>
<h1 class="font-bold tracking-wider text-lg">{name}</h1>
<h1
class={classNames('font-semibold tracking-wider', {
'text-lg': size === 'lg'
})}
>
{name}
</h1>
</div>
</div>
<div

View File

@@ -9,10 +9,13 @@
import EpisodeSelectModal from './EpisodeSelectModal.svelte';
import RequestModal from './RequestModal.svelte';
import type { SonarrEpisode } from '$lib/apis/sonarr/sonarrApi';
import Button from '../Button.svelte';
import { ChevronRight } from 'radix-icons-svelte';
export let modalProps: ModalProps;
export let sonarrId: number;
export let seasons: number;
export let heading = 'Seasons';
let episodeSelectProps: Omit<ComponentProps<EpisodeSelectModal>, 'modalProps'> | undefined =
undefined;
@@ -64,21 +67,23 @@
<Modal {...modalProps}>
<ModalContainer>
<ModalHeader {...modalProps} back={undefined} text="Seasons" />
<ModalHeader {...modalProps} back={undefined} text={heading} />
<ModalContent>
<div class="flex flex-col divide-y divide-zinc-700">
{#each [...Array(seasons).keys()].map((i) => i + 1) as seasonNumber}
<div
class="px-4 py-3 flex justify-between items-center text-zinc-300 group-hover:text-zinc-300"
>
<div class="uppercase font-bold text-sm">
<div class="font-medium">
Season {seasonNumber}
</div>
<div class="flex gap-2">
<RoundedButton on:click={() => selectSeasonPack(seasonNumber)}
>Season Packs</RoundedButton
>
<RoundedButton on:click={() => selectSeason(seasonNumber)}>Episodes</RoundedButton>
<Button size="sm" type="tertiary" on:click={() => selectSeasonPack(seasonNumber)}>
<span>Season Packs</span><ChevronRight size={20} />
</Button>
<Button size="sm" type="tertiary" on:click={() => selectSeason(seasonNumber)}>
<span>Episodes</span><ChevronRight size={20} />
</Button>
</div>
</div>
{/each}

View File

@@ -283,7 +283,7 @@
</Button>
</div> -->
<div style={opacityStyle} class:hidden={showDetails}>
<Button href={`/${type}/${tmdbId}`}>
<Button size="lg" href={`/${type}/${tmdbId}`}>
<span>Details</span>
<ChevronRight size={20} />
</Button>

View File

@@ -67,7 +67,8 @@
name: actor.name || '',
backdropUri: actor.profile_path || '',
knownFor: actor.known_for?.map((movie) => movie.title || '') || [],
department: actor.known_for_department || ''
department: actor.known_for_department || '',
size: 'lg'
}))
);

View File

@@ -1,217 +1,13 @@
<script lang="ts">
import { getTmdbSeries, getTmdbSeriesSeasons, type TmdbSeason } from '$lib/apis/tmdb/tmdbApi';
import Button from '$lib/components/Button.svelte';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import classNames from 'classnames';
import { ChevronRight, DotFilled } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
import type { PageData } from './$types';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { capitalize } from '$lib/utils';
import SeriesPage from './SeriesPage.svelte';
export let data: PageData;
let tmdbId = Number(data.tmdbId);
const itemStore = createLibraryItemStore(tmdbId);
let visibleSeason = 1;
let selectedEpisode;
let episodeProps: ComponentProps<EpisodeCard>[][] = [];
let tmdbSeriesPromise = (() => {
const tmdbId = Number(data.tmdbId);
const series = getTmdbSeries(tmdbId);
const seasons = series.then((s) => getTmdbSeriesSeasons(tmdbId, s?.number_of_seasons || 0));
return {
series,
seasons
};
})();
itemStore.subscribe(async (libraryItem) => {
const tmdbSeasons = await tmdbSeriesPromise.seasons;
tmdbSeasons.forEach((season) => {
const episodes: ComponentProps<EpisodeCard>[] = [];
season?.episodes?.forEach((tmdbEpisode) => {
const jellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.find(
(e) =>
e?.IndexNumber === tmdbEpisode?.episode_number &&
e?.ParentIndexNumber === tmdbEpisode?.season_number
);
episodes.push({
title: tmdbEpisode?.name || '',
subtitle: `Episode ${tmdbEpisode?.episode_number}`,
backdropPath: tmdbEpisode?.still_path || '',
progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0,
handlePlay: jellyfinEpisode?.Id
? () => playerState.streamJellyfinId(jellyfinEpisode?.Id || '')
: undefined
});
});
episodeProps[season?.season_number || 0] = episodes;
});
});
let tmdbId: number;
$: tmdbId = Number(data.tmdbId);
</script>
<div class="fixed inset-0 bg-black -z-20" />
{#await tmdbSeriesPromise.series then series}
<div class="flex flex-col max-h-screen bg-black">
<div
transition:fade
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + series?.backdrop_path + "')"}
class="flex-shrink relative flex pt-24 aspect-video min-h-[50vh] p-8 bg-center bg-cover w-screen"
>
<div class="absolute inset-0 bg-gradient-to-t from-black to-50% to-darken" />
<div class="z-[1] flex-1 flex justify-end gap-8 items-end">
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
style={"background-image: url('" + TMDB_POSTER_SMALL + series?.poster_path + "')"}
/>
<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>{new Date(series?.first_air_date || Date.now()).getFullYear()}</p>
<DotFilled />
<p>{series?.status}</p>
<DotFilled />
<p>{series?.genres?.map((g) => g.name).join(', ')}</p>
<DotFilled />
<p>{series?.vote_average?.toFixed(1)} TMDB</p>
</div>
<h1 class="text-6xl font-semibold">{series?.name}</h1>
</div>
<div class="flex-shrink-0">
<Button type="primary"><span>Add to Sonarr</span><ChevronRight size={20} /></Button>
</div>
</div>
</div>
</div>
<div>
<Carousel gradientFromColor="from-black">
<div slot="title" class="flex gap-6">
{#each [...Array(series?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber}
{@const season = series?.seasons?.find((s) => s.season_number === seasonNumber)}
{@const isSelected = season?.season_number === visibleSeason}
<button
class={classNames('text-lg font-semibold tracking-wide', {
'text-zinc-200 cursor-default': isSelected,
'text-zinc-500 hover:text-zinc-200 cursor-pointer': !isSelected
})}
on:click={() => (visibleSeason = season?.season_number || 1)}
>
Season {season?.season_number}
</button>
{/each}
</div>
{#each episodeProps[visibleSeason] || [] as props}
<div class="flex flex-col gap-3">
<EpisodeCard {...props} />
<!-- <EpisodeCard backdropPath={props.backdropPath} />
<div class="flex items-end justify-between">
<div>
<div class="text-zinc-400 text-xs font-medium">{props.episodeNumber}</div>
<h1 class="font-medium text-lg line-clamp-1">{props.title}</h1>
</div>
<div class="flex-shrink-0 text-zinc-300 font-medium">{props.runtime} min</div>
</div> -->
</div>
{:else}
<CarouselPlaceholderItems />
{/each}
</Carousel>
</div>
</div>
<div class="flex gap-8 p-8 flex-col xl:flex-row">
<div class="flex-1">
<div class="flex flex-col gap-3 max-w-5xl">
<h1 class="font-semibold text-2xl">{series?.tagline || series?.name}</h1>
<p class="pl-4 border-l-2">{series?.overview}</p>
</div>
</div>
<div class="flex-1 grid grid-cols-3 gap-4">
<div>
<p class="text-zinc-400 text-sm">Created By</p>
<h2 class="font-medium">{series?.created_by?.map((c) => c.name).join(', ')}</h2>
</div>
{#if series?.first_air_date}
<div>
<p class="text-zinc-400 text-sm">First Air Date</p>
<h2 class="font-medium">
{new Date(series?.first_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{/if}
{#if series?.next_episode_to_air}
<div>
<p class="text-zinc-400 text-sm">Next Air Date</p>
<h2 class="font-medium">
{new Date(series.next_episode_to_air?.air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{:else if series?.last_air_date}
<div>
<p class="text-zinc-400 text-sm">Last Air Date</p>
<h2 class="font-medium">
{new Date(series.last_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{/if}
<div>
<p class="text-zinc-400 text-sm">Networks</p>
<h2 class="font-medium">{series?.networks?.map((n) => n.name).join(', ')}</h2>
</div>
<div>
<p class="text-zinc-400 text-sm">Episode Run Time</p>
<h2 class="font-medium">{series?.episode_run_time} Minutes</h2>
</div>
<div>
<p class="text-zinc-400 text-sm">Spoken Languages</p>
<h2 class="font-medium">
{series?.spoken_languages?.map((l) => capitalize(l.name || '')).join(', ')}
</h2>
</div>
</div>
</div>
<div class="flex items-center justify-between p-8">
<div>
<h1 class="font-medium text-lg">No sources found</h1>
<p class="text-sm text-zinc-400">Check your source settings</p>
</div>
<Button type="primary" size="sm">Add to Sonarr</Button>
</div>
<Carousel gradientFromColor="from-black">
<div slot="title" class="font-medium">Cast & Crew</div>
<CarouselPlaceholderItems />
</Carousel>
<Carousel gradientFromColor="from-black">
<div slot="title" class="font-medium">Recommended</div>
<CarouselPlaceholderItems />
</Carousel>
<Carousel gradientFromColor="from-black">
<div slot="title" class="font-medium">Similar Titles</div>
<CarouselPlaceholderItems />
</Carousel>
{/await}
{#key tmdbId}
<SeriesPage {tmdbId} />
{/key}

View File

@@ -0,0 +1,428 @@
<script lang="ts">
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
import {
getTmdbSeries,
getTmdbSeriesRecommendations,
getTmdbSeriesSeasons,
getTmdbSeriesSimilar
} from '$lib/apis/tmdb/tmdbApi';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
import { createModalProps } from '$lib/components/Modal/Modal';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import SeriesRequestModal from '$lib/components/RequestModal/SeriesRequestModal.svelte';
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
import { createLibraryItemStore, library } from '$lib/stores/library.store';
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { Archive, ChevronLeft, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
export let tmdbId: number;
const itemStore = createLibraryItemStore(tmdbId);
let seasonSelectVisible = false;
let visibleSeasonNumber: number | undefined = undefined;
let visibleEpisodeNumber: number | undefined = undefined;
let requestModalVisible = false;
const requestModalProps = createModalProps(() => (requestModalVisible = false));
let episodeProps: ComponentProps<EpisodeCard>[][] = [];
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
getTmdbSeriesSeasons(tmdbId, s?.number_of_seasons || 0)
);
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.backdropUri));
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
Promise.all(
s?.aggregate_credits?.cast?.map((m) => ({
tmdbId: m.id || 0,
name: m.name || '',
backdropUri: m.profile_path || '',
department: m.known_for_department || ''
})) || []
)
);
itemStore.subscribe(async (libraryItem) => {
const tmdbSeasons = await tmdbSeasonsPromise;
tmdbSeasons.forEach((season) => {
const episodes: ComponentProps<EpisodeCard>[] = [];
season?.episodes?.forEach((tmdbEpisode) => {
const jellyfinEpisode = libraryItem.item?.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}`,
backdropPath: tmdbEpisode?.still_path || '',
progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0,
handlePlay: jellyfinEpisode?.Id
? () => playerState.streamJellyfinId(jellyfinEpisode?.Id || '')
: undefined
});
});
episodeProps[season?.season_number || 0] = episodes;
});
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
});
function playNextEpisode() {
if (nextJellyfinEpisode?.Id) playerState.streamJellyfinId(nextJellyfinEpisode?.Id || '');
}
async function refresh() {
await library.refresh();
}
let addToRadarrLoading = false;
function addToSonarr() {
addToRadarrLoading = true;
addSeriesToSonarr(tmdbId)
.then(refresh)
.finally(() => (addToRadarrLoading = false));
}
</script>
{#await tmdbSeriesPromise then series}
<div class="flex flex-col max-h-screen bg-black pb-2" transition:fade>
<div
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + series?.backdrop_path + "')"}
class="flex-shrink relative flex pt-24 aspect-video min-h-[70vh] p-8 bg-center bg-cover w-screen sm:bg-fixed"
>
<div class="absolute inset-0 bg-gradient-to-t from-black to-50% to-darken" />
<div class="z-[1] flex-1 flex justify-end gap-8 items-end">
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
style={"background-image: url('" + TMDB_POSTER_SMALL + series?.poster_path + "')"}
/>
<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">
{new Date(series?.first_air_date || Date.now()).getFullYear()}
</p>
<DotFilled />
<p class="flex-shrink-0">{series?.status}</p>
<DotFilled />
<p class="flex-shrink-0">{series?.vote_average?.toFixed(1)} TMDB</p>
<!-- <DotFilled />
<p class="line-clamp-1">{series?.genres?.map((g) => g.name).join(', ')}</p> -->
</div>
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{series?.name}</h1>
</div>
<div class="flex-shrink-0">
{#if $itemStore.loading}
<div class="placeholder h-10 w-48 rounded-xl" />
{:else if $itemStore.item?.sonarrSeries?.statistics?.sizeOnDisk}
<Button type="primary" on:click={playNextEpisode}>
<span>Next Episode</span><ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToSonarr}>
<span>Add to Sonarr</span><Plus size={20} />
</Button>
{:else}
<Button type="primary" on:click={() => (requestModalVisible = true)}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
{/if}
</div>
</div>
</div>
</div>
<div>
<Carousel gradientFromColor="from-black">
<UiCarousel slot="title" class="flex gap-6">
{#each [...Array(series?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber}
{@const season = series?.seasons?.find((s) => s.season_number === seasonNumber)}
{@const isSelected = season?.season_number === (visibleSeasonNumber || 1)}
<button
class={classNames(
'text-lg font-medium tracking-wide transition-colors flex-shrink-0 flex items-center gap-1',
{
'text-zinc-200': isSelected && seasonSelectVisible,
'text-zinc-500 hover:text-zinc-200 cursor-pointer':
(!isSelected || seasonSelectVisible === false) &&
series?.number_of_seasons !== 1,
'text-zinc-500 cursor-default': series?.number_of_seasons === 1,
hidden:
!seasonSelectVisible && visibleSeasonNumber !== (season?.season_number || 1)
}
)}
on:click={() => {
if (series?.number_of_seasons === 1) return;
if (seasonSelectVisible) {
visibleSeasonNumber = season?.season_number || 1;
seasonSelectVisible = false;
} else {
seasonSelectVisible = true;
}
}}
>
<ChevronLeft
size={22}
class={(seasonSelectVisible || series?.number_of_seasons === 1) && 'hidden'}
/>
Season {season?.season_number}
</button>
{/each}
</UiCarousel>
{#key visibleSeasonNumber}
{#each episodeProps[visibleSeasonNumber || 1] || [] as props, i}
<div class="flex flex-col gap-3" id={'episode-card-' + (i + 1)}>
<EpisodeCard {...props} on:click={() => (visibleEpisodeNumber = i)} />
</div>
{:else}
<CarouselPlaceholderItems />
{/each}
{/key}
</Carousel>
</div>
</div>
<div class="flex flex-col py-4 gap-8 bg-black">
<div
class="mx-8 p-6 px-10 grid grid-cols-4 sm:grid-cols-6 gap-4 sm:gap-x-8 bg-zinc-900 rounded-xl"
>
{#if visibleEpisodeNumber !== undefined}
{#await tmdbSeasonsPromise.then((season) => season?.[visibleSeasonNumber ? visibleSeasonNumber - 1 : 0]?.episodes?.[visibleEpisodeNumber || 0]) then episode}
<div class="flex flex-col gap-3 max-w-5xl items-start">
<button
class="flex items-center text-zinc-400 text-sm"
on:click={() => (visibleEpisodeNumber = undefined)}><ChevronLeft />Back</button
>
<h1 class="font-semibold text-2xl">
{episode?.name || 'Episode ' + episode?.episode_number}
</h1>
<p class="pl-4 border-l-2">{episode?.overview}</p>
</div>
{/await}
{:else}
<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-2xl">{series?.tagline || series?.name}</h1>
<!-- <div class="flex items-center gap-4">
<a
target="_blank"
href={'https://www.themoviedb.org/tv/' + tmdbId}
class="opacity-60 hover:opacity-100"
>
<img src="/tmdb.svg" alt="tmdb" width="25px" />
</a>
{#if $itemStore.item?.sonarrSeries?.titleSlug}
<a
target="_blank"
href={PUBLIC_SONARR_BASE_URL +
'/series/' +
$itemStore.item?.sonarrSeries?.titleSlug}
class="opacity-60 hover:opacity-100"
>
<img src="/sonarr.svg" alt="sonarr" width="15px" />
</a>
{/if}
{#if series?.homepage}
<a
target="_blank"
href={series.homepage}
class="flex gap-1 items-center opacity-60 hover:opacity-100"
>
<Globe size={15} />
</a>
{/if} -->
</div>
<p class="pl-4 border-l-2">{series?.overview}</p>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Created By</p>
<h2 class="font-medium">{series?.created_by?.map((c) => c.name).join(', ')}</h2>
</div>
{#if series?.first_air_date}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">First Air Date</p>
<h2 class="font-medium">
{new Date(series?.first_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{/if}
{#if series?.next_episode_to_air}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Next Air Date</p>
<h2 class="font-medium">
{new Date(series.next_episode_to_air?.air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{:else if series?.last_air_date}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Last Air Date</p>
<h2 class="font-medium">
{new Date(series.last_air_date).toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</h2>
</div>
{/if}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Networks</p>
<h2 class="font-medium">{series?.networks?.map((n) => n.name).join(', ')}</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Episode Run Time</p>
<h2 class="font-medium">{series?.episode_run_time} Minutes</h2>
</div>
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Spoken Languages</p>
<h2 class="font-medium">
{series?.spoken_languages?.map((l) => capitalize(l.name || '')).join(', ')}
</h2>
</div>
{#if $itemStore.loading}
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
<div class="placeholder h-10 w-48 rounded-xl" />
<div class="placeholder h-10 w-48 rounded-xl" />
</div>
{:else}
{@const item = $itemStore.item}
{#if !!item?.sonarrSeries}
{#if item.sonarrSeries.statistics?.episodeFileCount}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Available</p>
<h2 class="font-medium">
{item.sonarrSeries.statistics?.episodeFileCount || 0} Episodes
</h2>
</div>
{/if}
{#if item.sonarrSeries.statistics?.sizeOnDisk}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Size On Disk</p>
<h2 class="font-medium">
{formatSize(item.sonarrSeries.statistics?.sizeOnDisk || 0)}
</h2>
</div>
{/if}
{#if $itemStore.item?.download}
<div class="col-span-2 lg:col-span-1">
<p class="text-zinc-400 text-sm">Download Completed In</p>
<h2 class="font-medium">
{formatMinutesToTime(
(new Date($itemStore.item?.download.completionTime).getTime() - Date.now()) /
1000 /
60
)}
</h2>
</div>
{/if}
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
<Button on:click={() => (requestModalVisible = true)}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
<Button>
<span class="mr-2">Manage</span><Archive size={20} />
</Button>
</div>
{:else}
<!-- <div
class="flex-1 flex justify-between py-2 gap-4 items-start sm:items-center flex-col sm:flex-row"
>
<div>
<h1 class="font-medium text-lg">No sources found</h1>
<p class="text-sm text-zinc-400">Check your source settings</p>
</div>
<Button type="secondary"><span>Add to Sonarr</span><Plus size={20} /></Button>
</div> -->
{/if}
{/if}
{/if}
</div>
<div>
<Carousel gradientFromColor="from-black">
<div slot="title" class="font-medium text-lg">Cast & Crew</div>
{#await castProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<PeopleCard {...prop} />
{/each}
{/await}
</Carousel>
</div>
<div>
<Carousel gradientFromColor="from-black">
<div slot="title" class="font-medium text-lg">Recommendations</div>
{#await tmdbRecommendationProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<Card {...prop} />
{/each}
{/await}
</Carousel>
</div>
<div>
<Carousel gradientFromColor="from-black">
<div slot="title" class="font-medium text-lg">Similar Series</div>
{#await tmdbSimilarProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<Card {...prop} />
{/each}
{/await}
</Carousel>
</div>
</div>
{/await}
{#if requestModalVisible}
{@const sonarrSeries = $itemStore.item?.sonarrSeries}
{#if sonarrSeries && sonarrSeries.id && sonarrSeries?.statistics?.seasonCount}
<SeriesRequestModal
modalProps={requestModalProps}
sonarrId={sonarrSeries.id}
seasons={sonarrSeries?.statistics.seasonCount}
heading={sonarrSeries.title || 'Series'}
/>
{/if}
{/if}

9
static/radarr.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg
viewBox="0 0 1000 1115.2"
xmlns="http://www.w3.org/2000/svg"
class={$$restProps.class || 'h-10 w-10 flex-shrink-0'}
><path
d="m120.015 174.916-1.433 810.916C50.198 993.53-.595 958.758.245 890.481L0 216.126C2.624 2.76 199.555-46.036 317.994 40.776l601.67 357.383c84.615 60.794 100.32 171.956 56.7 248.25-7.799-59.85-32.984-94.304-83.773-129.073l-678.066-392.46c-50.79-34.768-93.568-26.758-94.513 50.056zm-61.707 852.847c51 17.7 102.314 9.794 145.3-15.285L908.5 611.405c41.94 60.268 32.671 119.903-18.958 153.414L296.44 1098.972c-85.873 41.624-196.297-2.414-238.138-71.217z"
fill="#fff"
/><path d="m272.941 797.285 414.225-245.888L273.7 327.762z" fill="#ffc230" /></svg
>

After

Width:  |  Height:  |  Size: 684 B

33
static/sonarr.svg Normal file
View File

@@ -0,0 +1,33 @@
<svg
viewBox="0 0 216.7 216.9"
xmlns="http://www.w3.org/2000/svg"
><path
d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3a92.767 92.767 0 0 1-11 9.25c-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z"
clip-rule="evenodd"
fill="#EEE"
fill-rule="evenodd"
/><path
d="m194.65 42.5-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3a90.601 90.601 0 0 1-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6a95.782 95.782 0 0 1-10.7-9.5c-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4a134.482 134.482 0 0 1 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7a121.188 121.188 0 0 1 9.55 10.95z"
clip-rule="evenodd"
fill="#3A3F51"
fill-rule="evenodd"
/><g clip-rule="evenodd"
><path
d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55a39.613 39.613 0 0 1 0-4c0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65a27.364 27.364 0 0 1-3.05 2.55c-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6a26.29 26.29 0 0 1-3.75-3.2c-4.532-4.5-7.316-9.734-8.35-15.7z"
fill="#0CF"
fill-rule="evenodd"
/><path
d="m157.8 59.75-15 14.65M30.785 32.526 71.65 73.25m84.6 84.25 27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126 27.35-27.4"
fill="none"
stroke="#0CF"
stroke-miterlimit="1"
stroke-width="2"
/><path
d="m157.8 59.75-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396 18.028 17.945"
fill="none"
stroke="#0CF"
stroke-miterlimit="1"
stroke-width="7"
/></g
></svg
>

After

Width:  |  Height:  |  Size: 2.3 KiB

1
static/tmdb.svg Normal file
View File

@@ -0,0 +1 @@
<svg 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><title>Asset 2</title><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>

After

Width:  |  Height:  |  Size: 2.2 KiB