General code cleanup and styling fixes regarding mobile hover actions & tab selection, updated screenshots

This commit is contained in:
Aleksi Lassila
2023-08-10 20:40:00 +03:00
parent fe6042f86d
commit 2566829e56
22 changed files with 317 additions and 483 deletions

View File

@@ -2,11 +2,9 @@
Reiverr is a project that aims to create a single UI for interacting with TMDB, Jellyfin, Radarr and Sonarr, as well as be an alternative to Overseerr.
This project is still in alpha, and many features are still missing. Contributions are welcome and necessary for the project to achieve it's full potential! If you would like to contribute, see [contributing](#Contributing).
This project is still in alpha, and many features are still missing. Contributions are welcome! See [contributing](#Contributing) for more information.
![Landing Page](images/screenshot-1.png)
![Discover Page](images/screenshot-2.png)
![Demo Videi](images/reiverr-demo.gif)
# List of major featuers
@@ -40,7 +38,7 @@ Before you contribute:
- If the ticket is vague or missing information, please ask for clarification in the comments.
- UI style must match the rest of the project and it is a good idea to discuss the design beforehand, especially for larger design choices (issues labelled with "design")
I'm not a designer, so if you have any ideas for improving the UI, I'd love to implement them. If you are a designer and would like to help, contributions are much appreciated!
I'm not a designer, so if you have any ideas for improving the UI, I'd love to learn about them. If you are a designer and would like to help, contributions are much appreciated!
# Development
@@ -68,3 +66,11 @@ PUBLIC_SONARR_BASE_URL=http://192.168.0.129:8989
PUBLIC_JELLYFIN_API_KEY=
PUBLIC_JELLYFIN_URL=http://192.168.0.129:8096
```
# Additional Screenshots
![Landing Page](images/screenshot-1.png)
![Series Page](images/screenshot-2.png)
![Library Page](images/screenshot-3.png)

BIN
images/reiverr-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
images/screenshot-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -1,11 +1,10 @@
import { browser } from '$app/environment';
import { TMDB_API_KEY } from '$lib/constants';
import { getIncludedLanguagesQuery, settings } from '$lib/stores/settings.store';
import { formatDateToYearMonthDay } from '$lib/utils';
import axios from 'axios';
import createClient from 'openapi-fetch';
import { get } from 'svelte/store';
import type { operations, paths } from './tmdb.generated';
import { browser } from '$app/environment';
const CACHE_ONE_DAY = 'max-age=86400';
const CACHE_FOUR_DAYS = 'max-age=345600';
@@ -188,6 +187,8 @@ export const getTmdbSeriesPoster = async (tmdbId: number) =>
export const getTmdbMoviePoster = async (tmdbId: number) =>
getTmdbCache(posterCache, tmdbId, () => getTmdbMovie(tmdbId).then((m) => m?.poster_path));
/** Discover */
export const getTmdbPopularMovies = () =>
TmdbApiOpen.get('/3/movie/popular', {
params: {
@@ -324,238 +325,148 @@ export const searchTmdbTitles = (query: string) =>
}
}).then((res) => res.data?.results || []);
// Deprecated hereon forward
/** @deprecated */
export const TmdbApi = axios.create({
baseURL: 'https://api.themoviedb.org/3',
headers: {
Authorization: `Bearer ${TMDB_API_KEY}`
export const TMDB_MOVIE_GENRES = [
{
id: 28,
name: 'Action'
},
{
id: 12,
name: 'Adventure'
},
{
id: 16,
name: 'Animation'
},
{
id: 35,
name: 'Comedy'
},
{
id: 80,
name: 'Crime'
},
{
id: 99,
name: 'Documentary'
},
{
id: 18,
name: 'Drama'
},
{
id: 10751,
name: 'Family'
},
{
id: 14,
name: 'Fantasy'
},
{
id: 36,
name: 'History'
},
{
id: 27,
name: 'Horror'
},
{
id: 10402,
name: 'Music'
},
{
id: 9648,
name: 'Mystery'
},
{
id: 10749,
name: 'Romance'
},
{
id: 878,
name: 'Science Fiction'
},
{
id: 10770,
name: 'TV Movie'
},
{
id: 53,
name: 'Thriller'
},
{
id: 10752,
name: 'War'
},
{
id: 37,
name: 'Western'
}
});
];
/** @deprecated */
export const fetchTmdbMovie = async (tmdbId: string): Promise<TmdbMovie> =>
await TmdbApi.get<TmdbMovie>('/movie/' + tmdbId).then((r) => r.data);
/** @deprecated */
export const fetchTmdbMovieVideos = async (tmdbId: string): Promise<Video[]> =>
await TmdbApi.get<VideosResponse>('/movie/' + tmdbId + '/videos').then((res) => res.data.results);
/** @deprecated */
export const fetchTmdbMovieImages = async (tmdbId: string): Promise<ImagesResponse> =>
await TmdbApi.get<ImagesResponse>('/movie/' + tmdbId + '/images', {
headers: {
'Cache-Control': CACHE_FOUR_DAYS // 4 days
}
}).then((res) => res.data);
/** @deprecated */
export const fetchTmdbMovieCredits = async (tmdbId: string): Promise<CastMember[]> =>
await TmdbApi.get<CreditsResponse>('/movie/' + tmdbId + '/credits').then((res) => res.data.cast);
export interface TmdbMovieFull extends TmdbMovie {
videos: {
results: Video[];
};
// images: {
// backdrops: Backdrop[];
// logos: Logo[];
// posters: Poster[];
// };
credits: {
cast: CastMember[];
};
}
export type MovieDetailsResponse = TmdbMovie;
export interface TmdbMovie {
adult: boolean;
backdrop_path: string;
belongs_to_collection: any;
budget: number;
genres: Genre[];
homepage: string;
id: number;
imdb_id: string;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string;
production_companies: ProductionCompany[];
production_countries: ProductionCountry[];
release_date: string;
revenue: number;
runtime: number;
spoken_languages: SpokenLanguage[];
status: string;
tagline: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export interface Genre {
id: number;
name: string;
}
export interface ProductionCompany {
id: number;
logo_path?: string;
name: string;
origin_country: string;
}
export interface ProductionCountry {
iso_3166_1: string;
name: string;
}
export interface SpokenLanguage {
english_name: string;
iso_639_1: string;
name: string;
}
export interface CreditsResponse {
id: number;
cast: CastMember[];
crew: CrewMember[];
}
export interface CastMember {
adult: boolean;
gender: number;
id: number;
known_for_department: string;
name: string;
original_name: string;
popularity: number;
profile_path?: string;
cast_id: number;
character: string;
credit_id: string;
order: number;
}
export interface CrewMember {
adult: boolean;
gender: number;
id: number;
known_for_department: string;
name: string;
original_name: string;
popularity: number;
profile_path?: string;
credit_id: string;
department: string;
job: string;
}
export interface VideosResponse {
id: number;
results: Video[];
}
export interface Video {
iso_639_1: string;
iso_3166_1: string;
name: string;
key: string;
site: string;
size: number;
type: string;
official: boolean;
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;
}
export interface MultiSearchResponse {
page: number;
results: MultiSearchResult[];
total_pages: number;
total_results: number;
}
export interface MultiSearchResult {
adult: boolean;
backdrop_path?: string;
id: number;
title: string;
original_language: string;
original_title: string;
overview: string;
poster_path?: string;
media_type: string;
genre_ids: number[];
popularity: number;
release_date: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export interface PopularMoviesResponse {
page: number;
results: PopularMovieResult[];
total_pages: number;
total_results: number;
}
export interface PopularMovieResult {
adult: boolean;
backdrop_path: string;
genre_ids: number[];
id: number;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string;
release_date: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export const TMDB_SERIES_GENRES = [
{
id: 10759,
name: 'Action & Adventure'
},
{
id: 16,
name: 'Animation'
},
{
id: 35,
name: 'Comedy'
},
{
id: 80,
name: 'Crime'
},
{
id: 99,
name: 'Documentary'
},
{
id: 18,
name: 'Drama'
},
{
id: 10751,
name: 'Family'
},
{
id: 10762,
name: 'Kids'
},
{
id: 9648,
name: 'Mystery'
},
{
id: 10763,
name: 'News'
},
{
id: 10764,
name: 'Reality'
},
{
id: 10765,
name: 'Sci-Fi & Fantasy'
},
{
id: 10766,
name: 'Soap'
},
{
id: 10767,
name: 'Talk'
},
{
id: 10768,
name: 'War & Politics'
},
{
id: 37,
name: 'Western'
}
];

View File

@@ -16,7 +16,8 @@
'flex items-center gap-1 rounded-xl font-medium select-none cursor-pointer selectable transition-all flex-shrink-0',
{
'bg-white text-zinc-900 font-extrabold backdrop-blur-lg': type === 'primary',
'hover:bg-amber-400 hover:border-amber-400': type === 'primary' && !disabled,
'hover:bg-amber-400 focus-within:bg-amber-400 hover:border-amber-400 focus-within:border-amber-400':
type === 'primary' && !disabled,
'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,

View File

@@ -9,6 +9,7 @@
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
import type { TitleType } from '$lib/types';
import { openTitleModal } from '../Modal/Modal';
import ProgressBar from '../ProgressBar.svelte';
export let tmdbId: number;
export let jellyfinId: string | undefined = undefined;
@@ -53,7 +54,8 @@
</svelte:fragment>
<button
class={classNames(
'rounded overflow-hidden relative shadow-lg shrink-0 aspect-video selectable block hover:text-inherit',
'rounded overflow-hidden relative shadow-lg shrink-0 aspect-video selectable hover:text-inherit flex flex-col justify-between group placeholder-image',
'p-2 px-3 gap-2',
{
'h-40': size === 'md',
'h-60': size === 'lg',
@@ -69,12 +71,19 @@
}}
>
<div
style={'width: ' + (progress ? Math.max(progress, 2) : progress) + '%'}
class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]"
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropUri + "')"}
class="absolute inset-0 bg-center bg-cover group-hover:scale-105 group-focus-visible:scale-105 transition-transform"
/>
<div
class="hidden sm:flex flex-col justify-between h-full w-full opacity-0 hover:opacity-100 transition-opacity cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
class={classNames(
'absolute inset-0 transition-opacity bg-darken sm:bg-opacity-100 bg-opacity-50',
{
'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100': available
}
)}
/>
<div
class="flex flex-col justify-between flex-1 transition-opacity cursor-pointer relative opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
>
<div class="text-left">
<h1 class="font-bold tracking-wider text-lg">{title}</h1>
@@ -109,24 +118,21 @@
</div>
{/if}
<div class="flex gap-1.5 items-center">
<Star />
<div class="text-sm text-zinc-200">
{rating ? rating.toFixed(1) : 'N/A'}
{#if rating}
<div class="flex gap-1.5 items-center">
<Star />
<div class="text-sm text-zinc-200">
{rating.toFixed(1)}
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
<div
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropUri + "')"}
class="absolute inset-0 bg-center bg-cover peer-hover:scale-105 transition-transform"
/>
<div
class={classNames('absolute inset-0 transition-opacity', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
{#if progress}
<div class="relative">
<ProgressBar {progress} />
</div>
{/if}
</button>
</ContextMenu>

View File

@@ -1,15 +1,28 @@
import type { TmdbMovie2, TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
import { getTmdbMovieBackdrop, getTmdbSeriesBackdrop } from '$lib/apis/tmdb/tmdbApi';
import {
TMDB_MOVIE_GENRES,
TMDB_SERIES_GENRES,
getTmdbMovieBackdrop,
getTmdbSeriesBackdrop
} from '$lib/apis/tmdb/tmdbApi';
import type { ComponentProps } from 'svelte';
import type Card from './Card.svelte';
export const fetchCardTmdbMovieProps = async (movie: TmdbMovie2): Promise<ComponentProps<Card>> => {
const backdropUri = getTmdbMovieBackdrop(movie.id || 0);
const movieAny = movie as any;
const genres =
movie.genres?.map((g) => g.name || '') ||
movieAny?.genre_ids?.map(
(id: number) => TMDB_MOVIE_GENRES.find((g) => g.id === id)?.name || ''
) ||
[];
return {
tmdbId: movie.id || 0,
title: movie.title || '',
genres: movie.genres?.map((g) => g.name || '') || [],
genres,
runtimeMinutes: movie.runtime,
backdropUri: (await backdropUri) || '',
rating: movie.vote_average || 0
@@ -21,10 +34,18 @@ export const fetchCardTmdbSeriesProps = async (
): Promise<ComponentProps<Card>> => {
const backdropUri = getTmdbSeriesBackdrop(series.id || 0);
const seriesAny = series as any;
const genres =
series.genres?.map((g) => g.name || '') ||
seriesAny?.genre_ids?.map(
(id: number) => TMDB_SERIES_GENRES.find((g) => g.id === id)?.name || ''
) ||
[];
return {
tmdbId: series.id || 0,
title: series.name || '',
genres: series.genres?.map((g) => g.name || '') || [],
genres,
runtimeMinutes: series.episode_run_time?.[0],
backdropUri: (await backdropUri) || '',
rating: series.vote_average || 0,

View File

@@ -63,7 +63,8 @@
<button
on:click
class={classNames(
'aspect-video bg-center bg-cover bg-no-repeat rounded-lg overflow-hidden transition-all shadow-lg relative selectable flex-shrink-0 placeholder-image flex flex-col',
'aspect-video bg-center bg-cover rounded-lg overflow-hidden transition-opacity shadow-lg selectable flex-shrink-0 placeholder-image relative',
'flex flex-col px-2 lg:px-3 py-2 gap-2',
{
'h-40': size === 'md',
'h-full': size === 'dynamic',
@@ -77,15 +78,17 @@
>
<div
class={classNames(
'w-full flex-1 flex flex-col justify-between group-hover:opacity-0 transition-all',
'absolute inset-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0',
{
'px-2 lg:px-3 pt-2': true,
'pb-4 lg:pb-6': progress,
'pb-2': !progress,
'bg-gradient-to-t from-darken': !!jellyfinId,
'bg-darken': !jellyfinId || watched
'bg-darken': !jellyfinId || watched,
'bg-gradient-to-t from-darken': !!jellyfinId
}
)}
/>
<div
class={classNames(
'flex-1 flex flex-col justify-between relative group-hover:opacity-0 group-focus-visible:opacity-0 transition-all'
)}
>
<div class="flex justify-between items-center">
<div>
@@ -127,18 +130,18 @@
</slot>
</div>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<PlayButton
on:click={handlePlay}
class="opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
{#if progress}
<div
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 group-hover:opacity-0 transition-opacity"
>
<div class="relative group-hover:opacity-0 transition-opacity">
<ProgressBar {progress} />
</div>
{/if}
<div class="absolute inset-0 flex items-center justify-center">
{#if jellyfinId}
<PlayButton
on:click={handlePlay}
class="sm:opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
/>
{/if}
</div>
</button>
</ContextMenu>

View File

@@ -10,9 +10,9 @@
}
}
// onDestroy(() => {
// modalStack.reset();
// });
onDestroy(() => {
modalStack.reset();
});
</script>
<svelte:window on:keydown={handleShortcuts} />
@@ -33,7 +33,7 @@
{#if modal.group === modal.id}
<div
class="fixed inset-0 bg-stone-950 bg-opacity-80 z-20"
transition:fade={{ duration: 150 }}
transition:fade|global={{ duration: 150 }}
/>
{/if}
@@ -47,7 +47,6 @@
}
)}
on:click|self={() => modalStack.close(modal.id)}
transition:fade|global={{ duration: 100 }}
>
<svelte:component this={modal.component} {...modal.props} modalId={modal.id} />
</div>

View File

@@ -12,7 +12,7 @@
<a
class={classNames(
'flex flex-col justify-start gap-3 p-4 rounded-xl overflow-hidden relative shadow-lg shrink-0 selectable hover:text-inherit bg-stone-900 hover:bg-stone-800 group',
'flex flex-col justify-start gap-3 p-4 rounded-xl overflow-hidden relative shadow-lg shrink-0 selectable hover:text-inherit hover:bg-stone-800 focus-visible:bg-stone-800 bg-stone-900 group',
{
'w-36 h-56': size === 'md',
'h-52': size === 'lg',
@@ -27,7 +27,7 @@
>
<div
style={"background-image: url('" + TMDB_PROFILE_SMALL + backdropUri + "')"}
class="bg-center bg-cover group-hover:scale-105 transition-transform w-full h-full"
class="bg-center bg-cover group-hover:scale-105 group-focus-visible:scale-105 transition-transform w-full h-full"
/>
</div>
<div>
@@ -36,60 +36,4 @@
{name}
</h1>
</div>
<!-- <div
class="h-full w-full transition-opacity flex flex-col justify-between cursor-pointer relative z-[1] peer group"
>
<div class="opacity-0 group-hover:opacity-100" />
<div class="bg-gradient-to-t from-darken from-40% p-2 px-3 pt-8">
<h2
class="text-xs text-zinc-300 tracking-wider font-medium opacity-0 group-hover:opacity-100"
>
{department}
</h2>
<h1
class={classNames('font-semibold tracking-wider', {
'text-lg': size === 'lg'
})}
>
{name}
</h1>
</div>
</div> -->
</a>
<!-- <a
class={classNames(
'rounded-xl overflow-hidden relative shadow-lg shrink-0 aspect-[4/5] selectable block hover:text-inherit',
{
'h-40': size === 'md',
'h-52': size === 'lg',
'w-full': size === 'dynamic'
}
)}
href={`/person/${tmdbId}`}
>
<div
class="h-full w-full transition-opacity flex flex-col justify-between cursor-pointer relative z-[1] peer group"
>
<div class="opacity-0 group-hover:opacity-100">
</div>
<div class="bg-gradient-to-t from-darken from-40% p-2 px-3 pt-8">
<h2
class="text-xs text-zinc-300 tracking-wider font-medium opacity-0 group-hover:opacity-100"
>
{department}
</h2>
<h1
class={classNames('font-semibold tracking-wider', {
'text-lg': size === 'lg'
})}
>
{name}
</h1>
</div>
</div>
<div
style={"background-image: url('" + TMDB_PROFILE_SMALL + backdropUri + "')"}
class="absolute inset-0 bg-center bg-cover peer-hover:scale-105 transition-transform"
/>
</a> -->

View File

@@ -4,8 +4,14 @@
import classNames from 'classnames';
</script>
<div class={classNames($$restProps.class, 'backdrop-blur-lg rounded-full p-1 bg-[#00000044]')}>
<IconButton on:click>
<TriangleRight size={30} />
</IconButton>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
$$restProps.class,
'backdrop-blur-lg rounded-full bg-[#00000044] text-zinc-300 hover:text-zinc-200 p-2'
)}
on:click
>
<TriangleRight size={30} />
</div>

View File

@@ -1,14 +1,10 @@
<script lang="ts">
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
import { TmdbApi } from '$lib/apis/tmdb/tmdbApi';
import { TMDB_POSTER_SMALL } from '$lib/constants';
import { formatMinutesToTime } from '$lib/utils';
import { onMount } from 'svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import type { TitleType } from '$lib/types';
import classNames from 'classnames';
import PlayButton from '../PlayButton.svelte';
import ProgressBar from '../ProgressBar.svelte';
import type { TitleType } from '$lib/types';
import { playerState } from '../VideoPlayer/VideoPlayer';
export let tmdbId: number;
export let jellyfinId: string = '';
@@ -65,7 +61,7 @@
e.preventDefault();
jellyfinId && playerState.streamJellyfinId(jellyfinId);
}}
class="opacity-0 group-hover:opacity-100 transition-opacity"
class="sm:opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
{#if progress}

View File

@@ -1,12 +1,11 @@
<script lang="ts">
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
import type { TitleType } from '$lib/types';
import classNames from 'classnames';
import { ChevronLeft, Cross2, DotFilled, ExternalLink } from 'radix-icons-svelte';
import { fade } from 'svelte/transition';
import Carousel from '../Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '../Carousel/CarouselPlaceholderItems.svelte';
import IconButton from '../IconButton.svelte';
import type { TitleType } from '$lib/types';
export let isModal = false;
export let handleCloseModal: () => void = () => {};
@@ -41,7 +40,7 @@
"'); height: " +
imageHeight.toFixed() +
'px'}
class={classNames('hidden sm:block inset-x-0 bg-center bg-cover', {
class={classNames('hidden sm:block inset-x-0 bg-center bg-cover bg-stone-950', {
absolute: isModal,
fixed: !isModal
})}
@@ -56,7 +55,7 @@
"'); height: " +
imageHeight.toFixed() +
'px'}
class="sm:hidden fixed inset-x-0 bg-center bg-cover"
class="sm:hidden fixed inset-x-0 bg-center bg-cover bg-stone-950"
>
<div class="absolute inset-0 bg-darken" />
</div>
@@ -66,7 +65,6 @@
'h-[85vh] sm:h-screen': !isModal,
'': isModal
})}
transition:fade
>
<div
class={classNames('flex-1 relative flex pt-24 px-4 sm:px-8 pb-6', {

View File

@@ -16,4 +16,4 @@ function createTitlePageModalStore() {
};
}
export let titlePageModal = createTitlePageModalStore();
export const titlePageModal = createTitlePageModalStore();

View File

@@ -130,94 +130,31 @@
{/if}
</div> -->
</div>
<div
class="sm:flex-1 flex flex-col gap-6 justify-end col-span-2 col-start-1 row-start-3"
in:fade|global={{ delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fade|global={{ duration: ANIMATION_DURATION }}
>
<div class="flex gap-4 items-center">
<Button size="lg" type="primary" href={`/${type}/${tmdbId}`}>
<span>Details</span><ChevronRight size={20} />
</Button>
{#if trailerId}
<Button
size="lg"
type="secondary"
href={youtubeUrl}
on:mouseover={() => (focusTrailer = true)}
on:mouseleave={() => (focusTrailer = false)}
>
<span>Watch Trailer</span><ChevronRight size={20} />
</Button>
{/if}
<!-- <div
style={"background-image: url('" + TMDB_POSTER_SMALL + posterUri + "');"}
class="w-24 aspect-[2/3] rounded-lg bg-center bg-cover flex-shrink-0 shadow-lg"
/>
<div class="flex-1 flex gap-8 flex-wrap py-2 items-end">
<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 class="flex gap-8">
<div
style={"background-image: url('" + TMDB_POSTER_SMALL + posterUri + "');"}
class="w-32 aspect-[2/3] rounded-lg bg-center bg-cover flex-shrink-0 shadow-lg"
/>
<div class="flex flex-col gap-6">
<div class="flex-1 flex gap-6 flex-wrap items-end">
<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 class="flex gap-4">
<Button size="lg" type="primary" href={`/${type}/${tmdbId}`}>
<span>Details</span><ChevronRight size={20} />
</Button>
{#if trailerId}
<Button
size="lg"
type="secondary"
href={youtubeUrl}
on:mouseover={() => (focusTrailer = true)}
on:mouseleave={() => (focusTrailer = false)}
>
<span>Watch Trailer</span><ChevronRight size={20} />
</Button>
{/if}
</div>
</div>
</div> -->
</div>
{/if}
<div
class="sm:flex-1 flex flex-col gap-6 justify-end col-span-2 col-start-1 row-start-3"
in:fade|global={{ delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }}
out:fade|global={{ duration: ANIMATION_DURATION }}
>
<div class="flex gap-4 items-center">
<Button size="lg" type="primary" href={`/${type}/${tmdbId}`}>
<span>Details</span><ChevronRight size={20} />
</Button>
{#if trailerId}
<Button
size="lg"
type="secondary"
href={youtubeUrl}
target="_blank"
on:mouseover={() => (focusTrailer = true)}
on:mouseleave={() => (focusTrailer = false)}
>
<span>Watch Trailer</span><ChevronRight size={20} />
</Button>
{/if}
</div>
</div>
<div class="hidden lg:flex items-end justify-end col-start-4 row-start-3 col-span-3">
<div class="flex gap-6 items-center">
<div>
@@ -290,24 +227,34 @@
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-[1] flex justify-start">
<IconButton on:click={onPrevious}>
<ChevronLeft size={20} />
</IconButton>
{#if UIVisible}
<div
class="absolute inset-y-0 left-0 px-3 flex justify-start w-[10vw]"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
>
<div class="peer relaitve z-[1] flex justify-start">
<IconButton on:click={onPrevious}>
<ChevronLeft size={20} />
</IconButton>
</div>
<div
class="opacity-0 peer-hover:opacity-20 transition-opacity bg-gradient-to-r from-darken absolute inset-0"
/>
</div>
<div
class="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-[1] flex justify-end">
<IconButton on:click={onNext}>
<ChevronRight size={20} />
</IconButton>
class="absolute inset-y-0 right-0 px-3 flex justify-end w-[10vw]"
in:fade={{ duration: ANIMATION_DURATION }}
out:fade={{ duration: ANIMATION_DURATION }}
>
<div class="peer relaitve z-[1] flex justify-end">
<IconButton on:click={onNext}>
<ChevronRight size={20} />
</IconButton>
</div>
<div
class="opacity-0 peer-hover:opacity-20 transition-opacity bg-gradient-to-l from-darken absolute inset-0"
/>
</div>
<div
class="opacity-0 peer-hover:opacity-20 transition-opacity bg-gradient-to-l from-darken absolute inset-0"
/>
</div>
{/if}
</div>

View File

@@ -1,4 +1,3 @@
import type { Genre } from '$lib/apis/tmdb/tmdbApi';
import { writable } from 'svelte/store';
export function formatMinutesToTime(minutes: number) {
@@ -11,10 +10,6 @@ export function formatMinutesToTime(minutes: number) {
}`;
}
export function formatGenres(genres: Genre[]) {
return genres.map((genre) => genre.name.charAt(0).toUpperCase() + genre.name.slice(1)).join(', ');
}
export function formatSize(size: number) {
const gbs = size / 1024 / 1024 / 1024;
const mbs = size / 1024 / 1024;

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import DynamicModal from '$lib/components/Modal/DynamicModal.svelte';
import Navbar from '$lib/components/Navbar/Navbar.svelte';
import SetupRequired from '$lib/components/SetupRequired/SetupRequired.svelte';
@@ -14,13 +15,9 @@
<main>
<slot />
</main>
<!-- {#key $page.url.pathname} -->
<DynamicModal />
<!-- {/key} -->
<!-- <footer>-->
<!-- <p>visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to learn SvelteKit</p>-->
<!-- </footer>-->
{#key $page.url.pathname}
<DynamicModal />
{/key}
</div>
{:else}
<SetupRequired missingEnvironmentVariables={data.missingEnvironmentVariables} />

View File

@@ -19,13 +19,13 @@
import { library } from '$lib/stores/library.store';
import { getIncludedLanguagesQuery, settings } from '$lib/stores/settings.store';
import { formatDateToYearMonthDay } from '$lib/utils';
import { fade, fly } from 'svelte/transition';
import { fade } from 'svelte/transition';
const fetchCardProps = async (items: TmdbMovie2[] | TmdbSeries2[]) =>
Promise.all(
(
await ($settings.excludeLibraryItemsFromDiscovery
? library.filterNotInLibrary(items, (t) => t.id)
? library.filterNotInLibrary(items, (t) => t.id || 0)
: items)
).map(fetchCardTmdbProps)
).then((props) => props.filter((p) => p.backdropUri));
@@ -74,7 +74,7 @@
<div class="pt-24 bg-stone-950 pb-8">
<div class="max-w-screen-2xl mx-auto">
<Carousel gradientFromColor="from-black" heading="Trending" class="mx-2 sm:mx-8 2xl:mx-0">
<Carousel gradientFromColor="from-stone-950" heading="Trending" class="mx-2 sm:mx-8 2xl:mx-0">
{#await fetchTrendingProps()}
<CarouselPlaceholderItems size="lg" />
{:then props}

View File

@@ -2,14 +2,13 @@
import Card from '$lib/components/Card/Card.svelte';
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { TMDB_IMAGES_ORIGINAL } from '$lib/constants';
import { library, type PlayableItem } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import classNames from 'classnames';
import { CaretDown, ChevronDown, Gear } from 'radix-icons-svelte';
import { CaretDown, Gear } from 'radix-icons-svelte';
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
@@ -58,7 +57,8 @@
backdropUri: item.cardBackdropUrl,
rating: series.ratings?.value || series.ratings?.value || item.tmdbRating || 0,
seasons: series.seasons?.length || 0,
jellyfinId: item.sonarrSeries?.statistics?.sizeOnDisk ? item.jellyfinId : undefined
jellyfinId: item.sonarrSeries?.statistics?.sizeOnDisk ? item.jellyfinId : undefined,
progress: item.nextJellyfinEpisode?.UserData?.PlayedPercentage || undefined
};
} else if (movie) {
props = {
@@ -70,7 +70,8 @@
backdropUri: item.cardBackdropUrl,
rating: movie.ratings?.tmdb?.value || movie.ratings?.imdb?.value || 0,
runtimeMinutes: movie.runtime || 0,
jellyfinId: item.radarrMovie?.movieFile ? item.jellyfinId : undefined
jellyfinId: item.radarrMovie?.movieFile ? item.jellyfinId : undefined,
progress: item.jellyfinItem?.UserData?.PlayedPercentage || undefined
};
} else props = undefined;

View File

@@ -13,5 +13,8 @@ export default {
}
}
},
future: {
hoverOnlyWhenSupported: true
},
plugins: [require('tailwind-scrollbar-hide')]
};