Refactored and cleaned up SeriesPage data fetching
This commit is contained in:
@@ -90,7 +90,11 @@ export const getTmdbSeriesFromTvdbId = async (tvdbId: string) =>
|
||||
}).then((res) => res.data?.tv_results?.[0] as TmdbSeries2 | undefined);
|
||||
|
||||
export const getTmdbIdFromTvdbId = async (tvdbId: number) =>
|
||||
getTmdbSeriesFromTvdbId(String(tvdbId)).then((res: any) => res?.id as number | undefined);
|
||||
getTmdbSeriesFromTvdbId(String(tvdbId)).then((res: any) => {
|
||||
const id = res?.id as number | undefined;
|
||||
if (!id) return Promise.reject();
|
||||
return id;
|
||||
});
|
||||
|
||||
export const getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | undefined> =>
|
||||
await TmdbApiOpen.get('/3/tv/{series_id}', {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
)}
|
||||
on:click={() => {
|
||||
if (openInModal) {
|
||||
openTitleModal(tmdbId, type);
|
||||
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
|
||||
} else {
|
||||
window.location.href = `/${type}/${tmdbId}`;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
if (!jellyfinId) return;
|
||||
|
||||
watched = true;
|
||||
progress = 0;
|
||||
setJellyfinItemWatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,21 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames('transition-opacity duration-300', {
|
||||
'opacity-0': !loaded,
|
||||
'opacity-100': loaded
|
||||
})}
|
||||
class={classNames(
|
||||
'transition-opacity duration-300',
|
||||
{
|
||||
'opacity-0': !loaded,
|
||||
'opacity-100': loaded
|
||||
},
|
||||
$$restProps.class
|
||||
)}
|
||||
>
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
class={classNames($$restProps.class)}
|
||||
style="object-fit: cover; width: 100%; height: 100%;"
|
||||
loading="lazy"
|
||||
on:load={handleLoad}
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
import { playerState } from '../VideoPlayer/VideoPlayer';
|
||||
import LazyImg from '../LazyImg.svelte';
|
||||
import { Star } from 'radix-icons-svelte';
|
||||
import { openTitleModal } from '$lib/stores/modal.store';
|
||||
|
||||
export let tmdbId: number | undefined = undefined;
|
||||
export let tvdbId: number | undefined = undefined;
|
||||
export let openInModal = true;
|
||||
export let jellyfinId: string = '';
|
||||
export let type: TitleType = 'movie';
|
||||
export let backdropUrl: string;
|
||||
@@ -22,10 +24,20 @@
|
||||
export let orientation: 'portrait' | 'landscape' = 'landscape';
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#'}
|
||||
<button
|
||||
on:click={() => {
|
||||
if (openInModal) {
|
||||
if (tmdbId) {
|
||||
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
|
||||
} else if (tvdbId) {
|
||||
openTitleModal({ type, id: tvdbId, provider: 'tvdb' });
|
||||
}
|
||||
} else {
|
||||
window.location.href = tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#';
|
||||
}
|
||||
}}
|
||||
class={classNames(
|
||||
'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden',
|
||||
'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left',
|
||||
{
|
||||
'aspect-video': orientation === 'landscape',
|
||||
'aspect-[2/3]': orientation === 'portrait',
|
||||
@@ -40,9 +52,19 @@
|
||||
)}
|
||||
>
|
||||
<LazyImg src={backdropUrl} class="absolute inset-0 group-hover:scale-105 transition-transform" />
|
||||
<div
|
||||
class="absolute inset-0 opacity-0 group-hover:opacity-30 transition-opacity bg-black"
|
||||
style="filter: blur(50px); transform: scale(3);"
|
||||
>
|
||||
<LazyImg src={backdropUrl} />
|
||||
</div>
|
||||
<!-- <div
|
||||
style={`background-image: url(${backdropUrl}); background-size: cover; background-position: center; filter: blur(50px); transform: scale(3);`}
|
||||
class="absolute inset-0 opacity-0 group-hover:opacity-30 transition-opacity bg-black"
|
||||
/> -->
|
||||
<div
|
||||
class={classNames(
|
||||
'flex-1 flex flex-col justify-between bg-black bg-opacity-60 opacity-0 group-hover:opacity-100 transition-opacity z-[1]',
|
||||
'flex-1 flex flex-col justify-between bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity z-[1]',
|
||||
{
|
||||
'py-2 px-3': true
|
||||
}
|
||||
@@ -95,4 +117,4 @@
|
||||
<ProgressBar {progress} />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import Carousel from '../Carousel/Carousel.svelte';
|
||||
import CarouselPlaceholderItems from '../Carousel/CarouselPlaceholderItems.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import LazyImg from '../LazyImg.svelte';
|
||||
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
@@ -33,7 +34,30 @@
|
||||
|
||||
<svelte:window bind:outerHeight={windowHeight} />
|
||||
|
||||
<!-- Desktop -->
|
||||
<div
|
||||
style={'height: ' + imageHeight.toFixed() + 'px'}
|
||||
class={classNames('hidden sm:block inset-x-0 bg-center bg-cover bg-stone-950', {
|
||||
absolute: isModal,
|
||||
fixed: !isModal
|
||||
})}
|
||||
>
|
||||
<LazyImg src={TMDB_IMAGES_ORIGINAL + getBackdropUri(backdropUriCandidates)} class="h-full">
|
||||
<div class="absolute inset-0 bg-darken" />
|
||||
</LazyImg>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- <div
|
||||
style={"background-image: url('" +
|
||||
TMDB_IMAGES_ORIGINAL +
|
||||
getBackdropUri(backdropUriCandidates) +
|
||||
@@ -46,9 +70,9 @@
|
||||
})}
|
||||
>
|
||||
<div class="absolute inset-0 bg-darken" />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div
|
||||
<!-- <div
|
||||
style={"background-image: url('" +
|
||||
TMDB_IMAGES_ORIGINAL +
|
||||
posterPath +
|
||||
@@ -58,7 +82,7 @@
|
||||
class="sm:hidden fixed inset-x-0 bg-center bg-cover bg-stone-950"
|
||||
>
|
||||
<div class="absolute inset-0 bg-darken" />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div
|
||||
class={classNames('flex flex-col relative z-[1]', {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { TitleType } from '$lib/types';
|
||||
import type { TitleId } from '$lib/types';
|
||||
import { fly } from 'svelte/transition';
|
||||
import MoviePage from '../../../routes/movie/[id]/MoviePage.svelte';
|
||||
import SeriesPage from '../../../routes/series/[id]/SeriesPage.svelte';
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let type: TitleType;
|
||||
export let titleId: TitleId;
|
||||
export let modalId: symbol;
|
||||
|
||||
function handleCloseModal() {
|
||||
@@ -22,10 +21,10 @@
|
||||
in:fly|global={{ y: 20, duration: 200, delay: 200 }}
|
||||
out:fly|global={{ y: 20, duration: 200 }}
|
||||
>
|
||||
{#if type === 'movie'}
|
||||
<MoviePage {tmdbId} isModal={true} {handleCloseModal} />
|
||||
{#if titleId.type === 'movie'}
|
||||
<MoviePage tmdbId={titleId.id} isModal={true} {handleCloseModal} />
|
||||
{:else}
|
||||
<SeriesPage {tmdbId} isModal={true} {handleCloseModal} />
|
||||
<SeriesPage {titleId} isModal={true} {handleCloseModal} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,11 @@
|
||||
out:fade|global={{ duration: ANIMATION_DURATION }}
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Button size="lg" type="primary" on:click={() => openTitleModal(tmdbId, type)}>
|
||||
<Button
|
||||
size="lg"
|
||||
type="primary"
|
||||
on:click={() => openTitleModal({ type, id: tmdbId, provider: 'tmdb' })}
|
||||
>
|
||||
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
|
||||
</Button>
|
||||
{#if trailerId}
|
||||
|
||||
@@ -65,13 +65,21 @@ function _createDataFetchStore<T>(fn: () => Promise<T>) {
|
||||
|
||||
export const jellyfinItemsStore = _createDataFetchStore(getJellyfinItems);
|
||||
|
||||
export function createJellyfinItemStore(tmdbId: number) {
|
||||
const store = derived(jellyfinItemsStore, (s) => {
|
||||
return {
|
||||
loading: s.loading,
|
||||
item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(tmdbId))
|
||||
};
|
||||
export function createJellyfinItemStore(tmdbId: number | Promise<number>) {
|
||||
const store = writable<{ loading: boolean; item?: JellyfinItem }>({
|
||||
loading: true,
|
||||
item: undefined
|
||||
});
|
||||
|
||||
jellyfinItemsStore.subscribe(async (s) => {
|
||||
const awaited = await tmdbId;
|
||||
|
||||
store.set({
|
||||
loading: s.loading,
|
||||
item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(awaited))
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh: jellyfinItemsStore.refresh,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TitleType } from '$lib/types';
|
||||
import type { TitleId, TitleType } from '$lib/types';
|
||||
import { writable } from 'svelte/store';
|
||||
import TitlePageModal from '../components/TitlePageLayout/TitlePageModal.svelte';
|
||||
|
||||
@@ -61,9 +61,11 @@ function createDynamicModalStack() {
|
||||
export const modalStack = createDynamicModalStack();
|
||||
|
||||
let lastTitleModal: symbol | undefined = undefined;
|
||||
export function openTitleModal(tmdbId: number, type: TitleType) {
|
||||
export function openTitleModal(titleId: TitleId) {
|
||||
if (lastTitleModal) {
|
||||
modalStack.close(lastTitleModal);
|
||||
}
|
||||
lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type });
|
||||
lastTitleModal = modalStack.create(TitlePageModal, {
|
||||
titleId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export type TitleType = 'movie' | 'series';
|
||||
export type TitleId = {
|
||||
id: number;
|
||||
provider: 'tmdb' | 'tvdb';
|
||||
type: TitleType;
|
||||
};
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
let continueWatchingP = getJellyfinContinueWatching();
|
||||
|
||||
let nextUpProps = Promise.all([nextUpP, continueWatchingP])
|
||||
.then(([nextUp, continueWatching]) => [...(continueWatching || []), ...(nextUp || [])])
|
||||
.then(log)
|
||||
.then(([nextUp, continueWatching]) => [
|
||||
...(continueWatching || []),
|
||||
...(nextUp?.filter((i) => !continueWatching?.find((c) => c.SeriesId === i.SeriesId)) || [])
|
||||
])
|
||||
.then((items) =>
|
||||
Promise.all(
|
||||
items?.map(async (item) => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { TitleId } from '$lib/types';
|
||||
import type { PageData } from './$types';
|
||||
import SeriesPage from './SeriesPage.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let tmdbId: number;
|
||||
$: tmdbId = Number(data.tmdbId);
|
||||
let titleId: TitleId;
|
||||
$: titleId = { provider: 'tmdb', id: data.tmdbId, type: 'series' };
|
||||
</script>
|
||||
|
||||
{#key tmdbId}
|
||||
<SeriesPage {tmdbId} />
|
||||
{#key titleId}
|
||||
<SeriesPage {titleId} />
|
||||
{/key}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
getTmdbIdFromTvdbId,
|
||||
getTmdbSeries,
|
||||
getTmdbSeriesRecommendations,
|
||||
getTmdbSeriesSeasons,
|
||||
@@ -27,99 +28,153 @@
|
||||
} from '$lib/stores/data.store';
|
||||
import { modalStack } from '$lib/stores/modal.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
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 type { ComponentProps } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let titleId: TitleId;
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
|
||||
|
||||
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
|
||||
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
|
||||
getTmdbSeriesSeasons(tmdbId, s?.number_of_seasons || 0)
|
||||
);
|
||||
let data = loadInitialPageData();
|
||||
|
||||
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
|
||||
const sonarrSeriesStore = createSonarrSeriesStore(tmdbSeriesPromise.then((s) => s?.name || ''));
|
||||
const jellyfinItemStore = createJellyfinItemStore(data.then((d) => d.tmdbId));
|
||||
const sonarrSeriesStore = createSonarrSeriesStore(data.then((d) => d.tmdbSeries?.name || ''));
|
||||
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
|
||||
|
||||
let sonarrSeries: undefined | SonarrSeries = undefined;
|
||||
let jellyfinItem = $jellyfinItemStore.item;
|
||||
|
||||
sonarrSeriesStore.subscribe((s) => (sonarrSeries = s.item));
|
||||
|
||||
let seasonSelectVisible = false;
|
||||
let visibleSeasonNumber: number | undefined = undefined;
|
||||
let visibleEpisodeIndex: number | undefined = undefined;
|
||||
|
||||
function openRequestModal() {
|
||||
if (!sonarrSeries?.id || !sonarrSeries?.statistics?.seasonCount) return;
|
||||
async function loadInitialPageData() {
|
||||
const tmdbId = await (titleId.provider === 'tvdb'
|
||||
? getTmdbIdFromTvdbId(titleId.id)
|
||||
: Promise.resolve(titleId.id));
|
||||
|
||||
modalStack.create(SeriesRequestModal, {
|
||||
sonarrId: sonarrSeries?.id || 0,
|
||||
seasons: sonarrSeries?.statistics?.seasonCount || 0,
|
||||
heading: sonarrSeries?.title || 'Series'
|
||||
});
|
||||
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
|
||||
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
|
||||
getTmdbSeriesSeasons(s?.id || 0, s?.number_of_seasons || 0)
|
||||
);
|
||||
|
||||
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
|
||||
|
||||
const tmdbRecommendationPropsPromise = getTmdbSeriesRecommendations(tmdbId).then((r) =>
|
||||
Promise.all(r.map(fetchCardTmdbProps))
|
||||
);
|
||||
const tmdbSimilarPropsPromise = getTmdbSeriesSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
|
||||
const castPropsPromise: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
|
||||
Promise.all(
|
||||
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
|
||||
const tmdbEpisodePropsPromise: Promise<ComponentProps<EpisodeCard>[][]> =
|
||||
tmdbSeasonsPromise.then((seasons) =>
|
||||
seasons.map(
|
||||
(season) =>
|
||||
season?.episodes?.map((episode) => ({
|
||||
title: episode?.name || '',
|
||||
subtitle: `Episode ${episode?.episode_number}`,
|
||||
backdropUrl: TMDB_BACKDROP_SMALL + episode?.still_path || '',
|
||||
airDate:
|
||||
episode.air_date && new Date(episode.air_date) > new Date()
|
||||
? new Date(episode.air_date)
|
||||
: undefined
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbId,
|
||||
tmdbSeries: await tmdbSeriesPromise,
|
||||
tmdbSeasons: await tmdbSeasonsPromise,
|
||||
tmdbUrl,
|
||||
tmdbRecommendationProps: await tmdbRecommendationPropsPromise,
|
||||
tmdbSimilarProps: await tmdbSimilarPropsPromise,
|
||||
castProps: await castPropsPromise,
|
||||
tmdbEpisodeProps: await tmdbEpisodePropsPromise
|
||||
};
|
||||
}
|
||||
|
||||
let episodeProps: ComponentProps<EpisodeCard>[][] = [];
|
||||
let jellyfinEpisodeData: {
|
||||
[key: string]: {
|
||||
jellyfinId: string | undefined;
|
||||
progress: number;
|
||||
watched: boolean;
|
||||
};
|
||||
} = {};
|
||||
let episodeComponents: HTMLDivElement[] = [];
|
||||
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
|
||||
|
||||
const tmdbRecommendationProps = getTmdbSeriesRecommendations(tmdbId).then((r) =>
|
||||
Promise.all(r.map(fetchCardTmdbProps))
|
||||
);
|
||||
const tmdbSimilarProps = getTmdbSeriesSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
|
||||
Promise.all(
|
||||
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
// Refresh jellyfin episode data
|
||||
jellyfinItemStore.subscribe(async (value) => {
|
||||
const item = value.item;
|
||||
if (!item?.Id) return;
|
||||
const episodes = await getJellyfinEpisodes(item.Id);
|
||||
|
||||
jellyfinItemStore.promise.then(async (jellyfinItem) => {
|
||||
const jellyfinEpisodes = jellyfinItem?.Id ? await getJellyfinEpisodes(jellyfinItem?.Id) : [];
|
||||
const tmdbSeasons = await tmdbSeasonsPromise;
|
||||
episodes?.forEach((episode) => {
|
||||
const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`;
|
||||
|
||||
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 && episode?.UserData?.Played === false) {
|
||||
nextJellyfinEpisode = episode;
|
||||
}
|
||||
|
||||
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;
|
||||
jellyfinEpisodeData[key] = {
|
||||
jellyfinId: episode?.Id,
|
||||
progress: episode?.UserData?.PlayedPercentage || 0,
|
||||
watched: episode?.UserData?.Played || false
|
||||
};
|
||||
});
|
||||
|
||||
if (!nextJellyfinEpisode) nextJellyfinEpisode = jellyfinEpisodes?.[0];
|
||||
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 || '');
|
||||
}
|
||||
@@ -129,13 +184,27 @@
|
||||
}
|
||||
|
||||
let addToSonarrLoading = false;
|
||||
function addToSonarr() {
|
||||
async function addToSonarr() {
|
||||
const tmdbId = await data.then((d) => d.tmdbId);
|
||||
addToSonarrLoading = true;
|
||||
addSeriesToSonarr(tmdbId)
|
||||
.then(refreshSonarr)
|
||||
.finally(() => (addToSonarrLoading = false));
|
||||
}
|
||||
|
||||
async function openRequestModal() {
|
||||
const sonarrSeries = get(sonarrSeriesStore).item;
|
||||
|
||||
if (!sonarrSeries?.id || !sonarrSeries?.statistics?.seasonCount) return;
|
||||
|
||||
modalStack.create(SeriesRequestModal, {
|
||||
sonarrId: sonarrSeries?.id || 0,
|
||||
seasons: sonarrSeries?.statistics?.seasonCount || 0,
|
||||
heading: sonarrSeries?.title || 'Series'
|
||||
});
|
||||
}
|
||||
|
||||
// Focus next episode on load
|
||||
let didFocusNextEpisode = false;
|
||||
$: {
|
||||
if (episodeComponents && !didFocusNextEpisode) {
|
||||
@@ -162,24 +231,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await tmdbSeriesPromise then series}
|
||||
{#await data then { tmdbSeries, tmdbId, ...data }}
|
||||
<TitlePageLayout
|
||||
{tmdbId}
|
||||
type="series"
|
||||
{isModal}
|
||||
{handleCloseModal}
|
||||
backdropUriCandidates={series?.images?.backdrops?.map((b) => b.file_path || '') || []}
|
||||
posterPath={series?.poster_path || ''}
|
||||
title={series?.name || ''}
|
||||
tagline={series?.tagline || series?.name || ''}
|
||||
overview={series?.overview || ''}
|
||||
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">
|
||||
{new Date(series?.first_air_date || Date.now()).getFullYear()}
|
||||
{new Date(tmdbSeries?.first_air_date || Date.now()).getFullYear()}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title-info-2">{series?.status}</svelte:fragment>
|
||||
<svelte:fragment slot="title-info-2">{tmdbSeries?.status}</svelte:fragment>
|
||||
<svelte:fragment slot="title-info-3">
|
||||
<a href={tmdbUrl} target="_blank">{series?.vote_average?.toFixed(1)} TMDB</a>
|
||||
<a href={data.tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="title-right">
|
||||
@@ -189,7 +258,13 @@
|
||||
{#if $jellyfinItemStore.loading || $sonarrSeriesStore.loading}
|
||||
<div class="placeholder h-10 w-48 rounded-xl" />
|
||||
{:else}
|
||||
<OpenInButton title={series?.name} {jellyfinItem} {sonarrSeries} type="series" {tmdbId} />
|
||||
<OpenInButton
|
||||
title={tmdbSeries?.name}
|
||||
jellyfinItem={$jellyfinItemStore.item}
|
||||
sonarrSeries={$sonarrSeriesStore.item}
|
||||
type="series"
|
||||
{tmdbId}
|
||||
/>
|
||||
{#if !!nextJellyfinEpisode}
|
||||
<Button type="primary" on:click={playNextEpisode}>
|
||||
<span>
|
||||
@@ -197,11 +272,11 @@
|
||||
</span>
|
||||
<ChevronRight size={20} />
|
||||
</Button>
|
||||
{:else if !sonarrSeries && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
|
||||
{:else if !$sonarrSeriesStore.item && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
|
||||
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
|
||||
<span>Add to Sonarr</span><Plus size={20} />
|
||||
</Button>
|
||||
{:else if sonarrSeries}
|
||||
{:else if $sonarrSeriesStore.item}
|
||||
<Button type="primary" on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Series</span><Plus size={20} />
|
||||
</Button>
|
||||
@@ -218,8 +293,8 @@
|
||||
})}
|
||||
>
|
||||
<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)}
|
||||
{#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)}
|
||||
<button
|
||||
class={classNames(
|
||||
@@ -228,14 +303,14 @@
|
||||
'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,
|
||||
tmdbSeries?.number_of_seasons !== 1,
|
||||
'text-zinc-500 cursor-default': tmdbSeries?.number_of_seasons === 1,
|
||||
hidden:
|
||||
!seasonSelectVisible && visibleSeasonNumber !== (season?.season_number || 1)
|
||||
}
|
||||
)}
|
||||
on:click={() => {
|
||||
if (series?.number_of_seasons === 1) return;
|
||||
if (tmdbSeries?.number_of_seasons === 1) return;
|
||||
|
||||
if (seasonSelectVisible) {
|
||||
visibleSeasonNumber = season?.season_number || 1;
|
||||
@@ -247,16 +322,27 @@
|
||||
>
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
class={(seasonSelectVisible || series?.number_of_seasons === 1) && 'hidden'}
|
||||
class={(seasonSelectVisible || tmdbSeries?.number_of_seasons === 1) && 'hidden'}
|
||||
/>
|
||||
Season {season?.season_number}
|
||||
</button>
|
||||
{/each}
|
||||
</UiCarousel>
|
||||
{#key visibleSeasonNumber}
|
||||
{#each episodeProps[visibleSeasonNumber || 1] || [] as props, i}
|
||||
{#each data.tmdbEpisodeProps[(visibleSeasonNumber || 1) - 1] || [] as props, i}
|
||||
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
|
||||
<div bind:this={episodeComponents[i]}>
|
||||
<EpisodeCard {...props} on:click={() => (visibleEpisodeIndex = i)} />
|
||||
<EpisodeCard
|
||||
{...props}
|
||||
{...jellyfinData
|
||||
? {
|
||||
watched: jellyfinData.watched,
|
||||
progress: jellyfinData.progress,
|
||||
jellyfinId: jellyfinData.jellyfinId
|
||||
}
|
||||
: {}}
|
||||
on:click={() => (visibleEpisodeIndex = i)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<CarouselPlaceholderItems />
|
||||
@@ -268,13 +354,13 @@
|
||||
<svelte:fragment slot="info-components">
|
||||
<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>
|
||||
<h2 class="font-medium">{tmdbSeries?.created_by?.map((c) => c.name).join(', ')}</h2>
|
||||
</div>
|
||||
{#if series?.first_air_date}
|
||||
{#if tmdbSeries?.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', {
|
||||
{new Date(tmdbSeries?.first_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -282,22 +368,22 @@
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if series?.next_episode_to_air}
|
||||
{#if tmdbSeries?.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', {
|
||||
{new Date(tmdbSeries.next_episode_to_air?.air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{:else if series?.last_air_date}
|
||||
{:else if tmdbSeries?.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', {
|
||||
{new Date(tmdbSeries.last_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -307,21 +393,22 @@
|
||||
{/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>
|
||||
<h2 class="font-medium">{tmdbSeries?.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>
|
||||
<h2 class="font-medium">{tmdbSeries?.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.english_name || '')).join(', ')}
|
||||
{tmdbSeries?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
|
||||
</h2>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="servarr-components">
|
||||
{@const sonarrSeries = $sonarrSeriesStore.item}
|
||||
{#if sonarrSeries}
|
||||
{#if sonarrSeries?.statistics?.episodeFileCount}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
@@ -371,7 +458,7 @@
|
||||
|
||||
<div slot="cast-crew-carousel-title" class="font-medium text-lg">Cast & Crew</div>
|
||||
<svelte:fragment slot="cast-crew-carousel">
|
||||
{#await castProps}
|
||||
{#await data.castProps}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
@@ -382,7 +469,7 @@
|
||||
|
||||
<div slot="recommendations-carousel-title" class="font-medium text-lg">Recommendations</div>
|
||||
<svelte:fragment slot="recommendations-carousel">
|
||||
{#await tmdbRecommendationProps}
|
||||
{#await data.tmdbRecommendationProps}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
@@ -393,7 +480,7 @@
|
||||
|
||||
<div slot="similar-carousel-title" class="font-medium text-lg">Similar Series</div>
|
||||
<svelte:fragment slot="similar-carousel">
|
||||
{#await tmdbSimilarProps}
|
||||
{#await data.tmdbSimilarProps}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
|
||||
Reference in New Issue
Block a user