feat: Combined home and discovery pages, small improvements

This commit is contained in:
Aleksi Lassila
2023-09-17 00:29:37 +03:00
parent 98a1003695
commit fed8eacae6
14 changed files with 762 additions and 323 deletions

View File

@@ -24,7 +24,7 @@
</head>
<body
data-sveltekit-preload-data="hover"
class="bg-stone-950 min-h-screen text-white touch-manipulation"
class="bg-stone-950 min-h-screen text-white touch-manipulation relative -z-10"
>
<div style="display: contents">%sveltekit.body%</div>
</body>

View File

@@ -1,9 +1,10 @@
import { browser } from '$app/environment';
import { TMDB_API_KEY } from '$lib/constants';
import { TMDB_API_KEY, TMDB_BACKDROP_SMALL } from '$lib/constants';
import { settings } from '$lib/stores/settings.store';
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
import type { operations, paths } from './tmdb.generated';
import type { TitleType } from '$lib/types';
const CACHE_ONE_DAY = 'max-age=86400';
const CACHE_FOUR_DAYS = 'max-age=345600';
@@ -293,6 +294,36 @@ export const getTmdbItemBackdrop = (item: {
item?.images?.backdrops?.[0]
)?.file_path;
export const getPosterProps = async (
item: {
name?: string;
title?: string;
id?: number;
vote_average?: number;
number_of_seasons?: number;
first_air_date?: string;
poster_path?: string;
},
type: TitleType | undefined = undefined
) => {
const backdropUri = item.poster_path;
const t =
type ||
(item?.number_of_seasons === undefined && item?.first_air_date === undefined
? 'movie'
: 'series');
return {
tmdbId: item.id || 0,
title: item.title || item.name || '',
// subtitle: item.subtitle || '',
rating: item.vote_average || undefined,
size: 'md',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
type: t,
orientation: 'portrait'
} as const;
};
export const TMDB_MOVIE_GENRES = [
{
id: 28,

View File

@@ -9,17 +9,21 @@
let carousel: HTMLDivElement | undefined;
let scrollX = 0;
export let scrollClass = '';
</script>
<div class={classNames('flex flex-col gap-4', $$restProps.class)}>
<div class={'flex justify-between items-center gap-4'}>
<div class={classNames('flex flex-col gap-4 group/carousel', $$restProps.class)}>
<div class={'flex justify-between items-center gap-4 ' + scrollClass}>
<slot name="title">
<div class="font-semibold text-xl">{heading}</div>
</slot>
<div
class={classNames('flex gap-2', {
hidden: (carousel?.scrollWidth || 0) === (carousel?.clientWidth || 0)
})}
class={classNames(
'flex gap-2 sm:opacity-0 transition-opacity sm:group-hover/carousel:opacity-100',
{
hidden: (carousel?.scrollWidth || 0) === (carousel?.clientWidth || 0)
}
)}
>
<IconButton
on:click={() => {
@@ -40,7 +44,10 @@
<div class="relative">
<div
class="flex overflow-x-scroll items-center overflow-y-hidden gap-4 relative scrollbar-hide p-1"
class={classNames(
'flex overflow-x-scroll items-center overflow-y-visible gap-4 relative scrollbar-hide p-1',
scrollClass
)}
bind:this={carousel}
tabindex="-1"
on:scroll={() => (scrollX = carousel?.scrollLeft || scrollX)}

View File

@@ -39,7 +39,10 @@
setJellyfinItemUnwatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000));
}
function handlePlay() {
function handlePlay(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!jellyfinId) return;
playerState.streamJellyfinId(jellyfinId);
@@ -67,7 +70,7 @@
'aspect-video bg-center bg-cover rounded-lg overflow-hidden transition-opacity shadow-lg selectable flex-shrink-0 placeholder-image relative',
'flex flex-col px-2 lg:px-3 py-2 gap-2 text-left',
{
'h-40': size === 'md',
'h-44': size === 'md',
'h-full': size === 'dynamic',
group: !!jellyfinId,
'cursor-default': !jellyfinId
@@ -79,10 +82,10 @@
>
<div
class={classNames(
'absolute inset-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0 bg-darken',
'absolute inset-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0 bg-gradient-to-t',
{
// 'bg-darken': !jellyfinId || watched,
// 'bg-gradient-to-t from-darken': !!jellyfinId
'bg-darken': !jellyfinId || watched,
'bg-gradient-to-t from-darken': !!jellyfinId
}
)}
/>

View File

@@ -62,9 +62,9 @@
<a href="/" class={$page && getLinkStyle('/')}>
{$_('navbar.home')}
</a>
<a href="/discover" class={$page && getLinkStyle('/discover')}>
<!-- <a href="/discover" class={$page && getLinkStyle('/discover')}>
{$_('navbar.discover')}
</a>
</a> -->
<a href="/library" class={$page && getLinkStyle('/library')}>
{$_('navbar.library')}
</a>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import classNames from 'classnames';
import { DotFilled } from 'radix-icons-svelte';
export let index: number;
export let length: number;
export let onJump: (index: number) => void;
export let onPrevious: () => void = () => {};
export let onNext: () => void = () => {};
</script>
<div class="flex gap-1">
{#each Array.from({ length }) as _, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- <div
on:click={() => onJump(i)}
class={classNames(
'py-2 flex-1 w-6 transition-transform hover:scale-y-150 hover:opacity-50 cursor-pointer',
{
'opacity-50': i === index,
'opacity-20': i !== index
}
)}
>
<div class={classNames('h-[3px] bg-zinc-200 rounded-full', {})} />
</div> -->
<div on:click={() => onJump(i)}>
<DotFilled
class={classNames(
'transition-transform hover:scale-150 hover:opacity-50 cursor-pointer text-zinc-200',
{
'opacity-50': i === index,
'opacity-20': i !== index
}
)}
size={20}
/>
</div>
{/each}
</div>

View File

@@ -20,6 +20,7 @@
export let rating: number | undefined = undefined;
export let progress = 0;
export let shadow = false;
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';
export let orientation: 'portrait' | 'landscape' = 'landscape';
</script>
@@ -37,7 +38,7 @@
}
}}
class={classNames(
'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left',
'relative flex rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left',
{
'aspect-video': orientation === 'landscape',
'aspect-[2/3]': orientation === 'portrait',
@@ -47,7 +48,8 @@
'h-44': size === 'md' && orientation === 'landscape',
'w-60': size === 'lg' && orientation === 'portrait',
'h-60': size === 'lg' && orientation === 'landscape',
'w-full': size === 'dynamic'
'w-full': size === 'dynamic',
'shadow-lg': shadow
}
)}
>

View File

@@ -1,254 +0,0 @@
<script lang="ts">
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
import classNames from 'classnames';
import { ChevronLeft, ChevronRight, DotFilled } from 'radix-icons-svelte';
import Button from '../Button.svelte';
import IconButton from '../IconButton.svelte';
import { formatMinutesToTime } from '$lib/utils';
import YoutubePlayer from '../YoutubePlayer.svelte';
import { settings } from '$lib/stores/settings.store';
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
import type { TitleType } from '$lib/types';
import { openTitleModal } from '../../stores/modal.store';
import { _ } from 'svelte-i18n';
const TRAILER_TIMEOUT = 3000;
const TRAILER_LOAD_TIME = 1000;
const ANIMATION_DURATION = $settings.animationDuration;
export let tmdbId: number;
export let type: TitleType;
export let title: string;
export let genres: string[];
export let runtime: number;
export let releaseDate: Date;
export let tmdbRating: number;
export let trailerId: string | undefined = undefined;
export let director: string | undefined = undefined;
export let backdropUri: string;
export let posterUri: string;
export let showcaseIndex: number;
export let showcaseLength: number;
export let onPrevious: () => void;
export let onNext: () => void;
let trailerMounted = false;
let trailerVisible = false;
let focusTrailer = false;
let UIVisible = true;
$: UIVisible = !(focusTrailer && trailerVisible);
let tmdbUrl = `https://www.themoviedb.org/${type}/${tmdbId}`;
let youtubeUrl = `https://www.youtube.com/watch?v=${trailerId}`;
let timeout: NodeJS.Timeout;
$: {
tmdbId;
trailerMounted = false;
trailerVisible = false;
UIVisible = true;
if ($settings.autoplayTrailers) {
timeout = setTimeout(() => {
trailerMounted = true; // Mount the trailer
timeout = setTimeout(() => {
trailerVisible = true;
}, TRAILER_LOAD_TIME);
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
}
}
onMount(() => {
return () => clearTimeout(timeout);
});
</script>
<div class="h-[80vh] sm:h-screen relative pt-24 flex">
<div
class={classNames(
'relative z-[1] px-4 lg:px-16 2xl:px-32 py-4 lg:py-8 2xl:py-16 flex-1 sm:grid grid-cols-6 grid-rows-3',
'flex flex-col justify-end gap-8'
)}
>
{#if UIVisible}
<div class="flex flex-col col-span-3 gap-6 max-w-screen-md">
<div class="flex flex-col gap-1">
<div
class="flex items-center gap-1 uppercase text-sm text-zinc-300 font-semibold tracking-wider"
in:fly|global={{ y: -5, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fly|global={{ y: 5, duration: ANIMATION_DURATION }}
>
<p class="flex-shrink-0">{releaseDate.getFullYear()}</p>
<DotFilled />
<p class="flex-shrink-0">{formatMinutesToTime(runtime)}</p>
<DotFilled />
<p class="flex-shrink-0"><a href={tmdbUrl}>{tmdbRating.toFixed(1)} TMDB</a></p>
</div>
<h1
class={classNames('font-medium tracking-wider text-stone-200', {
'text-5xl sm:text-6xl 2xl:text-7xl': title.length < 15,
'text-4xl sm:text-5xl 2xl:text-6xl': title.length >= 15
})}
in:fly|global={{ y: -10, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fly|global={{ y: 10, duration: ANIMATION_DURATION }}
>
{title}
</h1>
</div>
<div
class="flex items-center gap-4"
in:fly|global={{ y: -5, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fly|global={{ y: 5, duration: ANIMATION_DURATION }}
>
{#each genres.slice(0, 3) as genre}
<span
class="backdrop-blur-lg rounded-full bg-zinc-400 bg-opacity-20 p-1.5 px-4 font-medium text-sm flex-grow-0"
>
{genre}
</span>
{/each}
</div>
</div>
{/if}
<div
class="sm:flex-1 flex flex-col gap-6 justify-end col-span-2 col-start-1 row-start-3"
in:fade|global={{ delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fade|global={{ duration: ANIMATION_DURATION }}
>
<div class="flex gap-4 items-center">
<Button
size="lg"
type="primary"
on:click={() => openTitleModal({ type, id: tmdbId, provider: 'tmdb' })}
>
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
</Button>
{#if trailerId}
<Button
size="lg"
type="secondary"
href={youtubeUrl}
target="_blank"
on:mouseover={() => (focusTrailer = true)}
on:mouseleave={() => (focusTrailer = false)}
>
<span>{$_('titleShowcase.watchTrailer')}</span><ChevronRight size={20} />
</Button>
{/if}
</div>
</div>
<div class="hidden lg:flex items-end justify-end col-start-4 row-start-3 col-span-3">
<div class="flex gap-6 items-center">
<div>
<p class="text-zinc-400 text-sm font-medium">
{$_('titleShowcase.releaseDate')}
</p>
<h2 class="font-semibold">
<!-- We need to format dates -->
{releaseDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</h2>
</div>
{#if director}
<div>
<p class="text-zinc-400 text-sm font-medium">
{$_('titleShowcase.directedBy')}
</p>
<h2 class="font-semibold">{director}</h2>
</div>
{/if}
<div
style={"background-image: url('" + TMDB_POSTER_SMALL + posterUri + "');"}
class="w-20 aspect-[2/3] rounded-lg bg-center bg-cover flex-shrink-0 shadow-lg"
/>
</div>
</div>
{#if UIVisible}
<div
class="hidden lg:flex absolute inset-x-4 lg:inset-x-16 2xl:inset-x-32 bottom-4 lg:bottom-8 2xl:bottom-16 opacity-70 gap-3 justify-end lg:justify-center"
in:fade={{ delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
>
{#each Array.from({ length: showcaseLength }, (_, i) => i) as i}
{#if i === showcaseIndex}
<DotFilled size={15} class="opacity-100" />
{:else}
<DotFilled size={15} class="opacity-20" />
{/if}
{/each}
</div>
{/if}
</div>
{#if !trailerVisible}
<div
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropUri + "');"}
class={classNames('absolute inset-0 bg-cover bg-center')}
in:fade|global={{ delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fade|global={{ duration: ANIMATION_DURATION }}
/>
{/if}
{#if trailerId && $settings.autoplayTrailers && trailerMounted}
<div
class={classNames('absolute inset-0 transition-opacity', {
'opacity-100': trailerVisible,
'opacity-0': !trailerVisible
})}
out:fade|global={{ duration: ANIMATION_DURATION }}
>
<YoutubePlayer videoId={trailerId} />
</div>
{/if}
{#if UIVisible}
<div
class="absolute inset-0 bg-gradient-to-t from-stone-950 via-darken via-[20%] to-darken"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
/>
{:else if !UIVisible}
<div
class="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-darken"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
/>
{/if}
{#if UIVisible}
<div
class="absolute inset-y-0 left-0 px-3 flex justify-start w-[10vw]"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
>
<div class="peer relaitve z-[1] flex justify-start">
<IconButton on:click={onPrevious}>
<ChevronLeft size={20} />
</IconButton>
</div>
<div
class="opacity-0 peer-hover:opacity-20 transition-opacity bg-gradient-to-r from-darken absolute inset-0"
/>
</div>
<div
class="absolute inset-y-0 right-0 px-3 flex justify-end w-[10vw]"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
>
<div class="peer relaitve z-[1] flex justify-end">
<IconButton on:click={onNext}>
<ChevronRight size={20} />
</IconButton>
</div>
<div
class="opacity-0 peer-hover:opacity-20 transition-opacity bg-gradient-to-l from-darken absolute inset-0"
/>
</div>
{/if}
</div>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { TMDB_IMAGES_ORIGINAL } from '$lib/constants';
import { settings } from '$lib/stores/settings.store';
import classNames from 'classnames';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import YoutubePlayer from '../YoutubePlayer.svelte';
const TRAILER_TIMEOUT = 3000;
const TRAILER_LOAD_TIME = 1000;
const ANIMATION_DURATION = $settings.animationDuration;
export let tmdbId: number;
export let trailerId: string | undefined = undefined;
export let backdropUri: string;
let scrollY: number;
let trailerMounted = false;
let trailerVisible = false;
let hoverTrailer = false;
export let UIVisible = true;
$: UIVisible = !(hoverTrailer && trailerVisible);
let trailerShowTimeout: NodeJS.Timeout | undefined = undefined;
$: {
tmdbId;
trailerMounted = false;
trailerVisible = false;
UIVisible = true;
showTrailerDelayed();
}
function handleWindowScroll() {
if (scrollY > 100) hideTrailer();
else if (!trailerShowTimeout) showTrailerDelayed();
}
function hideTrailer() {
clearTimeout(trailerShowTimeout);
trailerShowTimeout = undefined;
trailerVisible = false;
trailerMounted = false;
}
function showTrailerDelayed() {
if ($settings.autoplayTrailers === false) return;
trailerShowTimeout = setTimeout(() => {
trailerMounted = true; // Mount the trailer
trailerShowTimeout = setTimeout(() => {
trailerVisible = true;
trailerShowTimeout = undefined;
}, TRAILER_LOAD_TIME);
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
}
onMount(() => {
return () => clearTimeout(trailerShowTimeout);
});
</script>
<svelte:window bind:scrollY on:scroll={handleWindowScroll} />
{#if !trailerVisible}
{#key tmdbId}
<div
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropUri + "');"}
class={classNames('fixed inset-0 bg-cover bg-center z-[-1]')}
in:fade={{ duration: ANIMATION_DURATION * 2 }}
out:fade={{ duration: ANIMATION_DURATION * 2, delay: ANIMATION_DURATION }}
/>
{/key}
{/if}
{#if trailerId && $settings.autoplayTrailers && trailerMounted}
<div
class={classNames('absolute inset-0 transition-opacity z-[-1]', {
'opacity-100': trailerVisible,
'opacity-0': !trailerVisible
})}
out:fade={{ duration: ANIMATION_DURATION }}
>
<YoutubePlayer videoId={trailerId} />
</div>
{/if}
{#if UIVisible}
<div
class="absolute inset-0 bg-gradient-to-t from-stone-950 from-10% via-darken via-60% to-darken z-[-1]"
/>
{:else if !UIVisible}
<div class="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-darken z-[-1]" />
{/if}

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { TMDB_POSTER_SMALL } from '$lib/constants';
import { formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { DotFilled } from 'radix-icons-svelte';
import { fly } from 'svelte/transition';
import Poster from '../Poster/Poster.svelte';
import type { TitleType } from '$lib/types';
import { openTitleModal } from '$lib/stores/modal.store';
import { settings } from '$lib/stores/settings.store';
const ANIMATION_DURATION = $settings.animationDuration;
export let tmdbId: number;
export let type: TitleType;
export let title: string;
export let genres: string[];
export let runtime: number;
export let releaseDate: Date;
export let tmdbRating: number;
export let posterUri: string;
export let hideUI = false;
$: tmdbUrl = `https://www.themoviedb.org/${type}/${tmdbId}`;
function handleOpenTitle() {
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
}
</script>
<div
class={classNames(
'flex gap-6 items-end transition-opacity row-[1/2] col-[1/3] md:row-[1/3] md:col-[1/2]',
{
'opacity-0': hideUI
}
)}
>
<div
class="hidden sm:block"
in:fly={{ y: -5, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fly={{ y: 5, duration: ANIMATION_DURATION }}
>
<Poster
orientation="portrait"
backdropUrl={TMDB_POSTER_SMALL + posterUri}
openInModal
{tmdbId}
/>
</div>
<div class="flex flex-col col-span-3 gap-4 max-w-screen-md">
<div class="flex flex-col gap-1">
<div
class="flex items-center gap-1 uppercase text-sm text-zinc-300 font-semibold tracking-wider"
in:fly={{ y: -5, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fly={{ y: 5, duration: ANIMATION_DURATION }}
>
<p class="flex-shrink-0">{releaseDate.getFullYear()}</p>
<DotFilled />
<p class="flex-shrink-0">{formatMinutesToTime(runtime)}</p>
<DotFilled />
<p class="flex-shrink-0"><a href={tmdbUrl}>{tmdbRating.toFixed(1)} TMDB</a></p>
</div>
<button
on:click={handleOpenTitle}
class={classNames(
'text-left font-medium tracking-wider text-stone-200 hover:text-amber-200',
{
'text-5xl sm:text-6xl 2xl:text-7xl': title.length < 15,
'text-4xl sm:text-5xl 2xl:text-6xl': title.length >= 15
}
)}
in:fly={{ y: -10, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fly={{ y: 10, duration: ANIMATION_DURATION }}
>
{title}
</button>
</div>
<div
class="flex items-center gap-4"
in:fly={{ y: -5, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fly={{ y: 5, duration: ANIMATION_DURATION }}
>
{#each genres.slice(0, 3) as genre}
<span
class="backdrop-blur-lg rounded-full bg-zinc-400 bg-opacity-20 p-1.5 px-4 font-medium text-sm flex-grow-0"
>
{genre}
</span>
{/each}
</div>
</div>
</div>

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import {
getJellyfinBackdrop,
getJellyfinContinueWatching,
getJellyfinNextUp
} from '$lib/apis/jellyfin/jellyfinApi';
import { getTmdbMovie, getTmdbPopularMovies } from '$lib/apis/tmdb/tmdbApi';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import classNames from 'classnames';
import Carousel from '../Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '../Carousel/CarouselPlaceholderItems.svelte';
import EpisodeCard from '../EpisodeCard/EpisodeCard.svelte';
import TitleShowcase from './TitleShowcaseBackground.svelte';
import TitleShowcaseVisuals from './TitleShowcaseVisuals.svelte';
import PageDots from '../PageDots.svelte';
import IconButton from '../IconButton.svelte';
import { ChevronRight } from 'radix-icons-svelte';
let hideUI = false;
let continueWatchingEmpty = false;
let nextUpP = getJellyfinNextUp();
let continueWatchingP = getJellyfinContinueWatching();
let nextUpProps = Promise.all([nextUpP, continueWatchingP])
.then(([nextUp, continueWatching]) => [
...(continueWatching || []),
...(nextUp?.filter((i) => !continueWatching?.find((c) => c.SeriesId === i.SeriesId)) || [])
])
.then((items) =>
Promise.all(
items?.map(async (item) => {
const parentSeries = await jellyfinItemsStore.promise.then((items) =>
items.find((i) => i.Id === item.SeriesId)
);
return {
tmdbId: Number(item.ProviderIds?.Tmdb) || Number(parentSeries?.ProviderIds?.Tmdb) || 0,
jellyfinId: item.Id,
backdropUrl: getJellyfinBackdrop(item),
title: item.Name || '',
progress: item.UserData?.PlayedPercentage || undefined,
// runtime: item.RunTimeTicks ? item.RunTimeTicks / 10_000_000 / 60 : 0,
...(item.Type === 'Movie'
? {
type: 'movie',
subtitle: item.Genres?.join(', ') || ''
}
: {
type: 'series',
subtitle:
(item?.IndexNumber && 'Episode ' + item.IndexNumber) ||
item.Genres?.join(', ') ||
''
})
} as const;
})
)
);
nextUpProps.then((props) => {
if (props.length === 0) {
continueWatchingEmpty = true;
}
});
const tmdbPopularMoviesPromise = getTmdbPopularMovies()
.then((movies) => Promise.all(movies.map((movie) => getTmdbMovie(movie.id || 0))))
.then((movies) => movies.filter((m) => !!m).slice(0, 10));
let showcaseIndex = 0;
async function onNext() {
showcaseIndex = (showcaseIndex + 1) % (await tmdbPopularMoviesPromise).length;
}
async function onPrevious() {
showcaseIndex =
(showcaseIndex - 1 + (await tmdbPopularMoviesPromise).length) %
(await tmdbPopularMoviesPromise).length;
}
async function onJump(index: number) {
showcaseIndex = index;
console.log(showcaseIndex);
}
// Cycle movies every 5 seconds
// onMount(() => {
// const interval = setInterval(() => {
// onNext();
// }, 2000);
// return () => clearInterval(interval);
// });
const PADDING = 'px-4 lg:px-8 xl:px-16';
</script>
<div class="h-screen flex flex-col relative pb-6 gap-6 xl:gap-8 overflow-hidden">
<div
class={classNames(
'flex-1 grid grid-cols-[1fr_max-content] grid-rows-[1fr_max-content] items-end gap-6',
PADDING
)}
>
{#await tmdbPopularMoviesPromise then movies}
{@const movie = movies[showcaseIndex]}
{#key movie?.id}
<TitleShowcaseVisuals
tmdbId={movie?.id || 0}
type="movie"
title={movie?.title || ''}
genres={movie?.genres?.map((g) => g.name || '') || []}
runtime={movie?.runtime || 0}
releaseDate={new Date(movie?.release_date || Date.now())}
tmdbRating={movie?.vote_average || 0}
posterUri={movie?.poster_path || ''}
{hideUI}
/>
{/key}
<div
class="md:relative self-stretch flex justify-center items-end row-start-2 row-span-1 col-start-1 col-span-2 md:row-start-1 md:row-span-2 md:col-start-2 md:col-span-2"
>
<PageDots index={showcaseIndex} length={movies.length} {onJump} {onPrevious} {onNext} />
{#if !hideUI}
<div class="absolute top-1/2 right-0 z-10">
<IconButton on:click={onNext}>
<ChevronRight size={38} />
</IconButton>
</div>
{/if}
</div>
<TitleShowcase
tmdbId={movie?.id || 0}
trailerId={movie?.videos?.results?.find((v) => v.site === 'YouTube' && v.type === 'Trailer')
?.key}
backdropUri={movie?.backdrop_path || ''}
/>
{/await}
</div>
<div
class={classNames('z-[1] transition-opacity', {
'opacity-0': hideUI
})}
>
{#if !continueWatchingEmpty}
<Carousel gradientFromColor="from-transparent" scrollClass={PADDING}>
<div slot="title" class="text-lg font-semibold text-zinc-300">Continue Watching</div>
{#await nextUpProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<EpisodeCard
on:click={() => (window.location.href = `/${prop.type}/${prop.tmdbId}`)}
{...prop}
/>
{/each}
{/await}
</Carousel>
{/if}
</div>
</div>

View File

@@ -2,15 +2,34 @@
import {
getJellyfinBackdrop,
getJellyfinContinueWatching,
getJellyfinNextUp
getJellyfinNextUp,
type JellyfinItem
} from '$lib/apis/jellyfin/jellyfinApi';
import { getTmdbMovie, getTmdbPopularMovies } from '$lib/apis/tmdb/tmdbApi';
import {
TmdbApiOpen,
getPosterProps,
getTmdbMovie,
getTmdbPopularMovies
} from '$lib/apis/tmdb/tmdbApi';
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 TitleShowcase from '$lib/components/TitleShowcase/TitleShowcase.svelte';
import GenreCard from '$lib/components/GenreCard.svelte';
import NetworkCard from '$lib/components/NetworkCard.svelte';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import TitleShowcase from '$lib/components/TitleShowcase/TitleShowcaseBackground.svelte';
import { genres, networks } from '$lib/discover';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { log } from '$lib/utils';
import { settings } from '$lib/stores/settings.store';
import type { TitleType } from '$lib/types';
import { formatDateToYearMonthDay } from '$lib/utils';
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
import { _ } from 'svelte-i18n';
import LazyImg from '$lib/components/LazyImg.svelte';
import { TMDB_IMAGES_ORIGINAL } from '$lib/constants';
import TitleShowcases from '$lib/components/TitleShowcase/TitleShowcasesContainer.svelte';
let continueWatchingVisible = true;
@@ -74,9 +93,139 @@
(showcaseIndex - 1 + (await tmdbPopularMoviesPromise).length) %
(await tmdbPopularMoviesPromise).length;
}
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
jellyfinItemsStore.subscribe((data) => {
if (data.loading) return;
resolve(data.data || []);
});
});
const fetchCardProps = async (
items: {
name?: string;
title?: string;
id?: number;
vote_average?: number;
number_of_seasons?: number;
first_air_date?: string;
poster_path?: string;
}[],
type: TitleType | undefined = undefined
): Promise<ComponentProps<Poster>[]> => {
const filtered = $settings.discover.excludeLibraryItems
? items.filter(
async (item) =>
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
)
: items;
return Promise.all(filtered.map(async (item) => getPosterProps(item, type))).then((props) =>
props.filter((p) => p.backdropUrl)
);
};
const trendingItemsPromise = TmdbApiOpen.get('/3/trending/all/{time_window}', {
params: {
path: {
time_window: 'day'
},
query: {
language: $settings.language
}
}
}).then((res) => res.data?.results || []);
const fetchTrendingProps = () => trendingItemsPromise.then(fetchCardProps);
const fetchTrendingActorProps = () =>
TmdbApiOpen.get('/3/trending/person/{time_window}', {
params: {
path: {
time_window: 'week'
}
}
})
.then((res) => res.data?.results || [])
.then((actors) =>
actors
.filter((a) => a.profile_path)
.map((actor) => ({
tmdbId: actor.id || 0,
backdropUri: actor.profile_path || '',
name: actor.name || '',
subtitle: actor.known_for_department || ''
}))
);
const fetchUpcomingMovies = () =>
TmdbApiOpen.get('/3/discover/movie', {
params: {
query: {
'primary_release_date.gte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
language: $settings.language,
region: $settings.discover.region,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
}
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
const fetchUpcomingSeries = () =>
TmdbApiOpen.get('/3/discover/tv', {
params: {
query: {
'first_air_date.gte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
language: $settings.language,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
}
}
})
.then((res) => res.data?.results || [])
.then((i) => fetchCardProps(i, 'series'));
const fetchDigitalReleases = () =>
TmdbApiOpen.get('/3/discover/movie', {
params: {
query: {
with_release_type: 4,
sort_by: 'popularity.desc',
'release_date.lte': formatDateToYearMonthDay(new Date()),
language: $settings.language,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
// region: $settings.discover.region
}
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
const fetchNowStreaming = () =>
TmdbApiOpen.get('/3/discover/tv', {
params: {
query: {
'air_date.gte': formatDateToYearMonthDay(new Date()),
'first_air_date.lte': formatDateToYearMonthDay(new Date()),
sort_by: 'popularity.desc',
language: $settings.language,
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
}
}
})
.then((res) => res.data?.results || [])
.then((i) => fetchCardProps(i, 'series'));
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
</script>
<div class="h-[80vh] sm:h-screen">
<TitleShowcases />
<!-- <div class="h-[80vh] sm:h-screen">
{#await tmdbPopularMoviesPromise then movies}
{@const movie = movies[showcaseIndex]}
{#key movie?.id}
@@ -100,20 +249,130 @@
/>
{/key}
{/await}
</div>
</div> -->
<div class="py-8" hidden={!continueWatchingVisible}>
<Carousel gradientFromColor="from-stone-950" class="px-4 lg:px-16 2xl:px-32">
<div slot="title" class="text-xl font-medium text-zinc-200">Continue Watching</div>
{#await nextUpProps}
<!-- {#await tmdbPopularMoviesPromise then movies}
{#if movies.length}
<div class="absolute inset-0 blur-3xl brightness-[0.1] z-[-1] overflow-hidden">
<LazyImg
src={TMDB_IMAGES_ORIGINAL + movies?.[showcaseIndex]?.backdrop_path}
class="h-full scale-125"
/>
</div>
{/if}
{/await} -->
<!-- Blur backdground -->
<!-- {#await tmdbPopularMoviesPromise then movies}
{#if movies.length}
<div class="fixed inset-0 z-[-1] overflow-hidden">
<LazyImg
src={'https://images.unsplash.com/photo-1493839523149-2864fca44919?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2069&q=80'}
class="h-full hue-rotate-[100deg]"
/>
<div class="absolute z-[1] inset-0 backdrop-blur-3xl bg-stone-950/100" />
</div>
{/if}
{/await} -->
<div
class="flex flex-col gap-12 py-6 bg-stone-950"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<!-- {#if continueWatchingVisible}
<Carousel gradientFromColor="from-stone-950" class="mx-2 sm:mx-8 2xl:mx-0">
<div slot="title" class="text-lg font-semibold text-zinc-300">Continue Watching</div>
{#await nextUpProps}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<EpisodeCard
on:click={() => (window.location.href = `/${prop.type}/${prop.tmdbId}`)}
{...prop}
/>
{/each}
{/await}
</Carousel>
{/if} -->
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.popularPeople')}
</div>
{#await fetchTrendingActorProps()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop}
<EpisodeCard
on:click={() => (window.location.href = `/${prop.type}/${prop.tmdbId}`)}
{...prop}
/>
{#each props as prop (prop.tmdbId)}
<PeopleCard {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.upcomingMovies')}
</div>
{#await fetchUpcomingMovies()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.upcomingSeries')}
</div>
{#await fetchUpcomingSeries()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.genres')}
</div>
{#each Object.values(genres) as genre (genre.tmdbGenreId)}
<GenreCard {genre} />
{/each}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.newDigitalReleases')}
</div>
{#await fetchDigitalReleases()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.streamingNow')}
</div>
{#await fetchNowStreaming()}
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Poster {...prop} />
{/each}
{/await}
</Carousel>
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
<div slot="title" class="text-lg font-semibold text-zinc-300">
{$_('discover.TVNetworks')}
</div>
{#each Object.values(networks) as network (network.tmdbNetworkId)}
<NetworkCard {network} />
{/each}
</Carousel>
</div>

View File

@@ -1,14 +1,12 @@
<script lang="ts">
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { TmdbApiOpen, getTmdbItemBackdrop, getTmdbMovieBackdrop } from '$lib/apis/tmdb/tmdbApi';
import Card from '$lib/components/Card/Card.svelte';
import { TmdbApiOpen, getPosterProps } from '$lib/apis/tmdb/tmdbApi';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import GenreCard from '$lib/components/GenreCard.svelte';
import NetworkCard from '$lib/components/NetworkCard.svelte';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { genres, networks } from '$lib/discover';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
@@ -33,6 +31,7 @@
vote_average?: number;
number_of_seasons?: number;
first_air_date?: string;
poster_path?: string;
}[],
type: TitleType | undefined = undefined
): Promise<ComponentProps<Poster>[]> => {
@@ -43,40 +42,23 @@
)
: items;
return Promise.all(
filtered.map(async (item) => {
const backdropUri = await getTmdbMovieBackdrop(item.id || 0);
const t =
type ||
(item?.number_of_seasons === undefined && item?.first_air_date === undefined
? 'movie'
: 'series');
return {
tmdbId: item.id || 0,
title: item.title || item.name || '',
// subtitle: item.subtitle || '',
rating: item.vote_average || undefined,
size: 'md',
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
type: t
} as const;
})
).then((props) => props.filter((p) => p.backdropUrl));
return Promise.all(filtered.map(async (item) => getPosterProps(item, type))).then((props) =>
props.filter((p) => p.backdropUrl)
);
};
const fetchTrendingProps = () =>
TmdbApiOpen.get('/3/trending/all/{time_window}', {
params: {
path: {
time_window: 'day'
},
query: {
language: $settings.language
}
const trendingItemsPromise = TmdbApiOpen.get('/3/trending/all/{time_window}', {
params: {
path: {
time_window: 'day'
},
query: {
language: $settings.language
}
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
}
}).then((res) => res.data?.results || []);
const fetchTrendingProps = () => trendingItemsPromise.then(fetchCardProps);
const fetchTrendingActorProps = () =>
TmdbApiOpen.get('/3/trending/person/{time_window}', {
@@ -163,8 +145,16 @@
}
</script>
<!-- {#await trendingItemsPromise then items}
{#if items.length}
<div class="absolute inset-0 blur-3xl brightness-[0.2] z-[-1] scale-125">
<LazyImg src={TMDB_IMAGES_ORIGINAL + items?.[4].backdrop_path} class="h-full" />
</div>
{/if}
{/await} -->
<div
class="pt-24 bg-stone-950 pb-8"
class="pt-24 pb-8"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration

View File

@@ -31,7 +31,7 @@
(b.DateCreated || b.DateLastMediaAdded || '')
? 1
: -1
)?.[0]
)?.[3]
);
let downloadProps: ComponentProps<Poster>[] = [];
@@ -93,7 +93,12 @@
/>
{/await}
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-80% to-darken" />
<div class="max-w-screen-2xl mx-auto relative z-[1] px-2 md:px-8 pt-56 pb-12">
<div class="max-w-screen-2xl mx-auto relative z-[1] px-2 md:px-8 pt-56 pb-12 overflow-hidden">
<h1
class="absolute font-bold uppercase text-amber-200 opacity-10 bottom-12 right-8 text-9xl hidden lg:block z-[-1]"
>
Library
</h1>
<div class="flex gap-4 items-end">
{#await showcasePromise}
<div class="w-32 aspect-[2/3] placeholder rounded-lg shadow-lg" />