diff --git a/src/app.html b/src/app.html index d3b0b9f..480fd13 100644 --- a/src/app.html +++ b/src/app.html @@ -24,7 +24,7 @@
%sveltekit.body%
diff --git a/src/lib/apis/tmdb/tmdbApi.ts b/src/lib/apis/tmdb/tmdbApi.ts index 5ee7f70..408abdc 100644 --- a/src/lib/apis/tmdb/tmdbApi.ts +++ b/src/lib/apis/tmdb/tmdbApi.ts @@ -1,9 +1,10 @@ import { browser } from '$app/environment'; -import { TMDB_API_KEY } from '$lib/constants'; +import { TMDB_API_KEY, TMDB_BACKDROP_SMALL } from '$lib/constants'; import { settings } from '$lib/stores/settings.store'; import createClient from 'openapi-fetch'; import { get } from 'svelte/store'; import type { operations, paths } from './tmdb.generated'; +import type { TitleType } from '$lib/types'; const CACHE_ONE_DAY = 'max-age=86400'; const CACHE_FOUR_DAYS = 'max-age=345600'; @@ -302,6 +303,36 @@ export const getTmdbItemBackdrop = (item: { item?.images?.backdrops?.[0] )?.file_path; +export const getPosterProps = async ( + item: { + name?: string; + title?: string; + id?: number; + vote_average?: number; + number_of_seasons?: number; + first_air_date?: string; + poster_path?: string; + }, + type: TitleType | undefined = undefined +) => { + const backdropUri = item.poster_path; + const t = + type || + (item?.number_of_seasons === undefined && item?.first_air_date === undefined + ? 'movie' + : 'series'); + return { + tmdbId: item.id || 0, + title: item.title || item.name || '', + // subtitle: item.subtitle || '', + rating: item.vote_average || undefined, + size: 'md', + backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '', + type: t, + orientation: 'portrait' + } as const; +}; + export const getTmdbPerson = async (person_id: number) => TmdbApiOpen.get('/3/person/{person_id}', { params: { diff --git a/src/lib/components/Carousel/Carousel.svelte b/src/lib/components/Carousel/Carousel.svelte index d82b474..1720cf0 100644 --- a/src/lib/components/Carousel/Carousel.svelte +++ b/src/lib/components/Carousel/Carousel.svelte @@ -9,17 +9,21 @@ let carousel: HTMLDivElement | undefined; let scrollX = 0; + export let scrollClass = ''; -
-
+
+
{heading}
{ @@ -40,7 +44,10 @@
(scrollX = carousel?.scrollLeft || scrollX)} diff --git a/src/lib/components/EpisodeCard/EpisodeCard.svelte b/src/lib/components/EpisodeCard/EpisodeCard.svelte index 874c874..27a8fac 100644 --- a/src/lib/components/EpisodeCard/EpisodeCard.svelte +++ b/src/lib/components/EpisodeCard/EpisodeCard.svelte @@ -22,7 +22,7 @@ export let jellyfinId: string | undefined = undefined; - export let size: 'md' | 'dynamic' = 'md'; + export let size: 'md' | 'sm' | 'dynamic' = 'md'; function handleSetWatched() { if (!jellyfinId) return; @@ -39,7 +39,10 @@ setJellyfinItemUnwatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000)); } - function handlePlay() { + function handlePlay(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!jellyfinId) return; playerState.streamJellyfinId(jellyfinId); @@ -67,7 +70,8 @@ '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 text-left', { - 'h-40': size === 'md', + 'h-44': size === 'md', + 'h-36 lg:h-44': size === 'sm', 'h-full': size === 'dynamic', group: !!jellyfinId, 'cursor-default': !jellyfinId @@ -79,16 +83,17 @@ >
{#if airDate && airDate > new Date()} -

+

{airDate.toLocaleString('en-US', { month: 'short', day: 'numeric', @@ -107,18 +112,18 @@ })}

{:else if episodeNumber} -

{episodeNumber}

+

{episodeNumber}

{/if}
{#if runtime && !progress} -

+

{runtime.toFixed(0)} min

{:else if runtime && progress} -

+

{(runtime - (runtime / 100) * progress).toFixed(0)} min left

{/if} @@ -132,7 +137,7 @@

{subtitle}

{/if} {#if title} -

+

{title}

{/if} diff --git a/src/lib/components/Lang/I18n.svelte b/src/lib/components/Lang/I18n.svelte index 80cd97c..3b08331 100644 --- a/src/lib/components/Lang/I18n.svelte +++ b/src/lib/components/Lang/I18n.svelte @@ -1,11 +1,14 @@ + +
+ {#each Array.from({ length }) as _, i} + + + +
onJump(i)}> + +
+ {/each} +
diff --git a/src/lib/components/Poster/Poster.svelte b/src/lib/components/Poster/Poster.svelte index 22466ff..e3f9fb6 100644 --- a/src/lib/components/Poster/Poster.svelte +++ b/src/lib/components/Poster/Poster.svelte @@ -20,6 +20,7 @@ export let rating: number | undefined = undefined; export let progress = 0; + export let shadow = false; export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md'; export let orientation: 'portrait' | 'landscape' = 'landscape'; @@ -37,7 +38,7 @@ } }} class={classNames( - 'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left', + 'relative flex rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left', { 'aspect-video': orientation === 'landscape', 'aspect-[2/3]': orientation === 'portrait', @@ -47,7 +48,8 @@ 'h-44': size === 'md' && orientation === 'landscape', 'w-60': size === 'lg' && orientation === 'portrait', 'h-60': size === 'lg' && orientation === 'landscape', - 'w-full': size === 'dynamic' + 'w-full': size === 'dynamic', + 'shadow-lg': shadow } )} > diff --git a/src/lib/components/TitleShowcase/TitleShowcase.svelte b/src/lib/components/TitleShowcase/TitleShowcase.svelte deleted file mode 100644 index 7c99628..0000000 --- a/src/lib/components/TitleShowcase/TitleShowcase.svelte +++ /dev/null @@ -1,254 +0,0 @@ - - -
-
- {#if UIVisible} -
-
-
-

{releaseDate.getFullYear()}

- -

{formatMinutesToTime(runtime)}

- -

{tmdbRating.toFixed(1)} TMDB

-
-

= 15 - })} - in:fly|global={{ y: -10, delay: ANIMATION_DURATION, duration: ANIMATION_DURATION }} - out:fly|global={{ y: 10, duration: ANIMATION_DURATION }} - > - {title} -

-
-
- {#each genres.slice(0, 3) as genre} - - {genre} - - {/each} -
-
- {/if} - -
-
- - {#if trailerId} - - {/if} -
-
- - {#if !trailerVisible} -
- {/if} - {#if trailerId && $settings.autoplayTrailers && trailerMounted} -
- -
- {/if} - {#if UIVisible} -
- {:else if !UIVisible} -
- {/if} - {#if UIVisible} -
-
- - - -
-
-
-
-
- - - -
-
-
- {/if} -
diff --git a/src/lib/components/TitleShowcase/TitleShowcaseBackground.svelte b/src/lib/components/TitleShowcase/TitleShowcaseBackground.svelte new file mode 100644 index 0000000..344b349 --- /dev/null +++ b/src/lib/components/TitleShowcase/TitleShowcaseBackground.svelte @@ -0,0 +1,94 @@ + + + + +{#if !trailerVisible} + {#key tmdbId} +
+ {/key} +{/if} +{#if trailerId && $settings.autoplayTrailers && trailerMounted} +
+ +
+{/if} +{#if UIVisible} +
+{:else if !UIVisible} +
+{/if} diff --git a/src/lib/components/TitleShowcase/TitleShowcaseVisuals.svelte b/src/lib/components/TitleShowcase/TitleShowcaseVisuals.svelte new file mode 100644 index 0000000..71a2b89 --- /dev/null +++ b/src/lib/components/TitleShowcase/TitleShowcaseVisuals.svelte @@ -0,0 +1,96 @@ + + +
+ +
+
+
+

{releaseDate.getFullYear()}

+ +

{formatMinutesToTime(runtime)}

+ +

{tmdbRating.toFixed(1)} TMDB

+
+ +
+
+ {#each genres.slice(0, 3) as genre} + + {genre} + + {/each} +
+
+
diff --git a/src/lib/components/TitleShowcase/TitleShowcasesContainer.svelte b/src/lib/components/TitleShowcase/TitleShowcasesContainer.svelte new file mode 100644 index 0000000..1dee749 --- /dev/null +++ b/src/lib/components/TitleShowcase/TitleShowcasesContainer.svelte @@ -0,0 +1,166 @@ + + +
+
+ {#await tmdbPopularMoviesPromise then movies} + {@const movie = movies[showcaseIndex]} + + {#key movie?.id} + g.name || '') || []} + runtime={movie?.runtime || 0} + releaseDate={new Date(movie?.release_date || Date.now())} + tmdbRating={movie?.vote_average || 0} + posterUri={movie?.poster_path || ''} + {hideUI} + /> + {/key} +
+ + {#if !hideUI} +
+ + + +
+ {/if} +
+ v.site === 'YouTube' && v.type === 'Trailer') + ?.key} + backdropUri={movie?.backdrop_path || ''} + /> + {/await} +
+
+ {#if !continueWatchingEmpty} + +
Continue Watching
+ {#await nextUpProps} + + {:then props} + {#each props as prop} + (window.location.href = `/${prop.type}/${prop.tmdbId}`)} + {...prop} + size="sm" + /> + {/each} + {/await} +
+ {/if} +
+
diff --git a/src/lib/lang/de.json b/src/lib/lang/de.json new file mode 100644 index 0000000..46b7a96 --- /dev/null +++ b/src/lib/lang/de.json @@ -0,0 +1,102 @@ +{ + "appName": "Reiverr", + "setupRequiredTitle": "Willkommen zu", + "setupRequiredDescription": "Es scheint das einige Umgebungsvariables zur fehlerfreien Ausführung fehlen. Bitte gebe die folgenden Umgebungsvariablen an:", + "navbar": { + "home": "Start", + "discover": "Entdecken", + "library": "Bibliothek", + "sources": "Quellen", + "settings": "Einstellungen" + }, + "search": { + "placeHolder": "Suche nach Filmen und Serien", + "noRecentSearches": "Kein Suchverlauf", + "noResults": "Keine Suchergebnisse" + }, + "discover": { + "trending": "Aufsteigend", + "popularPeople": "Beliebte Personen", + "upcomingMovies": "Bald verfügbare Filme", + "upcomingSeries": "Bald verfügbare Serien", + "genres": "Genres", + "newDigitalReleases": "Neue Digitale Veröffentlichungen", + "streamingNow": "Aktuell im Stream", + "TVNetworks": "Anbieter" + }, + "library": { + "missingConfiguration": "Konfiguriere Radarr, Sonarr und Jellyfin zum Verwalten und Abspielen der Bibliothek", + "available": "Verfügbar", + "watched": "Geschaut", + "unavailable": "Nicht verfügbar", + "sort": { + "byTitle": "Nach Titel" + }, + "content": { + "movie": "Film", + "show": "Serie", + "requestContent": "Anfrage", + "directedBy": "Regie von", + "releaseDate": "Veröffentlichung", + "budget": "Budget", + "status": "Status", + "runtime": "Spielzeit", + "castAndCrew": "Cast & Crew", + "recommendations": "Empfehlungen", + "similarTitles": "Ähnliche Titel" + } + }, + "sources": {}, + "titleShowcase": { + "details": "Details", + "watchTrailer": "Trailer abspielen", + "releaseDate": "Veröffentlichung", + "directedBy": "Regie von" + }, + "settings": { + "navbar": { + "settings": "Konfiguration", + "general": "Allgemein", + "integrations": "Integrationen" + }, + "general": { + "userInterface": { + "userInterface": "Benutzeroberfläche", + "language": "Sprache", + "autoplayTrailers": "Trailer automatisch abspielen", + "animationDuration": "Animationsdauer" + }, + "discovery": { + "discovery": "Entdecken", + "none": "Keine", + "region": "Region", + "excludeLibraryItemsFromDiscovery": "Schließe Bibliothekeinträge von 'Entdecken' aus", + "includedLanguages": "Enthaltene Sprachen", + "includedLanguagesDescription": "Filter Resultate nach gesprochener Sprache. Trage ISO 639-1 Sprachcodes kommasepariert ein. Freilassen zum deaktivieren." + } + }, + "integrations": { + "integrations": "Integrationen", + "integrationsNote": "Anmerkungen: Basis URLs müssen vom Browser aus erreichbar sein. Interne Docker Adressen funktionieren nicht. API Schlüssel sind Sichtbar in den Browseranfragen.", + "baseUrl": "Basis URL", + "apiKey": "API Schlüssel", + "testConnection": "Verbindung testen", + "status": { + "connected": "Verbunden", + "disconnected": "Getrennt" + }, + "options": { + "options": "Optionen", + "rootFolder": "Hauptordner", + "qualityProfile": "Qualitätsprofil", + "languageProfile": "Sprachprofil", + "jellyfinUser": "Jellyfin Benutzer" + } + }, + "misc": { + "saveChanges": "Änderungen speichern", + "resetToDefaults": "Auf Standard zurücksetzen ", + "changelog": "Änderungen" + } + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9e48f28..2e52ad1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,14 +2,30 @@ import { getJellyfinBackdrop, getJellyfinContinueWatching, - getJellyfinNextUp + getJellyfinNextUp, + type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi'; - import { getTmdbMovie, getTmdbPopularMovies } from '$lib/apis/tmdb/tmdbApi'; + import { + getPosterProps, + getTmdbMovie, + getTmdbPopularMovies, + TmdbApiOpen + } from '$lib/apis/tmdb/tmdbApi'; import Carousel from '$lib/components/Carousel/Carousel.svelte'; import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte'; - import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte'; - import TitleShowcase from '$lib/components/TitleShowcase/TitleShowcase.svelte'; + import GenreCard from '$lib/components/GenreCard.svelte'; + import NetworkCard from '$lib/components/NetworkCard.svelte'; + import PersonCard from '$lib/components/PersonCard/PersonCard.svelte'; + import Poster from '$lib/components/Poster/Poster.svelte'; + import TitleShowcases from '$lib/components/TitleShowcase/TitleShowcasesContainer.svelte'; + import { genres, networks } from '$lib/discover'; import { jellyfinItemsStore } from '$lib/stores/data.store'; + import { settings } from '$lib/stores/settings.store'; + import type { TitleType } from '$lib/types'; + import { formatDateToYearMonthDay } from '$lib/utils'; + import type { ComponentProps } from 'svelte'; + import { _ } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; let continueWatchingVisible = true; @@ -73,46 +89,220 @@ (showcaseIndex - 1 + (await tmdbPopularMoviesPromise).length) % (await tmdbPopularMoviesPromise).length; } + + const jellyfinItemsPromise = new Promise((resolve) => { + jellyfinItemsStore.subscribe((data) => { + if (data.loading) return; + resolve(data.data || []); + }); + }); + + const fetchCardProps = async ( + items: { + name?: string; + title?: string; + id?: number; + vote_average?: number; + number_of_seasons?: number; + first_air_date?: string; + poster_path?: string; + }[], + type: TitleType | undefined = undefined + ): Promise[]> => { + const filtered = $settings.discover.excludeLibraryItems + ? items.filter( + async (item) => + !(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id)) + ) + : items; + + return Promise.all(filtered.map(async (item) => getPosterProps(item, type))).then((props) => + props.filter((p) => p.backdropUrl) + ); + }; + + const trendingItemsPromise = TmdbApiOpen.get('/3/trending/all/{time_window}', { + params: { + path: { + time_window: 'day' + }, + query: { + language: $settings.language + } + } + }).then((res) => res.data?.results || []); + + const fetchTrendingProps = () => trendingItemsPromise.then(fetchCardProps); + + const fetchTrendingActorProps = () => + TmdbApiOpen.get('/3/trending/person/{time_window}', { + params: { + path: { + time_window: 'week' + } + } + }) + .then((res) => res.data?.results || []) + .then((actors) => + actors + .filter((a) => a.profile_path) + .map((actor) => ({ + tmdbId: actor.id || 0, + backdropUri: actor.profile_path || '', + name: actor.name || '', + subtitle: actor.known_for_department || '' + })) + ); + + const fetchUpcomingMovies = () => + TmdbApiOpen.get('/3/discover/movie', { + params: { + query: { + 'primary_release_date.gte': formatDateToYearMonthDay(new Date()), + sort_by: 'popularity.desc', + language: $settings.language, + region: $settings.discover.region, + with_original_language: parseIncludedLanguages($settings.discover.includedLanguages) + } + } + }) + .then((res) => res.data?.results || []) + .then(fetchCardProps); + + const fetchUpcomingSeries = () => + TmdbApiOpen.get('/3/discover/tv', { + params: { + query: { + 'first_air_date.gte': formatDateToYearMonthDay(new Date()), + sort_by: 'popularity.desc', + language: $settings.language, + with_original_language: parseIncludedLanguages($settings.discover.includedLanguages) + } + } + }) + .then((res) => res.data?.results || []) + .then((i) => fetchCardProps(i, 'series')); + + const fetchDigitalReleases = () => + TmdbApiOpen.get('/3/discover/movie', { + params: { + query: { + with_release_type: 4, + sort_by: 'popularity.desc', + 'release_date.lte': formatDateToYearMonthDay(new Date()), + language: $settings.language, + with_original_language: parseIncludedLanguages($settings.discover.includedLanguages) + // region: $settings.discover.region + } + } + }) + .then((res) => res.data?.results || []) + .then(fetchCardProps); + + const fetchNowStreaming = () => + TmdbApiOpen.get('/3/discover/tv', { + params: { + query: { + 'air_date.gte': formatDateToYearMonthDay(new Date()), + 'first_air_date.lte': formatDateToYearMonthDay(new Date()), + sort_by: 'popularity.desc', + language: $settings.language, + with_original_language: parseIncludedLanguages($settings.discover.includedLanguages) + } + } + }) + .then((res) => res.data?.results || []) + .then((i) => fetchCardProps(i, 'series')); + + function parseIncludedLanguages(includedLanguages: string) { + return includedLanguages.replace(' ', '').split(',').join('|'); + } -
- {#await tmdbPopularMoviesPromise then movies} - {@const movie = movies[showcaseIndex]} - {#key movie?.id} - g.name || '') || []} - runtime={movie?.runtime || 0} - releaseDate={new Date(movie?.release_date || Date.now())} - tmdbRating={movie?.vote_average || 0} - trailerId={movie?.videos?.results?.find((v) => v.site === 'YouTube' && v.type === 'Trailer') - ?.key} - director={movie?.credits?.crew?.find((c) => c.job === 'Director')?.name} - backdropUri={movie?.backdrop_path || ''} - posterUri={movie?.poster_path || ''} - {onPrevious} - {onNext} - {showcaseIndex} - showcaseLength={movies.length} - /> - {/key} - {/await} -
+ -