feat: Added library sorting and pagination, improved image loading and other small adjustments

This commit is contained in:
Aleksi Lassila
2023-08-31 02:45:36 +03:00
parent 203bce45b8
commit ca3911e062
24 changed files with 354 additions and 255 deletions

View File

@@ -91,7 +91,7 @@ export const getTmdbSeriesFromTvdbId = async (tvdbId: string) =>
}).then((res) => res.data?.tv_results?.[0] as TmdbSeries2 | undefined);
export const getTmdbIdFromTvdbId = async (tvdbId: number) =>
getTmdbSeriesFromTvdbId(tvdbId).then((res: any) => res?.id as number | undefined);
getTmdbSeriesFromTvdbId(String(tvdbId)).then((res: any) => res?.id as number | undefined);
export const getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | undefined> =>
await TmdbApiOpen.get('/3/tv/{series_id}', {
@@ -280,6 +280,16 @@ export const searchTmdbTitles = (query: string) =>
}
}).then((res) => res.data?.results || []);
export const getTmdbItemBackdrop = (item: {
images: { backdrops: { file_path: string; iso_639_1: string }[] };
}) =>
(
item?.images?.backdrops?.find((b) => b.iso_639_1 === get(settings)?.language) ||
item?.images?.backdrops?.find((b) => b.iso_639_1 === 'en') ||
item?.images?.backdrops?.find((b) => b.iso_639_1) ||
item?.images?.backdrops?.[0]
)?.file_path;
export const TMDB_MOVIE_GENRES = [
{
id: 28,

View File

@@ -55,7 +55,7 @@
)}
on:click={() => {
if (openInModal) {
openTitleModal(tmdbId, type, title);
openTitleModal(tmdbId, type);
} else {
window.location.href = `/${type}/${tmdbId}`;
}

View File

@@ -4,12 +4,17 @@
export let index = 0;
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let orientation: 'portrait' | 'landscape' = 'landscape';
</script>
<div
class={classNames('rounded overflow-hidden shadow-lg placeholder shrink-0 aspect-video', {
'h-40': size === 'md',
'h-60': size === 'lg',
class={classNames('rounded-xl overflow-hidden shadow-lg placeholder shrink-0', {
'aspect-video': orientation === 'landscape',
'aspect-[2/3]': orientation === 'portrait',
'w-44': size === 'md' && orientation === 'portrait',
'h-44': size === 'md' && orientation === 'landscape',
'w-60': size === 'lg' && orientation === 'portrait',
'h-60': size === 'lg' && orientation === 'landscape',
'w-full': size === 'dynamic'
})}
style={'animation-delay: ' + ((index * 100) % 2000) + 'ms;'}

View File

@@ -6,7 +6,6 @@
export let disabled = false;
export let position: 'absolute' | 'fixed' = 'fixed';
let anchored = position === 'absolute';
export let bottom = false;
export let id = Symbol();
@@ -21,7 +20,10 @@
}
export function handleOpen(event: MouseEvent) {
if (disabled || (anchored && $contextMenu === id)) return; // Clicking button will close menu
if (disabled || (anchored && $contextMenu === id)) {
close();
return;
}
fixedPosition = { x: event.clientX, y: event.clientY };
contextMenu.show(id);
@@ -63,7 +65,15 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:contextmenu|preventDefault={handleOpen} on:click={(e) => anchored && e.stopPropagation()}>
<div
on:contextmenu|preventDefault={handleOpen}
on:click={(e) => {
if (anchored) {
e.stopPropagation();
handleOpen(e);
}
}}
>
<slot />
</div>
@@ -75,12 +85,11 @@
? `left: ${
fixedPosition.x - (fixedPosition.x > windowWidth / 2 ? menu?.clientWidth : 0)
}px; top: ${
fixedPosition.y -
(bottom ? (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0) : 0)
fixedPosition.y - (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0)
}px;`
: menu?.getBoundingClientRect()?.left > windowWidth / 2
? `right: 0;${bottom ? 'bottom: 40px;' : ''}`
: `left: 0;${bottom ? 'bottom: 40px;' : ''}`}
? `right: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`
: `left: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`}
bind:this={menu}
in:fly|global={{ y: 5, duration: 100, delay: anchored ? 0 : 100 }}
out:fly|global={{ y: 5, duration: 100 }}

View File

@@ -1,25 +1,12 @@
<script lang="ts">
import ContextMenu from './ContextMenu.svelte';
import { contextMenu } from '../ContextMenu/ContextMenu';
import Button from '../Button.svelte';
import { DotsVertical } from 'radix-icons-svelte';
export let heading = '';
export let contextMenuId = Symbol();
function handleToggleVisibility() {
if ($contextMenu === contextMenuId) contextMenu.hide();
else contextMenu.show(contextMenuId);
}
</script>
<div class="relative">
<ContextMenu position="absolute" {heading} id={contextMenuId}>
<ContextMenu position="absolute" {heading}>
<slot name="menu" slot="menu" />
<Button slim on:click={handleToggleVisibility}>
<DotsVertical size={24} />
</Button>
<slot />
</ContextMenu>
</div>

View File

@@ -13,7 +13,7 @@
},
$$restProps.class
)}
on:click|stopPropagation
on:click
>
<slot />
</button>

