mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 00:35:12 +02:00
feat: Combined home and discovery pages, small improvements
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user