Initial work on library page

This commit is contained in:
Aleksi Lassila
2023-06-15 02:08:47 +03:00
parent c463bb89e9
commit e41b030d45
15 changed files with 275 additions and 26 deletions

View File

@@ -10,7 +10,7 @@
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Nunito+Sans:wght@200;300;400;500;600;700;800;900;1000&display=swap" rel="stylesheet">
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" class="bg-zinc-950 min-h-screen text-white"> <body data-sveltekit-preload-data="hover" class="bg-stone-950 min-h-screen text-white">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View File

@@ -8,14 +8,37 @@ export const TmdbApi = axios.create({
} }
}); });
export async function fetchMovieDetails(imdbId: string | number) { export async function fetchMovieDetails(imdbId: string | number): Promise<TmdbMovieFull> {
return { return {
...(await TmdbApi.get('/movie/' + imdbId).then((res) => res.data)), ...(await TmdbApi.get('/movie/' + imdbId).then((res) => res.data)),
videos: await TmdbApi.get('/movie/' + imdbId + '/videos').then((res) => res.data.results), videos: await TmdbApi.get<VideosResponse>('/movie/' + imdbId + '/videos').then(
credits: await TmdbApi.get('/movie/' + imdbId + '/credits').then((res) => res.data.cast) (res) => res.data.results
),
images: await TmdbApi.get<ImagesResponse>('/movie/' + imdbId + '/images').then((res) => {
return {
backdrops: res.data.backdrops,
logos: res.data.logos,
posters: res.data.posters
};
}),
credits: await TmdbApi.get<CreditsResponse>('/movie/' + imdbId + '/credits').then(
(res) => res.data.cast
)
}; };
} }
export interface TmdbMovieFull extends TmdbMovie {
videos: Video[];
images: {
backdrops: Backdrop[];
logos: Logo[];
posters: Poster[];
};
credits: CastMember[];
}
export type MovieDetailsResponse = TmdbMovie;
export interface TmdbMovie { export interface TmdbMovie {
adult: boolean; adult: boolean;
backdrop_path: string; backdrop_path: string;
@@ -119,3 +142,40 @@ export interface Video {
published_at: string; published_at: string;
id: string; id: string;
} }
export interface ImagesResponse {
backdrops: Backdrop[];
id: number;
logos: Logo[];
posters: Poster[];
}
export interface Backdrop {
aspect_ratio: number;
height: number;
iso_639_1?: string;
file_path: string;
vote_average: number;
vote_count: number;
width: number;
}
export interface Logo {
aspect_ratio: number;
height: number;
iso_639_1: string;
file_path: string;
vote_average: number;
vote_count: number;
width: number;
}
export interface Poster {
aspect_ratio: number;
height: number;
iso_639_1?: string;
file_path: string;
vote_average: number;
vote_count: number;
width: number;
}

12
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Genre } from '$lib/tmdb-api';
export function getRuntime(minutes: number) {
const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60);
return `${hours > 0 ? hours + 'h ' : ''}${mins}min`;
}
export function formatGenres(genres: Genre[]) {
return genres.map((genre) => genre.name.charAt(0).toUpperCase() + genre.name.slice(1)).join(', ');
}

View File

@@ -1,7 +1,7 @@
<script> <script>
import '../app.css'; import '../app.css';
import { setClient } from 'svelte-apollo'; import { setClient } from 'svelte-apollo';
import Navbar from './Navbar.svelte'; import Navbar from './components/Navbar.svelte';
</script> </script>
<div class="app"> <div class="app">

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import SmallPoster from './SmallPoster.svelte'; import SmallPoster from './components/SmallPoster/SmallPoster.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import ResourceDetails from './ResourceDetails/ResourceDetails.svelte'; import ResourceDetails from './components/ResourceDetails/ResourceDetails.svelte';
import ResourceDetailsControls from './ResourceDetailsControls.svelte'; import ResourceDetailsControls from './ResourceDetailsControls.svelte';
export let data: PageData; export let data: PageData;

View File

@@ -18,6 +18,6 @@
<ChevronRight size="24" /> <ChevronRight size="24" />
</div> </div>
</div> </div>
<div class="absolute inset-x-0 bottom-6 flex justify-center mx-auto opacity-50"> <!--<div class="absolute inset-x-0 bottom-6 flex justify-center mx-auto opacity-50">-->
<ChevronDown size="20" /> <!-- <ChevronDown size="20" />-->
</div> <!--</div>-->

View File