View File

@@ -11,18 +11,18 @@
}
</script>
<img
{src}
{alt}
class={classNames(
'transition-opacity',
{
'opacity-0': !loaded,
'opacity-100': loaded
},
$$restProps.class
)}
style="object-fit: cover; width: 100%; height: 100%;"
loading="lazy"
on:load={handleLoad}
/>
<div
class={classNames('transition-opacity duration-300', {
'opacity-0': !loaded,
'opacity-100': loaded
})}
>
<img
{src}
{alt}
class={classNames($$restProps.class)}
style="object-fit: cover; width: 100%; height: 100%;"
loading="lazy"
on:load={handleLoad}
/>
</div>

View File

@@ -5,6 +5,7 @@
import ProgressBar from '../ProgressBar.svelte';
import { playerState } from '../VideoPlayer/VideoPlayer';
import LazyImg from '../LazyImg.svelte';
import { Star } from 'radix-icons-svelte';
export let tmdbId: number | undefined = undefined;
export let tvdbId: number | undefined = undefined;
@@ -14,18 +15,24 @@
export let title = '';
export let subtitle = '';
export let rating: number | undefined = undefined;
export let progress = 0;
export let size: 'dynamic' | 'md' = 'md';
export let size: 'dynamic' | 'md' | 'lg' = 'md';
export let orientation: 'portrait' | 'landscape' = 'landscape';
</script>
<a
href={tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#'}
class={classNames(
'relative flex shadow-lg rounded-xl aspect-[2/3] w-44 selectable group hover:text-inherit flex-shrink-0 overflow-hidden',
'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden',
{
'w-44': size === 'md',
'aspect-video': orientation === 'landscape',
'aspect-[2/3]': orientation === 'portrait',
'w-44': size === 'md' && orientation === 'portrait',
'h-44': size === 'md' && orientation === 'landscape',
'w-60': size === 'lg' && orientation === 'portrait',
'h-60': size === 'lg' && orientation === 'landscape',
'w-full': size === 'dynamic'
}
)}
@@ -33,7 +40,7 @@
<LazyImg src={backdropUrl} class="absolute inset-0 group-hover:scale-105 transition-transform" />
<div
class={classNames(
'flex-1 flex flex-col justify-between bg-darken opacity-0 group-hover:opacity-100 transition-opacity z-[1]',
'flex-1 flex flex-col justify-between bg-black bg-opacity-60 opacity-0 group-hover:opacity-100 transition-opacity z-[1]',
{
'py-2 px-3': true
}
@@ -42,7 +49,7 @@
<div class="flex justify-self-start justify-between">
<slot name="top-left">
<div>
<h1 class="font-semibold line-clamp-2">{title}</h1>
<h1 class="text-zinc-100 font-bold line-clamp-2 text-lg">{title}</h1>
<h2 class="text-zinc-300 text-sm font-medium line-clamp-2">{subtitle}</h2>
</div>
</slot>
@@ -52,7 +59,13 @@
</div>
<div class="flex justify-self-end justify-between">
<slot name="bottom-left">
<div />
<div>
{#if rating}
<h2 class="flex items-center gap-1.5 text-sm text-zinc-300 font-medium">
<Star />{rating.toFixed(1)}
</h2>
{/if}
</div>
</slot>
<slot name="bottom-right">
<div />
@@ -75,7 +88,7 @@
{/if}
{#if progress}
<div
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 group-hover:opacity-0 transition-opacity bg-gradient-to-t ease-in-out z-[1]"
class="absolute bottom-2 lg:bottom-3 inset-x-2 lg:inset-x-3 bg-gradient-to-t ease-in-out z-[1]"
>
<ProgressBar {progress} />
</div>

View File

@@ -1,7 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
export let progress = 0;
let mounted = false;
onMount(() => {
mounted = true;
});
</script>
<div class="h-1 bg-zinc-200 bg-opacity-20 rounded-full overflow-hidden">
<div style={'width: ' + progress + '%'} class="h-full bg-zinc-200 bg-opacity-80" />
<div
style={'max-width: ' + (mounted ? progress : 0) + '%'}
class="h-full bg-zinc-200 bg-opacity-80 transition-[max-width] delay-200 duration-500"
/>
</div>

View File

@@ -3,6 +3,8 @@
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
import type { SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import type { TitleType } from '$lib/types';
import { DotsVertical } from 'radix-icons-svelte';
import Button from '../Button.svelte';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
@@ -20,4 +22,7 @@
<svelte:fragment slot="menu">
<LibraryItemContextItems {jellyfinItem} {sonarrSeries} {radarrMovie} {type} {tmdbId} />
</svelte:fragment>
<Button slim>
<DotsVertical size={24} />
</Button>
</ContextMenuButton>

View File

@@ -6,7 +6,6 @@
import { modalStack } from '../../stores/modal.store';
export let tmdbId: number;
export let title: string = '';
export let type: TitleType;
export let modalId: symbol;
@@ -26,7 +25,7 @@
{#if type === 'movie'}
<MoviePage {tmdbId} isModal={true} {handleCloseModal} />
{:else}
<SeriesPage {tmdbId} {title} isModal={true} {handleCloseModal} />
<SeriesPage {tmdbId} isModal={true} {handleCloseModal} />
{/if}
</div>
</div>

View File

@@ -123,7 +123,7 @@
out:fade|global={{ duration: ANIMATION_DURATION }}
>
<div class="flex gap-4 items-center">
<Button size="lg" type="primary" on:click={() => openTitleModal(tmdbId, type, title)}>
<Button size="lg" type="primary" on:click={() => openTitleModal(tmdbId, type)}>
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
</Button>
{#if trailerId}

View File

@@ -34,6 +34,7 @@
import Slider from './Slider.svelte';
import { playerState } from './VideoPlayer';
import { linear } from 'svelte/easing';
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
export let modalId: symbol;
@@ -366,8 +367,8 @@
'cursor-none': !uiVisible
}
)}
in:fade|global={{ duration: 500, easing: linear }}
out:fade|global={{ duration: 300, easing: linear }}
in:fade|global={{ duration: 300, easing: linear }}
out:fade|global={{ duration: 200, easing: linear }}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
@@ -375,8 +376,6 @@
bind:this={videoWrapper}
on:mousemove={() => handleUserInteraction(false)}
on:touchend|preventDefault={() => handleUserInteraction(true)}
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
on:click={() => (paused = !paused)}
in:fade|global={{ duration: 500, delay: 1200, easing: linear }}
>
<!-- svelte-ignore a11y-media-has-caption -->
@@ -398,6 +397,8 @@
bind:muted={mute}
class="sm:w-full sm:h-full"
playsinline={true}
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
on:click={() => (paused = !paused)}
/>
{#if uiVisible}
@@ -436,29 +437,22 @@
</IconButton>
<div class="flex items-center space-x-3">
<div class="relative">
<ContextMenu
heading="Quality"
position="absolute"
bottom={true}
id={qualityContextMenuId}
>
<svelte:fragment slot="menu">
{#each getQualities(resolution) as quality}
<SelectableContextMenuItem
selected={quality.maxBitrate === currentBitrate}
on:click={() => handleSelectQuality(quality.maxBitrate)}
>
{quality.name}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton on:click={handleQualityToggleVisibility}>
<Gear size={20} />
</IconButton>
</ContextMenu>
</div>
<ContextMenuButton heading="Quality">
<svelte:fragment slot="menu">
{#each getQualities(resolution) as quality}
<SelectableContextMenuItem
selected={quality.maxBitrate === currentBitrate}
on:click={() => handleSelectQuality(quality.maxBitrate)}
>
{quality.name}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton>
<Gear size={20} />
</IconButton>
</ContextMenuButton>
<IconButton
on:click={() => {
mute = !mute;

View File

@@ -21,7 +21,7 @@
"upcomingSeries": "Upcoming Series",
"genres": "Genres",
"newDigitalReleases": "New Digital Releases",
"streamingNow": "On Streaming Now",
"streamingNow": "Streaming Now",
"TVNetworks": "TV Networks"
},
"library": {

View File

@@ -7,7 +7,8 @@ import {
import {
getSonarrDownloads,
getSonarrSeries,
type SonarrDownload
type SonarrDownload,
type SonarrSeries
} from '$lib/apis/sonarr/sonarrApi';
import { derived, writable } from 'svelte/store';
import { settings } from './settings.store';
@@ -101,20 +102,27 @@ export function createRadarrMovieStore(tmdbId: number) {
};
}
export function createSonarrSeriesStore(name: string) {
export function createSonarrSeriesStore(name: Promise<string> | string) {
function shorten(str: string) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
}
const store = derived(sonarrSeriesStore, (s) => {
return {
const store = writable<{ loading: boolean; item?: SonarrSeries }>({
loading: true,
item: undefined
});
sonarrSeriesStore.subscribe(async (s) => {
const awaited = await name;
store.set({
loading: s.loading,
item: s.data?.find(
(i) =>
shorten(i.titleSlug || '') === shorten(name) ||
i.alternateTitles?.find((t) => shorten(t.title || '') === shorten(name))
shorten(i.titleSlug || '') === shorten(awaited) ||
i.alternateTitles?.find((t) => shorten(t.title || '') === shorten(awaited))
)
};
});
});
return {

View File

@@ -1,15 +1,15 @@
import { writable } from 'svelte/store';
export function createLocalStorageStore<T>(key: string) {
const store = writable<T | null>(JSON.parse(localStorage.getItem(key) || 'null') || null);
export function createLocalStorageStore<T>(key: string, defaultValue: T) {
const store = writable<T>(JSON.parse(localStorage.getItem(key) || 'null') || defaultValue);
return {
subscribe: store.subscribe,
set: (value: T | null) => {
set: (value: T) => {
localStorage.setItem(key, JSON.stringify(value));
store.set(value);
}
};
}
export const skippedVersion = createLocalStorageStore('skipped-version');
export const skippedVersion = createLocalStorageStore<string | null>('skipped-version', null);

View File

@@ -61,9 +61,9 @@ function createDynamicModalStack() {
export const modalStack = createDynamicModalStack();
let lastTitleModal: symbol | undefined = undefined;
export function openTitleModal(tmdbId: number, type: TitleType, title = '') {
export function openTitleModal(tmdbId: number, type: TitleType) {
if (lastTitleModal) {
modalStack.close(lastTitleModal);
}
lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type, title });
lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type });
}

View File

@@ -1,24 +1,22 @@
<script lang="ts">
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { TmdbApiOpen } from '$lib/apis/tmdb/tmdbApi';
import { TmdbApiOpen, getTmdbItemBackdrop, getTmdbMovieBackdrop } from '$lib/apis/tmdb/tmdbApi';
import Card from '$lib/components/Card/Card.svelte';
import { fetchCardTmdbProps } from '$lib/components/Card/card';
import Carousel from '$lib/components/Carousel/Carousel.svelte';
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
import GenreCard from '$lib/components/GenreCard.svelte';
import NetworkCard from '$lib/components/NetworkCard.svelte';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { genres, networks } from '$lib/discover';
import { jellyfinItemsStore } from '$lib/stores/data.store';
import { settings } from '$lib/stores/settings.store';
import { formatDateToYearMonthDay } from '$lib/utils';
import type { ComponentProps } from 'svelte';
import { _ } from 'svelte-i18n';
import { fade } from 'svelte/transition';
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
jellyfinItemsStore.subscribe((data) => {
if (data.loading) return;
@@ -26,17 +24,29 @@
});
});
const fetchCardProps = async (items: { id?: number }[]) => {
const i = $settings.discover.excludeLibraryItems
const fetchCardProps = async (
items: { name?: string; title?: string; id?: number; vote_average?: number }[]
): Promise<ComponentProps<Poster>[]> => {
const filtered = $settings.discover.excludeLibraryItems
? items.filter(
async (item) =>
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
)
: items;
return Promise.all(i.map(fetchCardTmdbProps)).then((props) =>
props.filter((p) => p.backdropUrl)
);
return Promise.all(
filtered.map(async (item) => {
const backdropUri = await getTmdbMovieBackdrop(item.id || 0);
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 : ''
} as const;
})
).then((props) => props.filter((p) => p.backdropUrl));
};
const fetchTrendingProps = () =>
@@ -132,6 +142,10 @@
})
.then((res) => res.data?.results || [])
.then(fetchCardProps);
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
</script>
<div
@@ -152,7 +166,7 @@
<CarouselPlaceholderItems size="lg" />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card size="lg" {...prop} />
<Poster size="lg" {...prop} />
{/each}
{/await}
</Carousel>
@@ -160,7 +174,7 @@
</div>
<div
class="flex flex-col gap-8 max-w-screen-2xl mx-auto py-4"
class="flex flex-col gap-12 max-w-screen-2xl mx-auto py-4"
in:fade|global={{
duration: $settings.animationDuration,
delay: $settings.animationDuration
@@ -181,7 +195,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>
@@ -190,7 +204,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>
@@ -204,7 +218,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>
@@ -213,7 +227,7 @@
<CarouselPlaceholderItems />
{:then props}
{#each props as prop (prop.tmdbId)}
<Card {...prop} />
<Poster {...prop} />
{/each}
{/await}
</Carousel>

View File

@@ -44,7 +44,8 @@
capitalize(item.status || ''),
type: 'series',
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
backdropUrl: item.series.images?.find((i) => i.coverType === 'poster')?.url || ''
backdropUrl: item.series.images?.find((i) => i.coverType === 'poster')?.url || '',
orientation: 'portrait'
})) || [];
const radarrProps: ComponentProps<Poster>[] =
@@ -54,7 +55,8 @@
subtitle: capitalize(item.status || ''),
type: 'movie',
backdropUrl: item.movie.images?.find((i) => i.coverType === 'poster')?.url || '',
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1))
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
orientation: 'portrait'
})) || [];
downloadProps = [...(sonarrProps || []), ...(radarrProps || [])];
@@ -90,7 +92,7 @@
class="bg-center bg-cover col-start-1 row-start-1 col-span-2 row-span-3 relative pt-24"
>
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-80% to-darken" />
<div class="max-w-screen-2xl mx-auto relative z-[1] px-2 md:px-8 pt-[10%] pb-8">
<div class="max-w-screen-2xl mx-auto relative z-[1] px-2 md:px-8 pt-56 pb-12">
<div class="flex gap-4 items-end">
<div
style={"background-image: url('" +
@@ -108,7 +110,7 @@
type="primary"
on:click={() => showcase?.Id && playerState.streamJellyfinId(showcase?.Id)}
>
Watch<ChevronRight size={20} />
Play<ChevronRight size={20} />
</Button>
<Button
href={`/${showcase?.Type === 'Movie' ? 'movie' : 'series'}/${
@@ -133,7 +135,7 @@
}}
out:fade|global={{ duration: $settings.animationDuration }}
>
<div class="max-w-screen-2xl m-auto flex flex-col gap-4">
<div class="max-w-screen-2xl m-auto flex flex-col gap-12">
{#if downloadProps?.length}
<div>
<Carousel heading="Downloading">

View File

@@ -5,7 +5,7 @@
import classNames from 'classnames';
import { fade } from 'svelte/transition';
import { _ } from 'svelte-i18n';
import { CaretDown, Gear } from 'radix-icons-svelte';
import { CaretDown, ChevronDown, Gear } from 'radix-icons-svelte';
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
import type { ComponentProps } from 'svelte';
import Poster from '$lib/components/Poster/Poster.svelte';
@@ -14,11 +14,21 @@
import { getSonarrPosterUrl, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import { jellyfinItemsStore, radarrMoviesStore, sonarrSeriesStore } from '$lib/stores/data.store';
import Button from '$lib/components/Button.svelte';
import ContextMenu from '$lib/components/ContextMenu/ContextMenu.svelte';
import SelectableContextMenuItem from '$lib/components/ContextMenu/SelectableContextMenuItem.svelte';
import ContextMenuDivider from '$lib/components/ContextMenu/ContextMenuDivider.svelte';
import { createLocalStorageStore } from '$lib/stores/localstorage.store';
type SortBy = 'Date Added' | 'Rating' | 'Relase Date' | 'Size' | 'Name';
type SortOrder = 'Ascending' | 'Descending';
const PAGE_SIZE = 100;
const SORT_OPTIONS = ['Date Added', 'Rating', 'Relase Date', 'Size', 'Name'] as const;
const SORT_ORDER = ['Ascending', 'Descending'] as const;
let itemsVisible: 'all' | 'movies' | 'shows' = 'all';
let sortBy: 'added' | 'rating' | 'release' | 'size' | 'name' = 'name';
const sortBy = createLocalStorageStore<SortBy>('library-sort-by', 'Date Added');
const sortOrder = createLocalStorageStore<SortOrder>('library-sort-order', 'Descending');
let searchQuery = '';
let openTab: 'available' | 'watched' | 'unavailable' = 'available';
@@ -27,14 +37,12 @@
let searchInput: HTMLInputElement | undefined;
let posterPropsPromise: Promise<ComponentProps<Poster>[]> = Promise.resolve([]);
$: posterPropsPromise = getComponentProps(openTab, page, sortBy, searchQuery);
let libraryLoading = true;
let posterProps: ComponentProps<Poster>[] = [];
let hasMore = true;
$: loadPosterProps(openTab, page, $sortBy, $sortOrder, searchQuery);
function getPropsFromJellyfinItem(
item: JellyfinItem,
sort: typeof sortBy,
searchQuery: string
): ComponentProps<Poster> {
function getPropsFromJellyfinItem(item: JellyfinItem): ComponentProps<Poster> {
return {
tmdbId: Number(item.ProviderIds?.Tmdb) || 0,
jellyfinId: item.Id,
@@ -42,17 +50,14 @@
subtitle: item.Genres?.join(', ') || undefined,
backdropUrl: getJellyfinPosterUrl(item, 80),
size: 'dynamic',
...(item.Type === 'Movie' ? { type: 'movie' } : { type: 'series' })
...(item.Type === 'Movie' ? { type: 'movie' } : { type: 'series' }),
orientation: 'portrait',
rating: item.CommunityRating || undefined
};
}
function getPropsfromServarrItem(
item: RadarrMovie & SonarrSeries,
sort: typeof sortBy,
searchQuery: string
): ComponentProps<Poster> {
console.log(item);
if (item.tmdbId) {
function getPropsfromServarrItem(item: RadarrMovie | SonarrSeries): ComponentProps<Poster> {
if ((<any>item)?.tmdbId) {
const movie = item as RadarrMovie;
return {
@@ -61,7 +66,9 @@
subtitle: movie.genres?.join(', ') || undefined,
backdropUrl: getRadarrPosterUrl(movie),
size: 'dynamic',
type: 'movie'
type: 'movie',
orientation: 'portrait',
rating: movie.ratings?.tmdb?.value || undefined
};
} else {
const series = item as SonarrSeries;
@@ -73,33 +80,62 @@
backdropUrl: getSonarrPosterUrl(series),
size: 'dynamic',
type: 'series',
tmdbId: undefined
tmdbId: undefined,
orientation: 'portrait',
rating: series.ratings?.value || undefined
};
}
}
async function getComponentProps(
async function loadPosterProps(
tab: typeof openTab,
page: number,
sort: typeof sortBy,
sort: SortBy,
order: SortOrder,
searchQuery: string
): Promise<ComponentProps<Poster>[]> {
const jellyfinItemsPromise = jellyfinItemsStore.promise.then((i) => i || []);
) {
if (page === 0) posterProps = [];
const jellyfinItemsPromise = jellyfinItemsStore.promise
.then((i) => i || [])
.then((i) => {
const sorted = i.sort((a, b) => {
if (sort === 'Date Added') {
return new Date(b.DateCreated || 0).getTime() - new Date(a.DateCreated || 0).getTime();
} else if (sort === 'Rating') {
return (b.CommunityRating || 0) - (a.CommunityRating || 0);
} else if (sort === 'Relase Date') {
return (
new Date(b.PremiereDate || 0).getTime() - new Date(a.PremiereDate || 0).getTime()
);
} else if (sort === 'Size') {
return (b.RunTimeTicks || 0) - (a.RunTimeTicks || 0);
} else if (sort === 'Name') {
return (b.Name || '').localeCompare(a.Name || '');
} else {
return 0;
}
});
if (order === 'Ascending') {
return sorted.reverse();
} else {
return sorted;
}
});
let props: ComponentProps<Poster>[] = [];
if (tab === 'available') {
return jellyfinItemsPromise.then((items) =>
items
.filter((i) => !i.UserData?.Played)
.map((item) => getPropsFromJellyfinItem(item, sort, searchQuery))
props = await jellyfinItemsPromise.then((items) =>
items.filter((i) => !i.UserData?.Played).map((item) => getPropsFromJellyfinItem(item))
);
} else if (tab === 'watched') {
return jellyfinItemsPromise.then((items) =>
items
.filter((i) => i.UserData?.Played)
.map((item) => getPropsFromJellyfinItem(item, sort, searchQuery))
props = await jellyfinItemsPromise.then((items) =>
items.filter((i) => i.UserData?.Played).map((item) => getPropsFromJellyfinItem(item))
);
} else if (tab === 'unavailable') {
return Promise.all([
props = await Promise.all([
radarrMoviesStore.promise,
sonarrSeriesStore.promise,
jellyfinItemsPromise
@@ -118,11 +154,19 @@
(i) =>
!jellyfinItems.find((j) => j.ProviderIds?.Tvdb === String((<any>i).tvdbId || '-'))
)
.map((i) => getPropsfromServarrItem(i, sort, searchQuery))
.map((i) => getPropsfromServarrItem(i))
);
}
return [];
const toAdd = props.slice(PAGE_SIZE * page, PAGE_SIZE * (page + 1));
hasMore = toAdd.length === PAGE_SIZE;
libraryLoading = false;
posterProps = [...posterProps, ...toAdd];
}
function handleTabChange(tab: typeof openTab) {
openTab = tab;
page = 0;
}
function handleShortcuts(event: KeyboardEvent) {
@@ -135,82 +179,101 @@
<svelte:window on:keydown={handleShortcuts} />
<div class="flex items-center justify-between gap-2">
<UiCarousel>
<div class="flex gap-6 text-lg font-medium text-zinc-400">
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'available'
})}
on:click={() => {
openTab = 'available';
page = 0;
}}
>
{$_('library.available')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'watched'
})}
on:click={() => {
openTab = 'watched';
page = 0;
}}
>
{$_('library.watched')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'unavailable'
})}
on:click={() => {
openTab = 'unavailable';
page = 0;
}}
>
{$_('library.unavailable')}
</button>
</div>
</UiCarousel>
<div class="flex items-center gap-3 justify-end flex-shrink-0 flex-initial relative">
<IconButton>
<div class="flex gap-0.5 items-center text-sm">
<span>
{$_('library.sort.byTitle')}
</span>
<CaretDown size={20} />
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-2">
<UiCarousel>
<div class="flex gap-6 text-lg font-medium text-zinc-400">
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'available'
})}
on:click={() => handleTabChange('available')}
>
{$_('library.available')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'watched'
})}
on:click={() => handleTabChange('watched')}
>
{$_('library.watched')}
</button>
<button
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
'text-zinc-200': openTab === 'unavailable'
})}
on:click={() => handleTabChange('unavailable')}
>
{$_('library.unavailable')}
</button>
</div>
</IconButton>
<IconButton>
<Gear size={20} />
</IconButton>
</UiCarousel>
<div class="flex items-center gap-3 justify-end flex-shrink-0 flex-initial relative">
<ContextMenu heading="Sort By" position="absolute">
<svelte:fragment slot="menu">
{#each SORT_OPTIONS as sortOption}
<SelectableContextMenuItem
selected={$sortBy === sortOption}
on:click={() => {
sortBy.set(sortOption);
page = 0;
}}
>
{sortOption}
</SelectableContextMenuItem>
{/each}
<ContextMenuDivider />
{#each SORT_ORDER as order}
<SelectableContextMenuItem
selected={$sortOrder === order}
on:click={() => {
sortOrder.set(order);
page = 0;
}}
>
{order}
</SelectableContextMenuItem>
{/each}
</svelte:fragment>
<IconButton>
<div class="flex gap-1 items-center">
{$sortBy}
<ChevronDown size={20} />
</div>
</IconButton>
</ContextMenu>
<IconButton>
<Gear size={20} />
</IconButton>
</div>
</div>
</div>
<div
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7"
>
{#await posterPropsPromise}
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder size="dynamic" {index} />
{/each}
{:then props}
{#each props as prop}
<Poster {...prop} />
<div
class="grid gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7"
>
{#if libraryLoading}
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder orientation="portrait" size="dynamic" {index} />
{/each}
{:else}
<div class="flex-1 flex items-center text-zinc-500">No items.</div>
{/each}
{:catch error}
<p>{error.message}</p>
{/await}
</div>
{#await posterPropsPromise then props}
<div class="mx-auto my-4">
<Button
on:click={() => (page = page + 1)}
disabled={PAGE_SIZE + page * PAGE_SIZE >= props?.length}>Load More</Button
>
{#each posterProps.slice(0, PAGE_SIZE + page * PAGE_SIZE) as prop}
<Poster {...prop} />
{:else}
<div class="flex-1 flex font-medium text-zinc-500 col-span-full mb-64">
{openTab === 'available'
? 'Your Jellyfin library items will appear here.'
: openTab === 'watched'
? 'Your watched Jellyfin items will appear here.'
: "Your Radarr and Sonarr items that aren't available will appear here."}
</div>
{/each}
{/if}
</div>
{/await}
{#if !libraryLoading && posterProps.length > 0}
<div class="mx-auto my-4">
<Button on:click={() => (page = page + 1)} disabled={!hasMore}>Load More</Button>
</div>
{/if}
</div>

View File

@@ -131,7 +131,7 @@
<OpenInButton title={movie?.title} {jellyfinItem} {radarrMovie} type="movie" {tmdbId} />
{#if jellyfinItem}
<Button type="primary" on:click={play}>
<span>Watch</span><ChevronRight size={20} />
<span>Play</span><ChevronRight size={20} />
</Button>
{:else if !radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey}
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>

View File

@@ -6,10 +6,8 @@
let tmdbId: number;
$: tmdbId = Number(data.tmdbId);
let name: string;
$: name = data.name || '';
</script>
{#key tmdbId}
<SeriesPage {tmdbId} title={name} />
<SeriesPage {tmdbId} />
{/key}

View File

@@ -1,25 +1,7 @@
import { getTmdbSeries, getTmdbSeriesFromTvdbId } from '$lib/apis/tmdb/tmdbApi';
import { sonarrSeriesStore } from '$lib/stores/data.store';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
const sonarrItem = await sonarrSeriesStore.promise.then((series) =>
series.find((s) => s.tvdbId === Number(params.id))
);
if (sonarrItem) {
const tmdbSeries = await getTmdbSeriesFromTvdbId(params.id);
return {
tmdbId: tmdbSeries?.id,
name: tmdbSeries?.name
};
} else {
const tmdbSeries = await getTmdbSeries(Number(params.id));
return {
tmdbId: tmdbSeries?.id,
name: tmdbSeries?.name
};
}
return {
tmdbId: Number(params.id)
};
}) satisfies PageLoad;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
import {
getTmdbSeries,
getTmdbSeriesRecommendations,
@@ -33,7 +33,6 @@
import type { ComponentProps } from 'svelte';
export let tmdbId: number;
export let title: string;
export let isModal = false;
export let handleCloseModal: () => void = () => {};
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
@@ -44,12 +43,14 @@
);
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
const sonarrSeriesStore = createSonarrSeriesStore(title);
const sonarrSeriesStore = createSonarrSeriesStore(tmdbSeriesPromise.then((s) => s?.name || ''));
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
let sonarrSeries = $sonarrSeriesStore.item;
let sonarrSeries: undefined | SonarrSeries = undefined;
let jellyfinItem = $jellyfinItemStore.item;
sonarrSeriesStore.subscribe((s) => (sonarrSeries = s.item));
let seasonSelectVisible = false;
let visibleSeasonNumber: number | undefined = undefined;
let visibleEpisodeIndex: number | undefined = undefined;
@@ -192,7 +193,7 @@
{#if !!nextJellyfinEpisode}
<Button type="primary" on:click={playNextEpisode}>
<span>
Watch {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
Play {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
</span>
<ChevronRight size={20} />
</Button>