mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 00:35:12 +02:00
Merge branch 'dev' into person-screen
This commit is contained in:
@@ -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';
|
||||
@@ -302,6 +303,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 getTmdbPerson = async (person_id: number) =>
|
||||
TmdbApiOpen.get('/3/person/{person_id}', {
|
||||
params: {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
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>
|
||||
166
src/lib/components/TitleShowcase/TitleShowcasesContainer.svelte
Normal file
166
src/lib/components/TitleShowcase/TitleShowcasesContainer.svelte
Normal 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>
|
||||
102
src/lib/lang/de.json
Normal file
102
src/lib/lang/de.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"appName": "Reiverr",
|
||||
"setupRequiredTitle": "Willkommen zu",
|
||||
"setupRequiredDescription": "Es scheint das einige Umgebungsvariables zur fehlerfreien Ausführung fehlen. Bitte gebe die folgenden Umgebungsvariablen an:",
|
||||
"navbar": {
|
||||
"home": "Start",
|
||||
"discover": "Entdecken",
|
||||
"library": "Bibliothek",
|
||||
"sources": "Quellen",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"search": {
|
||||
"placeHolder": "Suche nach Filmen und Serien",
|
||||
"noRecentSearches": "Kein Suchverlauf",
|
||||
"noResults": "Keine Suchergebnisse"
|
||||
},
|
||||
"discover": {
|
||||
"trending": "Aufsteigend",
|
||||
"popularPeople": "Beliebte Personen",
|
||||
"upcomingMovies": "Bald verfügbare Filme",
|
||||
"upcomingSeries": "Bald verfügbare Serien",
|
||||
"genres": "Genres",
|
||||
"newDigitalReleases": "Neue Digitale Veröffentlichungen",
|
||||
"streamingNow": "Aktuell im Stream",
|
||||
"TVNetworks": "Anbieter"
|
||||
},
|
||||
"library": {
|
||||
"missingConfiguration": "Konfiguriere Radarr, Sonarr und Jellyfin zum Verwalten und Abspielen der Bibliothek",
|
||||
"available": "Verfügbar",
|
||||
"watched": "Geschaut",
|
||||
"unavailable": "Nicht verfügbar",
|
||||
"sort": {
|
||||
"byTitle": "Nach Titel"
|
||||
},
|
||||
"content": {
|
||||
"movie": "Film",
|
||||
"show": "Serie",
|
||||
"requestContent": "Anfrage",
|
||||
"directedBy": "Regie von",
|
||||
"releaseDate": "Veröffentlichung",
|
||||
"budget": "Budget",
|
||||
"status": "Status",
|
||||
"runtime": "Spielzeit",
|
||||
"castAndCrew": "Cast & Crew",
|
||||
"recommendations": "Empfehlungen",
|
||||
"similarTitles": "Ähnliche Titel"
|
||||
}
|
||||
},
|
||||
"sources": {},
|
||||
"titleShowcase": {
|
||||
"details": "Details",
|
||||
"watchTrailer": "Trailer abspielen",
|
||||
"releaseDate": "Veröffentlichung",
|
||||
"directedBy": "Regie von"
|
||||
},
|
||||
"settings": {
|
||||
"navbar": {
|
||||
"settings": "Konfiguration",
|
||||
"general": "Allgemein",
|
||||
"integrations": "Integrationen"
|
||||
},
|
||||
"general": {
|
||||
"userInterface": {
|
||||
"userInterface": "Benutzeroberfläche",
|
||||
"language": "Sprache",
|
||||
"autoplayTrailers": "Trailer automatisch abspielen",
|
||||
"animationDuration": "Animationsdauer"
|
||||
},
|
||||
"discovery": {
|
||||
"discovery": "Entdecken",
|
||||
"none": "Keine",
|
||||
"region": "Region",
|
||||
"excludeLibraryItemsFromDiscovery": "Schließe Bibliothekeinträge von 'Entdecken' aus",
|
||||
"includedLanguages": "Enthaltene Sprachen",
|
||||
"includedLanguagesDescription": "Filter Resultate nach gesprochener Sprache. Trage ISO 639-1 Sprachcodes kommasepariert ein. Freilassen zum deaktivieren."
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"integrations": "Integrationen",
|
||||
"integrationsNote": "Anmerkungen: Basis URLs müssen vom Browser aus erreichbar sein. Interne Docker Adressen funktionieren nicht. API Schlüssel <span class='font-medium underline'>sind Sichtbar</span> in den Browseranfragen.",
|
||||
"baseUrl": "Basis URL",
|
||||
"apiKey": "API Schlüssel",
|
||||
"testConnection": "Verbindung testen",
|
||||
"status": {
|
||||
"connected": "Verbunden",
|
||||
"disconnected": "Getrennt"
|
||||
},
|
||||
"options": {
|
||||
"options": "Optionen",
|
||||
"rootFolder": "Hauptordner",
|
||||
"qualityProfile": "Qualitätsprofil",
|
||||
"languageProfile": "Sprachprofil",
|
||||
"jellyfinUser": "Jellyfin Benutzer"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"resetToDefaults": "Auf Standard zurücksetzen ",
|
||||
"changelog": "Änderungen"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user