feat: Combined home and discovery pages, small improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
src/lib/components/PageDots.svelte
Normal file
41
src/lib/components/PageDots.svelte
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
96
src/lib/components/TitleShowcase/TitleShowcaseVisuals.svelte
Normal file
96
src/lib/components/TitleShowcase/TitleShowcaseVisuals.svelte
Normal 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>
|
||||
165
src/lib/components/TitleShowcase/TitleShowcasesContainer.svelte
Normal file
165
src/lib/components/TitleShowcase/TitleShowcasesContainer.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user