Merge branch 'dev' into person-screen

This commit is contained in:
Aleksi Lassila
2023-09-18 01:50:41 +03:00
17 changed files with 833 additions and 353 deletions

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

@@ -22,7 +22,7 @@
export let jellyfinId: string | undefined = undefined;
export let size: 'md' | 'dynamic' = 'md';
export let size: 'md' | 'sm' | 'dynamic' = 'md';
function handleSetWatched() {
if (!jellyfinId) return;
@@ -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,8 @@
'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-36 lg:h-44': size === 'sm',
'h-full': size === 'dynamic',
group: !!jellyfinId,
'cursor-default': !jellyfinId
@@ -79,16 +83,17 @@
>
<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
}
)}
/>
<div
class={classNames(
'flex-1 flex flex-col justify-between relative group-hover:opacity-0 group-focus-visible:opacity-0 transition-all',
'text-xs lg:text-sm font-medium text-zinc-300',
{
'opacity-8': !jellyfinId || watched
}
@@ -98,7 +103,7 @@
<div>
<slot name="left-top">
{#if airDate && airDate > new Date()}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
<p>
{airDate.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
@@ -107,18 +112,18 @@
})}
</p>
{:else if episodeNumber}
<p class="text-xs lg:text-sm font-medium text-zinc-300">{episodeNumber}</p>
<p>{episodeNumber}</p>
{/if}
</slot>
</div>
<div>
<slot name="right-top">
{#if runtime && !progress}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
<p>
{runtime.toFixed(0)} min
</p>
{:else if runtime && progress}
<p class="text-xs lg:text-sm font-medium text-zinc-300">
<p>
{(runtime - (runtime / 100) * progress).toFixed(0)} min left
</p>
{/if}
@@ -132,7 +137,7 @@
<h2 class="text-zinc-300 text-sm font-medium">{subtitle}</h2>
{/if}
{#if title}
<h1 class="font-medium text-left line-clamp-2">
<h1 class="text-zinc-200 text-base font-medium text-left line-clamp-2">
{title}
</h1>
{/if}

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import { settings } from '$lib/stores/settings.store';
import { addMessages, init, locale } from 'svelte-i18n';
import de from '../../lang/de.json';
import en from '../../lang/en.json';
import es from '../../lang/es.json';
import fr from '../../lang/fr.json';
import it from '../../lang/it.json';
addMessages('de', de);
addMessages('en', en);
addMessages('es', es);
addMessages('fr', fr);

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

@@ -6,7 +6,7 @@
<a
href={`/discover/network/${network.name}`}
class="block border rounded-xl h-52 w-96 bg-stone-900 border-stone-700 cursor-pointer p-12 text-zinc-300 hover:text-amber-200 transition-all relative group selectable flex-shrink-0 max-w-[100%]"
class="block border rounded-xl h-52 w-96 bg-zinc-900 border-stone-700 cursor-pointer p-12 text-zinc-300 hover:text-amber-200 transition-all relative group selectable flex-shrink-0 max-w-[100%]"
>
<div
class="absolute inset-10 bg-zinc-300 sm:hover:bg-amber-200 sm:group-hover:scale-105 transition-all"

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,166 @@
<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}
size="sm"
/>
{/each}
{/await}
</Carousel>
{/if}
</div>
</div>