mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-22 00:35:12 +02:00
Reworked showcase front page
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
225
src/lib/components/TitleShowcase/TitleShowcase.svelte
Normal file
225
src/lib/components/TitleShowcase/TitleShowcase.svelte
Normal 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>
|
||||
35
src/lib/components/YoutubePlayer.svelte
Normal file
35
src/lib/components/YoutubePlayer.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user