Reworked showcase front page

This commit is contained in:
Aleksi Lassila
2023-08-06 04:04:41 +03:00
parent 3b3f607475
commit 5eae3e30b8
9 changed files with 281 additions and 1047 deletions

View File

@@ -17,7 +17,7 @@
{
'bg-white text-zinc-900 font-extrabold backdrop-blur-lg': type === 'primary',
'hover:bg-amber-400 hover:border-amber-400': type === 'primary' && !disabled,
'text-zinc-200 bg-zinc-500 bg-opacity-30 backdrop-blur-lg': type === 'secondary',
'text-zinc-200 bg-zinc-400 bg-opacity-20 backdrop-blur-lg': type === 'secondary',
'focus-visible:bg-zinc-200 focus-visible:text-zinc-800 hover:bg-zinc-200 hover:text-zinc-800':
(type === 'secondary' || type === 'tertiary') && !disabled,
'rounded-full': type === 'tertiary',

View File

@@ -1,351 +0,0 @@
<script lang="ts">
import { getJellyfinEpisodesBySeries } from '$lib/apis/jellyfin/jellyfinApi';
import {
addMovieToRadarr,
cancelDownloadRadarrMovie,
deleteRadarrMovie,
removeFromRadarr
} from '$lib/apis/radarr/radarrApi';
import {
addSeriesToSonarr,
cancelDownloadSonarrEpisode,
deleteSonarrEpisode,
getSonarrEpisodes,
removeFromSonarr,
type SonarrSeries
} from '$lib/apis/sonarr/sonarrApi';
import Button from '$lib/components/Button.svelte';
import { library } from '$lib/stores/library.store';
import { ChevronDown, Plus, Trash, Update } from 'radix-icons-svelte';
import { onMount, type ComponentProps } from 'svelte';
import IconButton from '../IconButton.svelte';
import { createModalProps } from '../Modal/Modal';
import RequestModal from '../RequestModal/RequestModal.svelte';
import SeriesRequestModal from '../RequestModal/SeriesRequestModal.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import LibraryDetailsFile from './LibraryDetailsFile.svelte';
import SeasonSelectModal from '../RequestModal/SeasonSelectModal.svelte';
export let tmdbId: number;
export let type: 'movie' | 'tv';
let servarrId: number | undefined = undefined;
let isAdded = false;
let isRequestModalVisible = false;
const requestModalProps = createModalProps(() => (isRequestModalVisible = false));
let series: SonarrSeries | undefined = undefined;
let downloadProps: ComponentProps<LibraryDetailsFile>[] = [];
let movieFileProps: ComponentProps<LibraryDetailsFile>[] = [];
let seasonFileProps: (ComponentProps<LibraryDetailsFile>[] | undefined)[] = [];
library.subscribe(async (libraryPromise) => {
const libraryData = await libraryPromise;
const item = libraryData.items[tmdbId];
if (!item) return;
const sonarrEpisodesPromise = item.sonarrSeries?.id
? getSonarrEpisodes(item.sonarrSeries?.id)
: undefined;
const jellyfinEpisodesPromise =
item.jellyfinItem?.Id && item.sonarrSeries?.id
? getJellyfinEpisodesBySeries(item.jellyfinItem?.Id)
: undefined;
const sonarrEpisodes = await sonarrEpisodesPromise;
const jellyfinEpisodes = await jellyfinEpisodesPromise;
const radarrDownloads = item.radarrDownloads;
const sonarrDownloads = item.sonarrDownloads;
const radarrMovie = item.radarrMovie;
const sonarrSeries = item.sonarrSeries;
const jellyfinItem = item.jellyfinItem;
downloadProps = [];
movieFileProps = [];
seasonFileProps = [];
sonarrEpisodes?.sort((a, b) => {
if (!a.episode.absoluteEpisodeNumber || !b.episode.absoluteEpisodeNumber) return -1;
return a.episode.absoluteEpisodeNumber - b.episode.absoluteEpisodeNumber;
});
if (radarrDownloads) {
downloadProps = radarrDownloads.map((download) => ({
status: 'downloading',
resolution: download.quality?.quality?.resolution || 0,
sizeOnDisk: download.size || 0,
qualityType: download.quality?.quality?.source || 'Unknown',
videoCodec: 'Unknown',
downloadEta: new Date(download.estimatedCompletionTime || Date.now()),
cancelDownload: () => {
if (download.id && !cancelDownloadFetching) cancelDownload(download.id);
}
}));
} else if (sonarrDownloads) {
downloadProps = sonarrDownloads.map((download) => ({
status: 'downloading',
resolution: download.quality?.quality?.resolution || 0,
sizeOnDisk: download.size || 0,
qualityType: download.quality?.quality?.source || 'Unknown',
videoCodec: 'Unknown',
downloadEta: new Date(download.estimatedCompletionTime || Date.now()),
cancelDownload: () => {
if (download.id && !cancelDownloadFetching) cancelDownload(download.id);
}
}));
}
if (radarrMovie?.movieFile) {
movieFileProps = [
{
status: 'ready',
resolution: radarrMovie.movieFile.quality?.quality?.resolution || 0,
sizeOnDisk: radarrMovie.movieFile.size || 0,
qualityType: radarrMovie.movieFile.quality?.quality?.source || 'Unknown',
videoCodec: radarrMovie.movieFile.mediaInfo?.videoCodec || 'Unknown',
downloadEta: undefined,
deleteFile: () => {
if (radarrMovie?.movieFile?.id && !deleteMovieFetching)
deleteFile(radarrMovie.movieFile.id);
},
jellyfinStreamDisabled: !jellyfinItem,
openJellyfinStream: () => {
if (jellyfinItem?.Id) playerState.streamJellyfinId(jellyfinItem.Id);
}
}
];
} else if (sonarrEpisodes) {
seasonFileProps = [];
for (const episode of sonarrEpisodes) {
const jellyfinEpisode = jellyfinEpisodes?.find(
(e) =>
e?.IndexNumber === episode.episode.episodeNumber &&
e?.ParentIndexNumber === episode.episode.seasonNumber
);
if (episode.episodeFile) {
seasonFileProps[(episode.episode.seasonNumber || 1) - 1] = [
...(seasonFileProps[(episode.episode.seasonNumber || 1) - 1] || []),
{
episodeNumber: episode.episode.episodeNumber,
status: 'ready',
resolution: episode.episodeFile.quality?.quality?.resolution || 0,
sizeOnDisk: episode.episodeFile.size || 0,
qualityType: episode.episodeFile.quality?.quality?.source || 'Unknown',
videoCodec: episode.episodeFile.mediaInfo?.videoCodec || 'Unknown',
downloadEta: undefined,
deleteFile: () => {
if (episode?.episodeFile?.id && !deleteMovieFetching)
deleteFile(episode.episodeFile.id);
},
jellyfinStreamDisabled: !jellyfinEpisode,
openJellyfinStream: () => {
if (jellyfinEpisode?.Id) playerState.streamJellyfinId(jellyfinEpisode.Id);
}
}
];
}
}
}
isAdded = !!radarrMovie || !!sonarrSeries;
servarrId = radarrMovie?.id || sonarrSeries?.id;
series = sonarrSeries;
});
let addToServarrLoading = false;
function addToServarr() {
if (addToServarrLoading) return;
addToServarrLoading = true;
isRefetching = true;
if (type === 'movie')
addMovieToRadarr(tmdbId)
.then(() => refetch())
.finally(() => (addToServarrLoading = false));
else
addSeriesToSonarr(tmdbId)
.then(() => refetch())
.finally(() => (addToServarrLoading = false));
}
let cancelDownloadFetching = false;
function cancelDownload(downloadId: number) {
if (cancelDownloadFetching) return;
cancelDownloadFetching = true;
isRefetching = true;
if (type === 'movie')
cancelDownloadRadarrMovie(downloadId)
.then(() => refetch())
.finally(() => (cancelDownloadFetching = false));
else
cancelDownloadSonarrEpisode(downloadId)
.then(() => refetch())
.finally(() => (cancelDownloadFetching = false));
}
let deleteMovieFetching = false;
function deleteFile(servarrId: number) {
if (deleteMovieFetching) return;
deleteMovieFetching = true;
isRefetching = true;
if (type === 'movie')
deleteRadarrMovie(servarrId)
.then((res) => refetch())
.finally(() => (deleteMovieFetching = false));
else
deleteSonarrEpisode(servarrId)
.then((res) => refetch())
.finally(() => (deleteMovieFetching = false));
}
let removeFromServarrLoading = false;
function removeFromServarr(servarrId: number) {
if (removeFromServarrLoading) return;
removeFromServarrLoading = true;
isRefetching = true;
if (type === 'movie')
removeFromRadarr(servarrId)
.then(() => refetch())
.finally(() => (removeFromServarrLoading = false));
else
removeFromSonarr(servarrId)
.then(() => refetch())
.finally(() => (removeFromServarrLoading = false));
}
function openRequestModal() {
isRequestModalVisible = true;
}
let isRefetching = false;
const refetch = async () => {
if (isRefetching) return;
isRefetching = true;
library.refresh().finally(() => (isRefetching = false));
};
onMount(() => {
// let interval = setInterval(async () => refetch(), 10000);
// return () => clearInterval(interval);
});
const headerStyle = 'uppercase tracking-widest font-bold';
</script>
<div class="flex flex-col gap-8 p-8">
{#if !downloadProps.length && !seasonFileProps.length && !movieFileProps.length}
<div>
<h1 class="text-lg mb-1 font-medium tracking-wide">No sources found</h1>
<p class="text-zinc-300">
No local or remote sources found for this title. You can configure your sources on the <a
href="/sources"
class="text-amber-200 hover:text-amber-100">sources</a
> page.
</p>
</div>
{/if}
{#if isAdded}
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div class={headerStyle}>Local Library</div>
<IconButton on:click={openRequestModal}>
<Plus size={20} />
</IconButton>
<IconButton
disabled={removeFromServarrLoading && !!servarrId}
on:click={() => servarrId && removeFromServarr(servarrId)}
>
<Trash size={20} />
</IconButton>
<div class={isRefetching ? 'animate-spin' : ''}>
<IconButton disabled={isRefetching} on:click={refetch}>
<Update size={15} />
</IconButton>
</div>
</div>
{#if downloadProps.length}
<div
class="flex justify-between items-center mt-3 mb-1 cursor-pointer group border-b-zinc-500 border-b-2 py-3"
>
<div class="uppercase font-bold text-sm text-zinc-300 group-hover:text-zinc-200">
Downloading
</div>
<ChevronDown size={20} />
</div>
{/if}
{#each downloadProps as props}
<LibraryDetailsFile {...props} />
{/each}
{#if movieFileProps.length}
<div
class="flex justify-between items-center mt-3 mb-1 cursor-pointer group border-b-zinc-500 border-b-2 py-3"
>
<div class="uppercase font-bold text-sm text-zinc-300 group-hover:text-zinc-200">
Available
</div>
<ChevronDown size={20} />
</div>
{/if}
{#each movieFileProps as props}
<LibraryDetailsFile {...props} />
{/each}
{#each seasonFileProps as seasonProps, i}
{#if seasonProps?.length}
<div
class="flex justify-between items-center mt-3 mb-1 cursor-pointer group border-b-zinc-500 border-b-2 py-3"
>
<div class="uppercase font-bold text-sm text-zinc-300 group-hover:text-zinc-200">
Season {i + 1}
</div>
<ChevronDown size={20} />
</div>
{#each seasonProps as props}
<LibraryDetailsFile {...props} />
{/each}
{/if}
{/each}
{#if !downloadProps.length && !seasonFileProps.length && !movieFileProps.length}
<div class="text-zinc-400 text-sm font-light">Click + to add files</div>
{/if}
</div>
{/if}
</div>
<div class="flex gap-4 items-center bg-black p-8 py-4 empty:hidden">
{#if !isAdded || false}
{#if !isAdded}
<Button on:click={() => addToServarr()} disabled={addToServarrLoading}
>Add to {type === 'movie' ? 'Radarr' : 'Sonarr'}</Button
>
{/if}
<!--{#if data.hasLocalFiles}-->
<!-- <Button type="secondary">Manage Local Files</Button>-->
<!--{/if}-->
{/if}
</div>
{#if isRequestModalVisible}
{#if isAdded && servarrId && type === 'movie'}
<RequestModal
modalProps={requestModalProps}
radarrId={servarrId}
on:download={() => setTimeout(refetch, 5000)}
/>
{:else if isAdded && servarrId && type === 'tv' && series?.statistics?.seasonCount}
<SeriesRequestModal
modalProps={requestModalProps}
sonarrId={servarrId}
seasons={series?.statistics.seasonCount}
/>
<!-- <SeasonSelectModal modalProps={requestModalProps} sonarrId={servarrId} sonarrSeries={series} /> -->
{:else}
<div>NO CONTENT</div>
{console.log('NO CONTENT')}
{/if}
{/if}

View File

@@ -1,88 +0,0 @@
<script lang="ts">
import { formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
import { DotFilled } from 'radix-icons-svelte';
import Button from '../Button.svelte';
import UiCarousel from '../Carousel/UICarousel.svelte';
export let status: 'downloading' | 'importing' | 'stalled' | 'queued' | 'ready' = 'ready';
export let resolution: number;
export let sizeOnDisk: number;
export let qualityType: string;
export let videoCodec: string;
export let episodeNumber: number | undefined = undefined;
export let downloadEta: Date | undefined = undefined;
export let deleteButtonDisabled = false;
export let deleteFile: (() => void) | undefined = undefined;
export let jellyfinStreamDisabled = false;
export let openJellyfinStream: (() => void) | undefined = undefined;
export let cancelDownloadDisabled = false;
export let cancelDownload: (() => void) | undefined = undefined;
</script>
<div
class={classNames('border-l-2 p-1 pl-4 gap-4 flex justify-between items-center py-1', {
'border-purple-400': status === 'downloading',
'border-amber-400': status === 'importing',
'border-zinc-200': status === 'ready',
'border-red-400': status === 'stalled'
})}
>
<UiCarousel>
<div class="flex gap-1 items-center w-max">
{#if episodeNumber}
<div class="font-bold">
Episode {episodeNumber}
</div>
<DotFilled class="text-zinc-300" />
{/if}
<div class={episodeNumber ? '' : 'font-bold'}>{resolution}p</div>
<DotFilled class="text-zinc-300" />
<h2 class="text-zinc-200 text-sm">
{formatSize(sizeOnDisk)} on disk
</h2>
<DotFilled class="text-zinc-300" />
<h2 class="uppercase text-zinc-200 text-sm">
{qualityType}
</h2>
<DotFilled class="text-zinc-300" />
{#if downloadEta}
<h2 class="text-zinc-200 text-sm">
Completed in {formatMinutesToTime((downloadEta.getTime() - Date.now()) / 1000 / 60)}
</h2>
{:else if status === 'queued'}
<h2 class="text-zinc-200 text-sm">Download starting</h2>
{:else if status === 'stalled'}
<h2 class="text-orange-300 text-sm">Download Stalled</h2>
{:else}
<h2 class="uppercase text-zinc-200 text-sm">
{videoCodec}
</h2>
{/if}
</div></UiCarousel
>
<div class="flex gap-2">
{#if deleteFile}
<Button
size="sm"
type="secondary"
on:click={() => deleteFile?.()}
disabled={deleteButtonDisabled}>Delete File</Button
>
{:else if cancelDownload}
<Button
size="sm"
type="secondary"
disabled={status === 'importing' || cancelDownloadDisabled}
on:click={() => cancelDownload?.()}>Cancel Download</Button
>
{/if}
{#if status === 'ready' && openJellyfinStream}
<Button size="sm" on:click={() => openJellyfinStream?.()} disabled={jellyfinStreamDisabled}
>Stream</Button
>
{/if}
</div>
</div>

View File

@@ -1,398 +0,0 @@
<script lang="ts">
import type { CastMember, Video } from '$lib/apis/tmdb/tmdbApi';
import Button from '$lib/components/Button.svelte';
import { TMDB_IMAGES_ORIGINAL } from '$lib/constants';
import { library } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatMinutesToTime } from '$lib/utils';
import classNames from 'classnames';
import { ChevronDown, ChevronRight, Clock } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { fade, fly } from 'svelte/transition';
import EpisodeCard from '../EpisodeCard/EpisodeCard.svelte';
import HeightHider from '../HeightHider.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import LibraryDetails from './LibraryDetails.svelte';
import SeasonsDetails from './SeasonsDetails.svelte';
export let tmdbId: number;
export let type: 'movie' | 'tv';
export let title: string;
export let reason = 'Popular Now';
export let releaseDate: Date | undefined = undefined;
export let endDate: Date | undefined = undefined;
export let seasons: number = 0;
export let tagline: string;
export let overview: string;
export let backdropPath: string;
export let genres: string[];
export let runtime: number;
export let tmdbRating: number;
export let starring: CastMember[];
export let videos: Video[];
export let showDetails = false;
let autoplayTrailer = $settings.autoplayTrailers;
let jellyfinId: string | undefined | null = null;
let showTrailer = false;
let focusTrailer = false;
let trailerStartTime = 0;
let detailsVisible = showDetails;
let streamButtonDisabled = true;
let nextEpisodeCardProps: ComponentProps<EpisodeCard> | undefined;
let video: Video | undefined;
$: video = videos?.find((v) => v.site === 'YouTube' && v.type === 'Trailer');
let opacityStyle: string;
$: opacityStyle =
(focusTrailer ? 'opacity: 0;' : 'opacity: 100;') + 'transition: opacity 0.3s ease-in-out;';
// Transitions
const duration = 200;
library.subscribe(async (libraryPromise) => {
const libraryData = await libraryPromise;
jellyfinId = libraryData.items[tmdbId]?.jellyfinId;
streamButtonDisabled = !jellyfinId;
});
function openTrailer() {
if (!video) return;
window
?.open(
'https://www.youtube.com/watch?v=' +
video.key +
'&autoplay=1&t=' +
(trailerStartTime === 0 ? 0 : Math.floor((Date.now() - trailerStartTime) / 1000)),
'_blank'
)
?.focus();
}
let fadeIndex = -1;
const getFade = () => {
fadeIndex += 1;
return { duration: 200, delay: 500 + fadeIndex * 50 };
};
let timeout: NodeJS.Timeout;
$: {
fadeIndex = 0;
streamButtonDisabled = true;
if (tmdbId) {
showTrailer = false;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
if (autoplayTrailer) {
showTrailer = true;
trailerStartTime = Date.now();
}
}, 2500);
}
}
let localDetails: HTMLDivElement;
</script>
<div class="grid">
<div
class="min-h-max h-screen relative overflow-hidden"
out:fade={{ duration }}
in:fade={{ delay: duration, duration }}
>
{#key (video?.key || '') + tmdbId}
<div
class="absolute inset-0 bg-center bg-cover transition-[background-image] duration-500 delay-500"
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropPath + "');"}
transition:fade
/>
<div class="youtube-container absolute h-full scale-[150%] hidden sm:block" transition:fade>
{#if video?.key}
<iframe
class={classNames('transition-opacity', {
'opacity-100': showTrailer,
'opacity-0': !showTrailer
})}
src={'https://www.youtube.com/embed/' +
video.key +
'?autoplay=1&mute=1&loop=1&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
tabindex="-1"
/>
{/if}
</div>
{/key}
{#key tmdbId}
<div
class={classNames(
'bg-gradient-to-b from-darken via-20% via-transparent transition-opacity absolute inset-0 z-[1]',
{
'opacity-100': focusTrailer,
'opacity-0': !focusTrailer
}
)}
/>
<div
class={classNames(
'h-full w-full px-8 lg:px-16 pb-8 pt-32',
'grid grid-cols-[1fr_max-content] grid-rows-[1fr_min-content] gap-x-16 gap-y-8 relative z-[2]',
'transition-colors',
{
'bg-darken': !focusTrailer,
'bg-transparent': focusTrailer
}
)}
>
<div class="flex flex-col justify-self-start min-w-0 row-span-full">
<div class="relative" style={opacityStyle} in:fly={{ x: -20, duration, delay: 400 }}>
<h2 class="text-zinc-300 text-sm self-end uppercase">
{#if seasons}
{#if endDate}
<span class="font-medium">Ended</span>
<span class="font-bold">{endDate.getFullYear()}</span>
{:else if releaseDate}
<span class="font-medium">Since</span>
<span class="font-bold">{releaseDate.getFullYear()}</span>
{/if}
{:else if releaseDate}
<span class="font-bold uppercase tracking-wider"
>{releaseDate.toLocaleString('en', { month: 'long' })}</span
>
{releaseDate.getFullYear()}
{/if}
</h2>
<h2
class="tracking-wider font-display font-bold text-amber-300 absolute opacity-10 text-6xl sm:text-7xl lg:text-8xl -ml-6 mt-16"
>
<slot name="reason">{reason}</slot>
</h2>
<h1
class="uppercase text-7xl sm:text-8xl lg:text-9xl font-semibold font-display z-[1] relative"
>
{title}
</h1>
</div>
<div
class="mt-auto max-w-3xl flex flex-col gap-4"
style={opacityStyle}
in:fly={{ x: -20, duration, delay: 600 }}
>
<div class="text-lg font-semibold tracking-wider">{tagline}</div>
<div
class="tracking-wider text-sm text-zinc-200 font-light leading-6 pl-4 border-l-2 border-zinc-300"
>
{overview}
</div>
</div>
<div class="flex gap-6 mt-10" in:fly={{ x: -20, duration, delay: 600 }}>
<!-- <button
class={classNames(
'flex items-center gap-1 py-3 px-6 rounded-xl font-medium select-none cursor-pointer selectable transition-all backdrop-blur-lg',
'text-zinc-200 bg-stone-800 bg-opacity-30 focus-visible:bg-zinc-200 focus-visible:text-zinc-800 hover:bg-zinc-200 hover:text-zinc-800'
)}
>
<span>Details</span><ChevronRight size={20} />
</button>
<button
class={classNames(
'flex items-center gap-1 py-3 px-6 rounded-xl font-medium select-none cursor-pointer selectable transition-all backdrop-blur-lg',
'text-zinc-200 bg-stone-800 bg-opacity-30 focus-visible:bg-zinc-200 focus-visible:text-zinc-800 hover:bg-zinc-200 hover:text-zinc-800'
)}
>
<span>Watch Trailer</span><ChevronRight size={20} />
</button> -->
<!-- <button
class={classNames(
'flex items-center gap-1 py-2 px-6 rounded-full font-medium select-none cursor-pointer selectable transition-all',
'text-zinc-200 hover:text-zinc-900 border border-zinc-200 border-opacity-50'
)}
>
<span>Details</span><ChevronRight size={20} />
</button>
<button
class={classNames(
'flex items-center gap-1 py-2 px-6 rounded-full font-medium select-none cursor-pointer selectable transition-all',
'text-zinc-200 hover:text-zinc-900 border border-zinc-200 border-opacity-50'
)}
>
<span>Watch Trailer</span><ChevronRight size={20} />
</button> -->
<!-- <button
class={classNames(
'flex items-center gap-1 backdrop-blur-xl py-2.5 px-6 rounded-xl font-medium select-none cursor-pointer selectable transition-all',
'text-zinc-200 bg-stone-700 bg-opacity-50 hover:bg-amber-300 hover:text-zinc-900 hover:bg-opacity-70'
)}
>
<span>Details</span><ChevronRight size={20} />
</button>
<button
class={classNames(
'flex items-center gap-1 backdrop-blur-xl py-2.5 px-6 rounded-xl font-medium select-none cursor-pointer selectable transition-all',
'text-zinc-200 bg-stone-700 bg-opacity-50 hover:bg-amber-300 hover:text-zinc-900 hover:bg-opacity-70'
)}
>
<span>Watch Trailer</span><ChevronRight size={20} />
</button> -->
<!-- <div class="flex gap-1">
<div style={opacityStyle}>
<Button
disabled={streamButtonDisabled}
size="lg"
on:click={() => jellyfinId && playerState.streamJellyfinId(jellyfinId)}
>Stream</Button
>
</div>
<div
class="hidden items-center justify-center border-2 border-white w-10 cursor-pointer hover:bg-white hover:text-zinc-900 transition-colors"
>
<ChevronDown size={20} />
</div>
</div>
<div style={opacityStyle} class:hidden={showDetails}>
<Button
size="lg"
type="secondary"
on:click={() => {
detailsVisible = true;
localDetails?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}>Details</Button
>
</div>
<Button
size="lg"
type="secondary"
on:mouseover={() => (focusTrailer = autoplayTrailer)}
on:mouseleave={() => (focusTrailer = false)}
on:click={openTrailer}>Watch Trailer</Button
> -->
<!-- <div style={opacityStyle}>
<Button
disabled={streamButtonDisabled}
size="lg"
on:click={() => jellyfinId && playerState.streamJellyfinId(jellyfinId)}
>
<span>Stream</span><ChevronRight size={20} />
</Button>
</div> -->
<div style={opacityStyle} class:hidden={showDetails}>
<Button size="lg" href={`/${type}/${tmdbId}`}>
<span>Details</span>
<ChevronRight size={20} />
</Button>
</div>
<Button
size="lg"
type="secondary"
on:mouseover={() => (focusTrailer = autoplayTrailer)}
on:focus={() => (focusTrailer = autoplayTrailer)}
on:mouseleave={() => (focusTrailer = false)}
on:blur={() => (focusTrailer = false)}
on:click={openTrailer}
>
<span>Watch Trailer</span>
<ChevronRight size={20} />
</Button>
</div>
</div>
<div
class="flex flex-col gap-6 justify-between 2xl:w-96 xl:w-80 lg:w-64 w-52 row-span-full"
style={opacityStyle}
>
<div class="flex flex-col gap-6 self-end">
<h3 class="text-xs tracking-wide uppercase" in:fade={getFade()}>Details</h3>
<div class="flex flex-col gap-1 text-sm tracking-widest font-extralight">
<div in:fade={getFade()}>
{genres.map((g) => g.charAt(0).toUpperCase() + g.slice(1)).join(', ')}
</div>
{#if seasons}
<a href={`https://www.themoviedb.org/tv/${tmdbId}/seasons`} target="_blank"
>{seasons} Season{seasons > 1 ? 's' : ''}</a
>
{/if}
{#if runtime}
<div class="flex gap-1.5 items-center" in:fade={getFade()}>
<Clock size={14} />
<div>
{formatMinutesToTime(runtime)}
</div>
</div>
{/if}
<div in:fade={getFade()}>
Currently <b>Streaming</b>
</div>
<a
href={`https://www.themoviedb.org/${type}/${tmdbId}`}
target="_blank"
in:fade={getFade()}
>
<b>{tmdbRating.toFixed(1)}</b> TMDB
</a>
</div>
{#if starring?.length > 0}
<h3 class="text-xs tracking-wide uppercase" in:fade={getFade()}>Starring</h3>
<div class="flex flex-col gap-1 text-sm tracking-widest font-extralight">
{#each starring.slice(0, 5) as a}
<a
href={'https://www.themoviedb.org/person/' + a.id}
target="_blank"
in:fade={getFade()}>{a.name}</a
>
{/each}
<a
href={`https://www.themoviedb.org/${type}/${tmdbId}/cast`}
target="_blank"
in:fade={getFade()}>View all...</a
>
</div>
{/if}
</div>
<div class="w-full aspect-video">
{#if nextEpisodeCardProps}
<div in:fly={{ y: 10, duration: duration * 2 }}>
<EpisodeCard size="dynamic" {...nextEpisodeCardProps} />
</div>
{/if}
</div>
</div>
<slot name="page-controls" />
</div>
{/key}
</div>
</div>
<HeightHider duration={1000} visible={detailsVisible}>
{#if jellyfinId !== null && type === 'tv'}
<SeasonsDetails {tmdbId} totalSeasons={seasons} {jellyfinId} bind:nextEpisodeCardProps />
{/if}
{#key tmdbId}
<div bind:this={localDetails}>
<LibraryDetails {tmdbId} {type} />
</div>
{/key}
</HeightHider>
<style>
.youtube-container {
overflow: hidden;
width: 100%;
aspect-ratio: 16/9;
pointer-events: none;
}
.youtube-container iframe {
width: 300%;
height: 100%;
margin-left: -100%;
}
</style>

View File

@@ -1,184 +0,0 @@
<script lang="ts">
import { getJellyfinEpisodesBySeries } from '$lib/apis/jellyfin/jellyfinApi';
import { getTmdbSeriesSeasons } from '$lib/apis/tmdb/tmdbApi';
import classNames from 'classnames';
import { Check, StarFilled } from 'radix-icons-svelte';
import { onMount, type ComponentProps } from 'svelte';
import CardPlaceholder from '../Card/CardPlaceholder.svelte';
import Carousel from '../Carousel/Carousel.svelte';
import UiCarousel from '../Carousel/UICarousel.svelte';
import EpisodeCard from '../EpisodeCard/EpisodeCard.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
export let tmdbId: number;
export let totalSeasons: number;
export let jellyfinId: string | undefined = undefined;
export let nextEpisodeCardProps: ComponentProps<EpisodeCard> | undefined = undefined;
let visibleSeason = 1;
async function fetchSeriesData() {
const tmdbSeasonsPromise = getTmdbSeriesSeasons(tmdbId, totalSeasons);
const jellyfinEpisodesPromise = jellyfinId
? getJellyfinEpisodesBySeries(jellyfinId)
: undefined;
const tmdbSeasons = await tmdbSeasonsPromise;
const jellyfinEpisodes = await jellyfinEpisodesPromise;
jellyfinEpisodes?.sort((a, b) => (a.IndexNumber || 99) - (b.IndexNumber || 99));
const nextJellyfinEpisode = jellyfinEpisodes?.find((e) => e?.UserData?.Played === false);
const nextEpisode = {
jellyfinEpisode: nextJellyfinEpisode,
tmdbEpisode: nextJellyfinEpisode
? tmdbSeasons
.flatMap((s) => s?.episodes)
.find(
(e) =>
e?.episode_number === nextJellyfinEpisode.IndexNumber &&
e?.season_number === nextJellyfinEpisode.ParentIndexNumber
)
: undefined
};
visibleSeason = nextEpisode.tmdbEpisode?.season_number || visibleSeason;
const tmdbEpisode = nextEpisode.tmdbEpisode;
nextEpisodeCardProps = tmdbEpisode
? {
title: tmdbEpisode.name || '',
subtitle: 'Next Episode',
backdropPath: tmdbEpisode.still_path || '',
runtime: tmdbEpisode.runtime || 0,
progress: nextEpisode?.jellyfinEpisode?.UserData?.PlayedPercentage || 0,
episodeNumber: `S${tmdbEpisode.season_number}E${tmdbEpisode.episode_number}`,
handlePlay: nextEpisode?.jellyfinEpisode?.Id
? () => playerState.streamJellyfinId(nextEpisode?.jellyfinEpisode?.Id || '')
: undefined
}
: undefined;
return {
tmdbSeasons,
jellyfinEpisodes,
nextJellyfinEpisode,
nextEpisode,
nextEpisodeCardProps
};
}
const seriesPromise = fetchSeriesData();
onMount(() => {
seriesPromise.then(({ nextEpisode }) => {
if (nextEpisode) {
const episodeCard = document.getElementById(
'episode-card-' + nextEpisode?.tmdbEpisode?.episode_number
);
if (episodeCard) {
const parent = episodeCard.offsetParent;
if (parent) {
parent.scrollLeft =
episodeCard.offsetLeft - document.body.clientWidth / 2 + episodeCard.clientWidth / 2;
}
}
}
});
});
</script>
<div class="py-4">
{#await seriesPromise}
<Carousel>
<div slot="title" class="flex gap-4 my-1">
{#each [...Array(3).keys()] as season}
<div class={'rounded-full p-2 px-6 font-medium placeholder text-transparent'}>
Season 1
</div>
{/each}
</div>
{#each Array(10) as _, i (i)}
<div class="aspect-video h-40 lg:h-48">
<CardPlaceholder size="dynamic" />
</div>
{/each}
</Carousel>
{:then { tmdbSeasons, jellyfinEpisodes }}
<div class="flex flex-col gap-4">
<div>
<Carousel>
<UiCarousel slot="title" class="flex gap-4 my-1">
{#each tmdbSeasons as season}
<button
class={classNames('rounded-full p-2 px-6 font-medium whitespace-nowrap ', {
'text-amber-200 bg-darken shadow-lg':
visibleSeason === (season?.season_number || 1),
'text-zinc-300 hover:bg-lighten hover:text-amber-100':
visibleSeason !== (season?.season_number || 1)
})}
on:click={() => (visibleSeason = season?.season_number || 1)}
>Season {season?.season_number}</button
>
{/each}
</UiCarousel>
{#each tmdbSeasons as season}
{#if season?.season_number === visibleSeason}
{#each season?.episodes || [] as tmdbEpisode}
{@const upcoming =
new Date(tmdbEpisode.air_date || Date.now()) > new Date() ||
tmdbEpisode.runtime === null}
{@const jellyfinEpisode = jellyfinEpisodes?.find(
(e) =>
e.IndexNumber === tmdbEpisode.episode_number &&
e.ParentIndexNumber === season?.season_number
)}
<div
class="flex-shrink-0 h-40 lg:h-48"
id={'episode-card-' + tmdbEpisode.episode_number}
>
<EpisodeCard
backdropPath={tmdbEpisode.still_path || ''}
title={tmdbEpisode.name || ''}
subtitle={upcoming ? 'Upcoming' : 'Episode ' + tmdbEpisode.episode_number}
runtime={tmdbEpisode.runtime || 0}
size="dynamic"
progress={jellyfinEpisode?.UserData?.PlayedPercentage || 0}
handlePlay={jellyfinEpisode?.Id
? () => playerState.streamJellyfinId(jellyfinEpisode?.Id || '')
: undefined}
>
<div slot="left-info" class="flex gap-1 items-center">
{#if upcoming}
{@const date = new Date(tmdbEpisode.air_date || Date.now())}
{`${date.getDay()}. ${date.toLocaleDateString('en', { month: 'short' })}`}
{:else}
{tmdbEpisode.vote_average?.toFixed(1)}
<StarFilled size={14} />
{/if}
</div>
<div slot="right-info">
{#if jellyfinEpisode?.UserData?.Played}
<div class="flex gap-1 text-amber-200 items-center">
<Check size={20} /> Watched
</div>
{:else if jellyfinEpisode?.UserData?.PlayedPercentage}
{@const runtime = tmdbEpisode.runtime || 0}
{(
runtime -
runtime * (jellyfinEpisode?.UserData?.PlayedPercentage / 100)
).toFixed(0)} min left
{:else}
{tmdbEpisode.runtime} min
{/if}
</div>
</EpisodeCard>
</div>
{/each}
{/if}
{/each}
</Carousel>
</div>
</div>
{/await}
</div>

View File

@@ -18,7 +18,7 @@
style={"background-image: url('" + TMDB_IMAGES_ORIGINAL + backdropPath + "')"}
class="flex-shrink relative flex pt-24 aspect-video min-h-[70vh] px-4 sm:px-8 bg-center bg-cover sm:bg-fixed"
>
<div class="absolute inset-0 bg-gradient-to-t from-black to-50% to-darken" />
<div class="absolute inset-0 bg-gradient-to-t from-black to-30% to-darken" />
<div class="z-[1] flex-1 flex justify-end gap-8 items-end">
<div
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"

View File

@@ -0,0 +1,225 @@
<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';
const TRAILER_TIMEOUT = 3000;
const TRAILER_LOAD_TIME = 1000;
const ANIMATION_DURATION = 150;
export let tmdbId: number;
export let type: 'movie' | 'series';
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 trailerMountedTime = 0;
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;
trailerMountedTime = 0;
trailerVisible = false;
UIVisible = true;
timeout = setTimeout(() => {
trailerMounted = true;
trailerMountedTime = Date.now();
timeout = setTimeout(() => {
trailerVisible = true;
}, TRAILER_LOAD_TIME);
}, TRAILER_TIMEOUT - TRAILER_LOAD_TIME);
}
onMount(() => {
return () => clearTimeout(timeout);
});
</script>
<div class="h-screen relative pt-24 flex">
<div class="relative z-[1] px-16 py-8 flex-1 grid grid-cols-6">
{#if UIVisible}
<div class="flex flex-col justify-center 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-6xl': title.length < 15,
'text-5xl': 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>
<div
class="flex-1 flex gap-4 items-end col-span-2 col-start-1"
in:fade|global={{ delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fade|global={{ duration: ANIMATION_DURATION }}
>
<div class="flex gap-6 items-center">
<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 class="flex gap-6 flex-wrap py-2">
<div>
<p class="text-zinc-400 text-sm font-medium">Release Date</p>
<h2 class="font-semibold">
{releaseDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</h2>
</div>
{#if director}
<div>
<p class="text-zinc-400 text-sm font-medium">Directed By</p>
<h2 class="font-semibold">{director}</h2>
</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="row-start-2 col-start-5 col-span-2 flex gap-4 items-end justify-end">
{#if trailerId}
<Button
type="secondary"
href={youtubeUrl}
on:mouseover={() => (focusTrailer = true)}
on:mouseleave={() => (focusTrailer = false)}
>
<span>Watch Trailer</span><ChevronRight size={20} />
</Button>
{/if}
<Button type="primary" href={`/${type}/${tmdbId}`}>
<span>Details</span><ChevronRight size={20} />
</Button>
</div>
</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-[30%] 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}
<div class="absolute inset-y-0 left-0 px-3 flex justify-start w-[10vw]">
<div class="peer relaitve z-10 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]">
<div class="peer relaitve z-10 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 UIVisible}
<div
class="absolute inset-x-0 bottom-8 flex justify-center opacity-70 gap-3"
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>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import classNames from 'classnames';
export let videoId: string;
</script>
<div class="overflow-hidden w-full h-full">
<div class="youtube-container scale-[150%] hidden sm:block h-full w-full">
<iframe
src={'https://www.youtube.com/embed/' +
videoId +
'?autoplay=1&mute=1&loop=1&controls=0&modestbranding=1&playsinline=1&rel=0&enablejsapi=1'}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
tabindex="-1"
/>
</div>
</div>
<style>
.youtube-container {
overflow: hidden;
width: 100%;
aspect-ratio: 16/9;
pointer-events: none;
}
.youtube-container iframe {
width: 300%;
height: 100%;
margin-left: -100%;
}
</style>