General code cleanup and styling fixes regarding mobile hover actions & tab selection, updated screenshots
This commit is contained in:
16
README.md
16
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
# 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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
BIN
images/reiverr-demo.gif
Normal file
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
BIN
images/screenshot-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -16,4 +16,4 @@ function createTitlePageModalStore() {
|
||||
};
|
||||
}
|
||||
|
||||
export let titlePageModal = createTitlePageModalStore();
|
||||
export const titlePageModal = createTitlePageModalStore();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -13,5 +13,8 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true
|
||||
},
|
||||
plugins: [require('tailwind-scrollbar-hide')]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user