Initial work on library page
This commit is contained in:
@@ -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">
|
||||
%sveltekit.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 {
|
||||
...(await TmdbApi.get('/movie/' + imdbId).then((res) => res.data)),
|
||||
videos: await TmdbApi.get('/movie/' + imdbId + '/videos').then((res) => res.data.results),
|
||||
credits: await TmdbApi.get('/movie/' + imdbId + '/credits').then((res) => res.data.cast)
|
||||
videos: await TmdbApi.get<VideosResponse>('/movie/' + imdbId + '/videos').then(
|
||||
(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 {
|
||||
adult: boolean;
|
||||
backdrop_path: string;
|
||||
@@ -119,3 +142,40 @@ export interface Video {
|
||||
published_at: 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
12
src/lib/utils.ts
Normal 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(', ');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { setClient } from 'svelte-apollo';
|
||||
import Navbar from './Navbar.svelte';
|
||||
import Navbar from './components/Navbar.svelte';
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SmallPoster from './SmallPoster.svelte';
|
||||
import SmallPoster from './components/SmallPoster/SmallPoster.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import ResourceDetails from './ResourceDetails/ResourceDetails.svelte';
|
||||
import ResourceDetails from './components/ResourceDetails/ResourceDetails.svelte';
|
||||
import ResourceDetailsControls from './ResourceDetailsControls.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
<ChevronRight size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-6 flex justify-center mx-auto opacity-50">
|
||||
<ChevronDown size="20" />
|
||||
</div>
|
||||
<!--<div class="absolute inset-x-0 bottom-6 flex justify-center mx-auto opacity-50">-->
|
||||
<!-- <ChevronDown size="20" />-->
|
||||
<!--</div>-->
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
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}
|
||||
<iframe
|
||||
class={classNames('transition-opacity', {
|
||||
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
<div
|
||||
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-0': !focusTrailer
|
||||
@@ -95,12 +95,12 @@
|
||||
/>
|
||||
<div
|
||||
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]',
|
||||
'transition-colors',
|
||||
{
|
||||
'bg-[#070501bf]': !focusTrailer,
|
||||
'bg-[#00000000]': focusTrailer
|
||||
'bg-darken': !focusTrailer,
|
||||
'bg-transparent': focusTrailer
|
||||
}
|
||||
)}
|
||||
>
|
||||
15
src/routes/components/SmallHorizontalPoster/PosterTag.svelte
Normal file
15
src/routes/components/SmallHorizontalPoster/PosterTag.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -3,14 +3,15 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
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';
|
||||
|
||||
let bg = '';
|
||||
let title = 'Loading...';
|
||||
|
||||
const progress = Math.random() > 0.5 ? Math.round(Math.random() * 100) : 100;
|
||||
|
||||
onMount(() => {
|
||||
TmdbApi.get('/' + type + '/' + tmdbId)
|
||||
.then((res) => res.data)
|
||||
@@ -35,9 +36,7 @@
|
||||
class="bg-center bg-cover aspect-[2/3] h-72 shadow-2xl m-1.5"
|
||||
style={"background-image: url('" + bg + "')"}
|
||||
>
|
||||
<div
|
||||
class="w-full h-full bg-gradient-to-b from-[#00000099] via-20% via-transparent hover:bg-[#00000099] transition-all flex"
|
||||
>
|
||||
<div class="w-full h-full hover:bg-darken transition-all flex">
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-2 flex flex-col justify-between flex-1 cursor-pointer"
|
||||
>
|
||||
@@ -1,6 +1,75 @@
|
||||
<div class="pt-24">
|
||||
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 available via any source.
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
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>
|
||||
|
||||
34
src/routes/library/+page.ts
Normal file
34
src/routes/library/+page.ts
Normal 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;
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ResourceDetails from '../../ResourceDetails/ResourceDetails.svelte';
|
||||
import ResourceDetails from '../../components/ResourceDetails/ResourceDetails.svelte';
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<ResourceDetails trailer={false} resource={data.movie} remoteResource={data.remoteMovie} />
|
||||
<ResourceDetails resource={data.movie} remoteResource={data.remoteMovie} />
|
||||
|
||||
@@ -6,6 +6,9 @@ export default {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
display: ['Inter', 'system', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
darken: '#070501bf'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user