Series page finalized
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { modalStack } from './Modal';
|
||||
|
||||
// export let visible = false;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{#if !!back}
|
||||
<ChevronLeft size={20} />
|
||||
{/if}
|
||||
<h1>{text}</h1>
|
||||
<h1 class="font-medium">{text}</h1>
|
||||
</button>
|
||||
{/if}
|
||||
</slot>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
428
src/routes/series/[id]/SeriesPage.svelte
Normal file
428
src/routes/series/[id]/SeriesPage.svelte
Normal 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
9
static/radarr.svg
Normal 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
33
static/sonarr.svg
Normal 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
1
static/tmdb.svg
Normal 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 |
Reference in New Issue
Block a user