@@ -66,7 +66,7 @@
remoteResource.backdrop_path + remoteResource.backdrop_path +
"')"} "')"}
> >
<div class="youtube-container absolute h-full scale-[150%]"> <div class="youtube-container absolute h-full scale-[150%] hidden sm:block">
{#if video.key} {#if video.key}
<iframe <iframe
class={classNames('transition-opacity', { class={classNames('transition-opacity', {
@@ -86,7 +86,7 @@
</div> </div>
<div <div
class={classNames( class={classNames(
'bg-gradient-to-b from-[#070501bf] via-20% via-transparent transition-opacity absolute inset-0 z-[1]', 'bg-gradient-to-b from-darken via-20% via-transparent transition-opacity absolute inset-0 z-[1]',
{ {
'opacity-100': focusTrailer, 'opacity-100': focusTrailer,
'opacity-0': !focusTrailer 'opacity-0': !focusTrailer
@@ -95,12 +95,12 @@
/> />
<div <div
class={classNames( class={classNames(
'h-full w-full px-16 pb-12 pt-32', 'h-full w-full 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]', 'grid grid-cols-[1fr_max-content] grid-rows-[1fr_min-content] gap-x-16 gap-y-8 relative z-[2]',
'transition-colors', 'transition-colors',
{ {
'bg-[#070501bf]': !focusTrailer, 'bg-darken': !focusTrailer,
'bg-[#00000000]': focusTrailer 'bg-transparent': focusTrailer
} }
)} )}
> >

View File

@@ -0,0 +1,15 @@
<script>
import classNames from 'classnames';
export let value = '';
export let filled = false;
</script>
<div
class={classNames('border rounded p-[0px] px-1 text-[10px] font-medium', {
'text-zinc-200 border-zinc-500': !filled,
'bg-zinc-200 border-zinc-200 text-zinc-900': filled
})}
>
{value}
</div>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import type { TmdbMovieFull } from '$lib/tmdb-api';
import { formatGenres, getRuntime } from '$lib/utils';
import classNames from 'classnames';
export let tmdbMovie: TmdbMovieFull;
export let available = true;
export let progress = 0;
export let progressType: 'watched' | 'downloading' = 'watched';
export let randomProgress = false;
if (randomProgress) {
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
}
const backdropUrl =
'https://www.themoviedb.org/t/p/original' +
tmdbMovie.images.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path;
</script>
<div
style={"background-image: url('" + backdropUrl + "')"}
class="bg-center bg-cover h-40 w-72 rounded overflow-hidden relative drop-shadow-2xl"
>
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
<div
on:click={() => window.open('/movie/' + tmdbMovie.id, '_self')}
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
>
<div>
<h1 class="font-bold tracking-wider">{tmdbMovie.original_title}</h1>
<div class="text-xs text-zinc-300 tracking-wider font-medium">
{formatGenres(tmdbMovie.genres)}
</div>
</div>
<div class="flex justify-between items-end">
{#if progressType === 'watched'}
<div class="text-xs font-medium text-zinc-200">
{progress
? getRuntime(tmdbMovie.runtime - tmdbMovie.runtime * (progress / 100)) + ' left'
: getRuntime(tmdbMovie.runtime)}
</div>
{:else if progressType === 'downloading'}
<div class="text-xs font-medium text-zinc-200">
{Math.floor(progress) + '% Downloaded'}
</div>
{/if}
</div>
</div>
<div
class={classNames('absolute inset-0', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
</div>

View File

@@ -3,14 +3,15 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
export let tmdbId; export let tmdbId;
export let progress = 0;
export let randomProgress = false;
if (randomProgress) progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
export let type: 'movie' | 'tv' = 'movie'; export let type: 'movie' | 'tv' = 'movie';
let bg = ''; let bg = '';
let title = 'Loading...'; let title = 'Loading...';
const progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
onMount(() => { onMount(() => {
TmdbApi.get('/' + type + '/' + tmdbId) TmdbApi.get('/' + type + '/' + tmdbId)
.then((res) => res.data) .then((res) => res.data)
@@ -35,9 +36,7 @@
class="bg-center bg-cover aspect-[2/3] h-72 shadow-2xl m-1.5" class="bg-center bg-cover aspect-[2/3] h-72 shadow-2xl m-1.5"
style={"background-image: url('" + bg + "')"} style={"background-image: url('" + bg + "')"}
> >
<div <div class="w-full h-full hover:bg-darken transition-all flex">
class="w-full h-full bg-gradient-to-b from-[#00000099] via-20% via-transparent hover:bg-[#00000099] transition-all flex"
>
<div <div
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer" class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
> >

View File

@@ -1,6 +1,75 @@
<div class="pt-24"> <script lang="ts">
Contains all the titles available locally, the ones already watched previously (greyed out at the import type { PageData } from './$types';
bottom), and the ones that are in some sort of watchlist and available via any source. import SmallHorizontalPoster from '../components/SmallHorizontalPoster/SmallHorizontalPoster.svelte';
import type { TmdbMovieFull } from '$lib/tmdb-api';
export let data: PageData;
console.log(data);
<div>Library</div> const allMovies: Record<string, TmdbMovieFull> = {};
data.tmdbMovies.forEach((m) => (allMovies[m.id] = m));
const tmdbIdToDownloading = {};
(data.downloading as any).forEach((d) => (tmdbIdToDownloading[d.movie.tmdbId] = d));
const tmdbIdToRadarrMovie = {};
(data.radarrMovies as any).forEach((r) => (tmdbIdToRadarrMovie[r.tmdbId] = r));
const downloading = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] !== undefined);
const available = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] === undefined);
const unavailable = data.tmdbMovies.filter(
(m) => !tmdbIdToRadarrMovie[m.id]?.hasFile && !tmdbIdToDownloading[m.id]
);
const watched = [];
const posterGridStyle = 'flex flex-wrap justify-center gap-x-4 gap-y-8';
const headerStyle = 'uppercase tracking-widest font-bold text-center mt-2';
</script>
<div
style="background-image: url('https://www.themoviedb.org/t/p/original/vvjYv7bSWerbsi0LsMjLnTVOX7c.jpg')"
>
<div class="py-24 backdrop-blur-2xl bg-darken px-8 flex flex-col gap-4">
<!-- Contains all the titles available locally, the ones already watched previously (greyed out at the-->
<!-- bottom), and the ones that are in some sort of watchlist and not available via any source.-->
<!-- <div>Library</div>-->
{#if downloading.length > 0}
<h1 class={headerStyle}>Downloading</h1>
<div class={posterGridStyle}>
{#each downloading as movie (movie.id)}
<SmallHorizontalPoster
progress={(tmdbIdToDownloading[movie.id].sizeleft /
tmdbIdToDownloading[movie.id].size) *
100}
progressType="downloading"
available={false}
tmdbMovie={movie}
/>
{/each}
</div>
{/if}
{#if available.length > 0}
<h1 class={headerStyle}>Available</h1>
<div class={posterGridStyle}>
{#each available as movie (movie.id)}
<SmallHorizontalPoster randomProgress={true} tmdbMovie={movie} />
{/each}
</div>
{/if}
{#if unavailable.length > 0}
<h1 class={headerStyle}>Unavailable</h1>
<div class={posterGridStyle}>
{#each unavailable as movie (movie.id)}
<SmallHorizontalPoster available={false} tmdbMovie={movie} />
{/each}
</div>
{/if}
{#if watched.length > 0}
<h1 class={headerStyle}>Watched</h1>
{/if}
</div>
</div> </div>

View File

@@ -0,0 +1,34 @@
import type { PageLoad } from './$types';
import { radarrApi } from '$lib/servarr-api';
import { fetchMovieDetails } from '$lib/tmdb-api';
export const load = (async () => {
const radarrMovies = await radarrApi
.get('/api/v3/movie', {
params: {}
})
.then((r) => r.data);
let tmdbMovies;
if (radarrMovies) {
tmdbMovies = await Promise.all(
radarrMovies.filter((m) => m.tmdbId).map((m) => fetchMovieDetails(m.tmdbId as any))
);
}
console.log('radarrMovies', radarrMovies);
return {
radarrMovies,
tmdbMovies,
downloading: await radarrApi
.get('/api/v3/queue', {
params: {
query: {
includeMovie: true
}
}
})
.then((r) => r.data?.records)
};
}) satisfies PageLoad;

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import ResourceDetails from '../../ResourceDetails/ResourceDetails.svelte'; import ResourceDetails from '../../components/ResourceDetails/ResourceDetails.svelte';
export let data: PageData; export let data: PageData;
</script> </script>
<ResourceDetails trailer={false} resource={data.movie} remoteResource={data.remoteMovie} /> <ResourceDetails resource={data.movie} remoteResource={data.remoteMovie} />

View File

@@ -6,6 +6,9 @@ export default {
fontFamily: { fontFamily: {
sans: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-serif'],
display: ['Inter', 'system', 'sans-serif'] display: ['Inter', 'system', 'sans-serif']
},
colors: {
darken: '#070501bf'
} }
} }
}, },