Merge branch 'dev'
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,4 +10,5 @@ node_modules
|
||||
.output
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
/config/*.sqlite
|
||||
/config/*.sqlite
|
||||
/dist
|
||||
22
build.config.json
Normal file
22
build.config.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"appId": "me.aleksilassila.reiverr",
|
||||
"productName": "Reiverr",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"mac": {
|
||||
"asar": false
|
||||
},
|
||||
"win": {
|
||||
"asar": false,
|
||||
"target": "msi"
|
||||
},
|
||||
"asar": false,
|
||||
"files": [
|
||||
"src/electron.cjs",
|
||||
{
|
||||
"from": "build",
|
||||
"to": "build"
|
||||
}
|
||||
]
|
||||
}
|
||||
1994
package-lock.json
generated
1994
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "reiverr",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aleksilassila/reiverr"
|
||||
},
|
||||
"main": "src/electron.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "PORT=9494 NODE_ENV=production node build/",
|
||||
"deploy:electron": "vite build && electron-builder -mw --x64 --config build.config.json; electron-builder -m --arm64 --config build.config.json",
|
||||
"test": "playwright test",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -31,6 +33,8 @@
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"classnames": "^2.3.2",
|
||||
"electron": "^26.1.0",
|
||||
"electron-builder": "^24.6.3",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte": "^2.26.0",
|
||||
|
||||
@@ -14,6 +14,10 @@ a {
|
||||
@apply bg-zinc-700 bg-opacity-75;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
@apply bg-zinc-700 bg-opacity-40 animate-pulse text-transparent rounded-lg select-none;
|
||||
}
|
||||
|
||||
.selectable {
|
||||
@apply focus-visible:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
|
||||
}
|
||||
|
||||
51
src/electron.cjs
Normal file
51
src/electron.cjs
Normal file
@@ -0,0 +1,51 @@
|
||||
process.env.PORT = '9494';
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
|
||||
(async () => {
|
||||
await import('../build/index.js');
|
||||
// const serveURL = serve({ directory: '.' });
|
||||
const port = process.env.PORT || 5173;
|
||||
let mainWindow;
|
||||
|
||||
const createWindow = () => {
|
||||
if (!mainWindow) {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mainWindow.once('close', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
loadSite(port);
|
||||
|
||||
return mainWindow;
|
||||
};
|
||||
|
||||
function loadSite(port) {
|
||||
mainWindow.loadURL(`http://localhost:${port}`).catch((e) => {
|
||||
console.log('Error loading URL, retrying', e);
|
||||
setTimeout(() => {
|
||||
loadSite(port);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
app.on('activate', () => {
|
||||
if (!mainWindow) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
})();
|
||||
@@ -33,7 +33,7 @@ export const getJellyfinContinueWatching = async (): Promise<JellyfinItem[] | un
|
||||
},
|
||||
query: {
|
||||
mediaTypes: ['Video'],
|
||||
fields: ['ProviderIds']
|
||||
fields: ['ProviderIds', 'Genres']
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -45,7 +45,7 @@ export const getJellyfinNextUp = async () =>
|
||||
params: {
|
||||
query: {
|
||||
userId: get(settings)?.jellyfin.userId || '',
|
||||
fields: ['ProviderIds']
|
||||
fields: ['ProviderIds', 'Genres']
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -62,11 +62,11 @@ export const getJellyfinItems = async () =>
|
||||
hasTmdbId: true,
|
||||
recursive: true,
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
fields: ['ProviderIds']
|
||||
fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated']
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((r) => r.data?.Items || []);
|
||||
.then((r) => r.data?.Items || []) || Promise.resolve([]);
|
||||
|
||||
// export const getJellyfinSeries = () =>
|
||||
// JellyfinApi.get('/Users/{userId}/Items', {
|
||||
@@ -82,7 +82,7 @@ export const getJellyfinItems = async () =>
|
||||
// }
|
||||
// }).then((r) => r.data?.Items || []);
|
||||
|
||||
export const getJellyfinEpisodes = async () =>
|
||||
export const getJellyfinEpisodes = async (parentId = '') =>
|
||||
getJellyfinApi()
|
||||
?.get('/Users/{userId}/Items', {
|
||||
params: {
|
||||
@@ -91,7 +91,8 @@ export const getJellyfinEpisodes = async () =>
|
||||
},
|
||||
query: {
|
||||
recursive: true,
|
||||
includeItemTypes: ['Episode']
|
||||
includeItemTypes: ['Episode'],
|
||||
parentId
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
@@ -100,6 +101,23 @@ export const getJellyfinEpisodes = async () =>
|
||||
})
|
||||
.then((r) => r.data?.Items || []);
|
||||
|
||||
export const getJellyfinEpisodesInSeasons = async (seriesId: string) =>
|
||||
getJellyfinEpisodes(seriesId).then((items) => {
|
||||
const seasons: Record<string, JellyfinItem[]> = {};
|
||||
|
||||
items?.forEach((item) => {
|
||||
const seasonNumber = item.ParentIndexNumber || 0;
|
||||
|
||||
if (!seasons[seasonNumber]) {
|
||||
seasons[seasonNumber] = [];
|
||||
}
|
||||
|
||||
seasons[seasonNumber].push(item);
|
||||
});
|
||||
|
||||
return seasons;
|
||||
});
|
||||
|
||||
// export const getJellyfinEpisodesBySeries = (seriesId: string) =>
|
||||
// getJellyfinEpisodes().then((items) => items?.filter((i) => i.SeriesId === seriesId) || []);
|
||||
|
||||
@@ -266,3 +284,22 @@ export const getJellyfinUsers = async (
|
||||
})
|
||||
.then((res) => res.data || [])
|
||||
.catch(() => []);
|
||||
|
||||
export const getJellyfinPosterUrl = (item: JellyfinItem, quality = 100, original = false) =>
|
||||
item.ImageTags?.Primary
|
||||
? `${get(settings).jellyfin.baseUrl}/Items/${item?.Id}/Images/Primary?quality=${quality}${
|
||||
original ? '' : '&fillWidth=432'
|
||||
}&tag=${item?.ImageTags?.Primary}`
|
||||
: '';
|
||||
|
||||
export const getJellyfinBackdrop = (item: JellyfinItem, quality = 100) => {
|
||||
if (item.BackdropImageTags?.length) {
|
||||
return `${get(settings).jellyfin.baseUrl}/Items/${
|
||||
item?.Id
|
||||
}/Images/Backdrop?quality=${quality}&tag=${item?.BackdropImageTags?.[0]}`;
|
||||
} else {
|
||||
return `${get(settings).jellyfin.baseUrl}/Items/${
|
||||
item?.Id
|
||||
}/Images/Primary?quality=${quality}&tag=${item?.ImageTags?.Primary}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,3 +222,12 @@ export const getRadarrQualityProfiles = async (
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
export function getRadarrPosterUrl(item: RadarrMovie, original = false) {
|
||||
const url =
|
||||
get(settings).radarr.baseUrl + (item.images?.find((i) => i.coverType === 'poster')?.url || '');
|
||||
|
||||
if (!original) return url.replace('poster.jpg', `poster-${500}.jpg`);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -306,3 +306,12 @@ export const getSonarrLanguageProfiles = async (
|
||||
}
|
||||
)
|
||||
.then((res) => res.data || []);
|
||||
|
||||
export function getSonarrPosterUrl(item: SonarrSeries, original = false) {
|
||||
const url =
|
||||
get(settings).sonarr.baseUrl + (item.images?.find((i) => i.coverType === 'poster')?.url || '');
|
||||
|
||||
if (!original) return url.replace('poster.jpg', `poster-${500}.jpg`);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { TMDB_API_KEY } from '$lib/constants';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { formatDateToYearMonthDay } from '$lib/utils';
|
||||
import createClient from 'openapi-fetch';
|
||||
import { get } from 'svelte/store';
|
||||
import type { operations, paths } from './tmdb.generated';
|
||||
@@ -75,11 +74,11 @@ export const getTmdbMovie = async (tmdbId: number) =>
|
||||
}
|
||||
}).then((res) => res.data as TmdbMovieFull2 | undefined);
|
||||
|
||||
export const getTmdbSeriesFromTvdbId = async (tvdbId: number) =>
|
||||
export const getTmdbSeriesFromTvdbId = async (tvdbId: string) =>
|
||||
TmdbApiOpen.get('/3/find/{external_id}', {
|
||||
params: {
|
||||
path: {
|
||||
external_id: String(tvdbId)
|
||||
external_id: tvdbId
|
||||
},
|
||||
query: {
|
||||
external_source: 'tvdb_id'
|
||||
@@ -91,7 +90,11 @@ export const getTmdbSeriesFromTvdbId = async (tvdbId: number) =>
|
||||
}).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) => {
|
||||
const id = res?.id as number | undefined;
|
||||
if (!id) return Promise.reject();
|
||||
return id;
|
||||
});
|
||||
|
||||
export const getTmdbSeries = async (tmdbId: number): Promise<TmdbSeriesFull2 | undefined> =>
|
||||
await TmdbApiOpen.get('/3/tv/{series_id}', {
|
||||
@@ -280,6 +283,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,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
|
||||
import { createLibraryItemStore } from '$lib/stores/library.store';
|
||||
import {
|
||||
createJellyfinItemStore,
|
||||
createRadarrMovieStore,
|
||||
createSonarrSeriesStore
|
||||
} from '$lib/stores/data.store';
|
||||
import type { TitleType } from '$lib/types';
|
||||
import { formatMinutesToTime } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { Clock, Star } from 'radix-icons-svelte';
|
||||
import { openTitleModal } from '../../stores/modal.store';
|
||||
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
|
||||
import LibraryItemContextItems from '../ContextMenu/LibraryItemContextItems.svelte';
|
||||
import { openTitleModal } from '../../stores/modal.store';
|
||||
import ProgressBar from '../ProgressBar.svelte';
|
||||
|
||||
export let tmdbId: number;
|
||||
@@ -25,12 +28,20 @@
|
||||
export let size: 'dynamic' | 'md' | 'lg' = 'md';
|
||||
export let openInModal = true;
|
||||
|
||||
let itemStore = createLibraryItemStore(tmdbId);
|
||||
let jellyfinItemStore = createJellyfinItemStore(tmdbId);
|
||||
let radarrMovieStore = createRadarrMovieStore(tmdbId);
|
||||
let sonarrSeriesStore = createSonarrSeriesStore(title);
|
||||
</script>
|
||||
|
||||
<ContextMenu heading={title}>
|
||||
<svelte:fragment slot="menu">
|
||||
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
|
||||
<LibraryItemContextItems
|
||||
jellyfinItem={$jellyfinItemStore.item}
|
||||
radarrMovie={$radarrMovieStore.item}
|
||||
sonarrSeries={$sonarrSeriesStore.item}
|
||||
{type}
|
||||
{tmdbId}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
<button
|
||||
class={classNames(
|
||||
@@ -44,7 +55,7 @@
|
||||
)}
|
||||
on:click={() => {
|
||||
if (openInModal) {
|
||||
openTitleModal(tmdbId, type);
|
||||
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
|
||||
} else {
|
||||
window.location.href = `/${type}/${tmdbId}`;
|
||||
}
|
||||
|
||||
@@ -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;'}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,81 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { library, type LibraryItemStore } from '$lib/stores/library.store';
|
||||
import {
|
||||
setJellyfinItemUnwatched,
|
||||
setJellyfinItemWatched,
|
||||
type JellyfinItem
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
|
||||
import type { SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
|
||||
import { jellyfinItemsStore } from '$lib/stores/data.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import type { TitleType } from '$lib/types';
|
||||
import ContextMenuDivider from './ContextMenuDivider.svelte';
|
||||
import ContextMenuItem from './ContextMenuItem.svelte';
|
||||
|
||||
export let itemStore: LibraryItemStore;
|
||||
export let jellyfinItem: JellyfinItem | undefined = undefined;
|
||||
export let sonarrSeries: SonarrSeries | undefined = undefined;
|
||||
export let radarrMovie: RadarrMovie | undefined = undefined;
|
||||
|
||||
export let type: TitleType;
|
||||
export let tmdbId: number;
|
||||
|
||||
let watched = false;
|
||||
itemStore.subscribe((i) => {
|
||||
if (i.item?.jellyfinItem) {
|
||||
watched =
|
||||
i.item.jellyfinItem.UserData?.Played !== undefined
|
||||
? i.item.jellyfinItem.UserData?.Played
|
||||
: watched;
|
||||
}
|
||||
});
|
||||
$: watched = jellyfinItem?.UserData?.Played !== undefined ? jellyfinItem.UserData?.Played : false;
|
||||
|
||||
function handleSetWatched() {
|
||||
if ($itemStore.item?.jellyfinId) {
|
||||
if (jellyfinItem?.Id) {
|
||||
watched = true;
|
||||
setJellyfinItemWatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
|
||||
setJellyfinItemWatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetUnwatched() {
|
||||
if ($itemStore.item?.jellyfinId) {
|
||||
if (jellyfinItem?.Id) {
|
||||
watched = false;
|
||||
setJellyfinItemUnwatched($itemStore.item?.jellyfinId).finally(() => library.refreshIn(3000));
|
||||
setJellyfinItemUnwatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenInJellyfin() {
|
||||
window.open(
|
||||
$settings.jellyfin.baseUrl +
|
||||
'/web/index.html#!/details?id=' +
|
||||
$itemStore.item?.jellyfinItem?.Id
|
||||
);
|
||||
window.open($settings.jellyfin.baseUrl + '/web/index.html#!/details?id=' + jellyfinItem?.Id);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $itemStore.item}
|
||||
<ContextMenuItem on:click={handleSetWatched} disabled={!$itemStore.item?.jellyfinId || watched}>
|
||||
Mark as watched
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem on:click={handleSetWatched} disabled={!jellyfinItem?.Id || watched}>
|
||||
Mark as watched
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem on:click={handleSetUnwatched} disabled={!jellyfinItem?.Id || !watched}>
|
||||
Mark as unwatched
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem disabled={!jellyfinItem?.Id} on:click={handleOpenInJellyfin}>
|
||||
Open in Jellyfin
|
||||
</ContextMenuItem>
|
||||
{#if type === 'movie'}
|
||||
<ContextMenuItem
|
||||
on:click={handleSetUnwatched}
|
||||
disabled={!$itemStore.item?.jellyfinId || !watched}
|
||||
disabled={!radarrMovie}
|
||||
on:click={() => window.open($settings.radarr.baseUrl + '/movie/' + radarrMovie?.tmdbId)}
|
||||
>
|
||||
Mark as unwatched
|
||||
Open in Radarr
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem disabled={!$itemStore.item.jellyfinItem} on:click={handleOpenInJellyfin}>
|
||||
Open in Jellyfin
|
||||
{:else}
|
||||
<ContextMenuItem
|
||||
disabled={!sonarrSeries}
|
||||
on:click={() => window.open($settings.sonarr.baseUrl + '/series/' + sonarrSeries?.titleSlug)}
|
||||
>
|
||||
Open in Sonarr
|
||||
</ContextMenuItem>
|
||||
{#if $itemStore.item.type === 'movie'}
|
||||
<ContextMenuItem
|
||||
disabled={!$itemStore.item.radarrMovie}
|
||||
on:click={() =>
|
||||
window.open($settings.radarr.baseUrl + '/movie/' + $itemStore.item?.radarrMovie?.tmdbId)}
|
||||
>
|
||||
Open in Radarr
|
||||
</ContextMenuItem>
|
||||
{:else}
|
||||
<ContextMenuItem
|
||||
disabled={!$itemStore.item.sonarrSeries}
|
||||
on:click={() =>
|
||||
window.open(
|
||||
$settings.sonarr.baseUrl + '/series/' + $itemStore.item?.sonarrSeries?.titleSlug
|
||||
)}
|
||||
>
|
||||
Open in Sonarr
|
||||
</ContextMenuItem>
|
||||
{/if}
|
||||
{/if}
|
||||
<ContextMenuItem on:click={() => window.open(`https://www.themoviedb.org/${type}/${tmdbId}`)}>
|
||||
Open in TMDB
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
|
||||
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { jellyfinItemsStore } from '$lib/stores/data.store';
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'radix-icons-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
|
||||
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
|
||||
import PlayButton from '../PlayButton.svelte';
|
||||
import ProgressBar from '../ProgressBar.svelte';
|
||||
import ContextMenuItem from '../ContextMenu/ContextMenuItem.svelte';
|
||||
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { playerState } from '../VideoPlayer/VideoPlayer';
|
||||
import { library } from '$lib/stores/library.store';
|
||||
|
||||
export let backdropPath: string;
|
||||
export let backdropUrl: string;
|
||||
|
||||
export let title = '';
|
||||
export let subtitle = '';
|
||||
@@ -19,6 +18,7 @@
|
||||
export let runtime = 0;
|
||||
export let progress = 0;
|
||||
export let watched = false;
|
||||
export let airDate: Date | undefined = undefined;
|
||||
|
||||
export let jellyfinId: string | undefined = undefined;
|
||||
|
||||
@@ -28,14 +28,15 @@
|
||||
if (!jellyfinId) return;
|
||||
|
||||
watched = true;
|
||||
setJellyfinItemWatched(jellyfinId).finally(() => library.refreshIn(5000));
|
||||
progress = 0;
|
||||
setJellyfinItemWatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000));
|
||||
}
|
||||
|
||||
function handleSetUnwatched() {
|
||||
if (!jellyfinId) return;
|
||||
|
||||
watched = false;
|
||||
setJellyfinItemUnwatched(jellyfinId).finally(() => library.refreshIn(5000));
|
||||
setJellyfinItemUnwatched(jellyfinId).finally(() => jellyfinItemsStore.refreshIn(5000));
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
@@ -64,7 +65,7 @@
|
||||
on:click
|
||||
class={classNames(
|
||||
'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',
|
||||
'flex flex-col px-2 lg:px-3 py-2 gap-2 text-left',
|
||||
{
|
||||
'h-40': size === 'md',
|
||||
'h-full': size === 'dynamic',
|
||||
@@ -72,37 +73,53 @@
|
||||
'cursor-default': !jellyfinId
|
||||
}
|
||||
)}
|
||||
style={"background-image: url('" + TMDB_BACKDROP_SMALL + backdropPath + "');"}
|
||||
style={"background-image: url('" + backdropUrl + "');"}
|
||||
in:fade|global={{ duration: 100, delay: 100 }}
|
||||
out:fade|global={{ duration: 100 }}
|
||||
>
|
||||
<div
|
||||
class={classNames(
|
||||
'absolute inset-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0',
|
||||
'absolute inset-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0 bg-darken',
|
||||
{
|
||||
'bg-darken': !jellyfinId || watched,
|
||||
'bg-gradient-to-t from-darken': !!jellyfinId
|
||||
// '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'
|
||||
'flex-1 flex flex-col justify-between relative group-hover:opacity-0 group-focus-visible:opacity-0 transition-all',
|
||||
{
|
||||
'opacity-8': !jellyfinId || watched
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<slot name="left-top">
|
||||
{#if episodeNumber}
|
||||
{#if airDate && airDate > new Date()}
|
||||
<p class="text-xs lg:text-sm font-medium text-zinc-300">
|
||||
{airDate.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
{:else if episodeNumber}
|
||||
<p class="text-xs lg:text-sm font-medium text-zinc-300">{episodeNumber}</p>
|
||||
{/if}
|
||||
</slot>
|
||||
</div>
|
||||
<div>
|
||||
<slot name="right-top">
|
||||
{#if runtime}
|
||||
{#if runtime && !progress}
|
||||
<p class="text-xs lg:text-sm font-medium text-zinc-300">
|
||||
{runtime} min
|
||||
{runtime.toFixed(0)} min
|
||||
</p>
|
||||
{:else if runtime && progress}
|
||||
<p class="text-xs lg:text-sm font-medium text-zinc-300">
|
||||
{(runtime - (runtime / 100) * progress).toFixed(0)} min left
|
||||
</p>
|
||||
{/if}
|
||||
</slot>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
$$restProps.class
|
||||
)}
|
||||
on:click|stopPropagation
|
||||
on:click
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { addMessages, init, locale, dictionary } from 'svelte-i18n';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { addMessages, init, locale } from 'svelte-i18n';
|
||||
import en from '../../lang/en.json';
|
||||
import es from '../../lang/es.json';
|
||||
import fr from '../../lang/fr.json';
|
||||
import it from '../../lang/it.json';
|
||||
|
||||
addMessages('en', en);
|
||||
addMessages('es', es);
|
||||
addMessages('fr', fr);
|
||||
addMessages('it', it);
|
||||
|
||||
settings.subscribe((value) => {
|
||||
if (value.language) {
|
||||
|
||||
32
src/lib/components/LazyImg.svelte
Normal file
32
src/lib/components/LazyImg.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let src: string;
|
||||
export let alt: string = '';
|
||||
|
||||
let loaded = false;
|
||||
|
||||
function handleLoad() {
|
||||
loaded = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
'transition-opacity duration-300',
|
||||
{
|
||||
'opacity-0': !loaded,
|
||||
'opacity-100': loaded
|
||||
},
|
||||
$$restProps.class
|
||||
)}
|
||||
>
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
style="object-fit: cover; width: 100%; height: 100%;"
|
||||
loading="lazy"
|
||||
on:load={handleLoad}
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -1,41 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { TMDB_POSTER_SMALL } from '$lib/constants';
|
||||
import type { TitleType } from '$lib/types';
|
||||
import classNames from 'classnames';
|
||||
import PlayButton from '../PlayButton.svelte';
|
||||
import ProgressBar from '../ProgressBar.svelte';
|
||||
import { playerState } from '../VideoPlayer/VideoPlayer';
|
||||
import LazyImg from '../LazyImg.svelte';
|
||||
import { Star } from 'radix-icons-svelte';
|
||||
import { openTitleModal } from '$lib/stores/modal.store';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let tmdbId: number | undefined = undefined;
|
||||
export let tvdbId: number | undefined = undefined;
|
||||
export let openInModal = true;
|
||||
export let jellyfinId: string = '';
|
||||
export let type: TitleType = 'movie';
|
||||
export let backdropUrl: string;
|
||||
|
||||
export let title = '';
|
||||
export let subtitle = '';
|
||||
|
||||
export let rating: number | undefined = undefined;
|
||||
export let progress = 0;
|
||||
|
||||
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';
|
||||
export let orientation: 'portrait' | 'landscape' = 'landscape';
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`/${type}/${tmdbId}`}
|
||||
style={"background-image: url('" + backdropUrl + "');"}
|
||||
class="relative flex shadow-lg rounded-lg aspect-[2/3] bg-center bg-cover w-44 selectable group hover:text-inherit flex-shrink-0"
|
||||
<button
|
||||
on:click={() => {
|
||||
if (openInModal) {
|
||||
if (tmdbId) {
|
||||
openTitleModal({ type, id: tmdbId, provider: 'tmdb' });
|
||||
} else if (tvdbId) {
|
||||
openTitleModal({ type, id: tvdbId, provider: 'tvdb' });
|
||||
}
|
||||
} else {
|
||||
window.location.href = tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#';
|
||||
}
|
||||
}}
|
||||
class={classNames(
|
||||
'relative flex shadow-lg rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left',
|
||||
{
|
||||
'aspect-video': orientation === 'landscape',
|
||||
'aspect-[2/3]': orientation === 'portrait',
|
||||
'w-32': size === 'sm' && orientation === 'portrait',
|
||||
'h-32': size === 'sm' && orientation === 'landscape',
|
||||
'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'
|
||||
}
|
||||
)}
|
||||
>
|
||||
<LazyImg src={backdropUrl} class="absolute inset-0 group-hover:scale-105 transition-transform" />
|
||||
<div
|
||||
class="absolute inset-0 opacity-0 group-hover:opacity-30 transition-opacity bg-black"
|
||||
style="filter: blur(50px); transform: scale(3);"
|
||||
>
|
||||
<LazyImg src={backdropUrl} />
|
||||
</div>
|
||||
<!-- <div
|
||||
style={`background-image: url(${backdropUrl}); background-size: cover; background-position: center; filter: blur(50px); transform: scale(3);`}
|
||||
class="absolute inset-0 opacity-0 group-hover:opacity-30 transition-opacity bg-black"
|
||||
/> -->
|
||||
<div
|
||||
class={classNames(
|
||||
'flex-1 flex flex-col justify-between bg-darken opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
'flex-1 flex flex-col justify-between bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity z-[1]',
|
||||
{
|
||||
'py-2 px-3': true
|
||||
// 'pb-4': progress,
|
||||
// 'pb-2': !progress
|
||||
}
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
@@ -45,30 +83,38 @@
|
||||
</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 />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-darken group-hover:opacity-0 transition-opacity"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<PlayButton
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
jellyfinId && playerState.streamJellyfinId(jellyfinId);
|
||||
}}
|
||||
class="sm:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="absolute inset-0 bg-gradient-to-t from-darken group-hover:opacity-0 transition-opacity z-[1]"
|
||||
/> -->
|
||||
{#if jellyfinId}
|
||||
<div class="absolute inset-0 flex items-center justify-center z-[1]">
|
||||
<PlayButton
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
jellyfinId && playerState.streamJellyfinId(jellyfinId);
|
||||
}}
|
||||
class="sm:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
{/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"
|
||||
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>
|
||||
{/if}
|
||||
</a>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getDiskSpace } from '$lib/apis/radarr/radarrApi';
|
||||
import { library } from '$lib/stores/library.store';
|
||||
import { radarrMoviesStore } from '$lib/stores/data.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { formatSize } from '$lib/utils.js';
|
||||
import RadarrIcon from '../svgs/RadarrIcon.svelte';
|
||||
@@ -11,24 +11,15 @@
|
||||
|
||||
async function fetchStats() {
|
||||
const discSpacePromise = getDiskSpace();
|
||||
const { itemsArray } = await $library;
|
||||
const availableMovies = itemsArray.filter(
|
||||
(item) =>
|
||||
!item.download &&
|
||||
item.radarrMovie &&
|
||||
item.radarrMovie.isAvailable &&
|
||||
item.radarrMovie.movieFile
|
||||
);
|
||||
const radarrMovies = await radarrMoviesStore.promise;
|
||||
const availableMovies = radarrMovies.filter((item) => item.isAvailable && item.movieFile);
|
||||
|
||||
const diskSpaceInfo =
|
||||
(await discSpacePromise).find((disk) => disk.path === '/') ||
|
||||
(await discSpacePromise)[0] ||
|
||||
undefined;
|
||||
|
||||
const spaceOccupied = availableMovies.reduce(
|
||||
(acc, movie) => acc + (movie.radarrMovie?.sizeOnDisk || 0),
|
||||
0
|
||||
);
|
||||
const spaceOccupied = availableMovies.reduce((acc, movie) => acc + (movie?.sizeOnDisk || 0), 0);
|
||||
|
||||
return {
|
||||
moviesCount: availableMovies.length,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getDiskSpace } from '$lib/apis/sonarr/sonarrApi';
|
||||
import { library } from '$lib/stores/library.store';
|
||||
import { sonarrSeriesStore } from '$lib/stores/data.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { formatSize } from '$lib/utils.js';
|
||||
import SonarrIcon from '../svgs/SonarrIcon.svelte';
|
||||
@@ -11,10 +11,8 @@
|
||||
|
||||
async function fetchStats() {
|
||||
const discSpacePromise = getDiskSpace();
|
||||
const { itemsArray } = await $library;
|
||||
const availableSeries = itemsArray.filter(
|
||||
(item) => item.sonarrSeries && item.sonarrSeries.statistics?.episodeFileCount
|
||||
);
|
||||
const sonarrSeries = await sonarrSeriesStore.promise;
|
||||
const availableSeries = sonarrSeries.filter((item) => item.statistics?.episodeFileCount);
|
||||
|
||||
const diskSpaceInfo =
|
||||
(await discSpacePromise).find((disk) => disk.path === '/') ||
|
||||
@@ -22,12 +20,12 @@
|
||||
undefined;
|
||||
|
||||
const spaceOccupied = availableSeries.reduce(
|
||||
(acc, series) => acc + (series.sonarrSeries?.statistics?.sizeOnDisk || 0),
|
||||
(acc, series) => acc + (series?.statistics?.sizeOnDisk || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const episodesCount = availableSeries.reduce(
|
||||
(acc, series) => acc + (series.sonarrSeries?.statistics?.episodeFileCount || 0),
|
||||
(acc, series) => acc + (series?.statistics?.episodeFileCount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { LibraryItemStore } from '$lib/stores/library.store';
|
||||
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
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';
|
||||
|
||||
export let title = '';
|
||||
export let itemStore: LibraryItemStore;
|
||||
|
||||
export let jellyfinItem: JellyfinItem | undefined = undefined;
|
||||
export let sonarrSeries: SonarrSeries | undefined = undefined;
|
||||
export let radarrMovie: RadarrMovie | undefined = undefined;
|
||||
|
||||
export let type: TitleType;
|
||||
export let tmdbId: number;
|
||||
</script>
|
||||
|
||||
<ContextMenuButton heading={$itemStore.loading ? 'Loading...' : title}>
|
||||
<ContextMenuButton heading={title}>
|
||||
<svelte:fragment slot="menu">
|
||||
<LibraryItemContextItems {itemStore} {type} {tmdbId} />
|
||||
<LibraryItemContextItems {jellyfinItem} {sonarrSeries} {radarrMovie} {type} {tmdbId} />
|
||||
</svelte:fragment>
|
||||
<Button slim>
|
||||
<DotsVertical size={24} />
|
||||
</Button>
|
||||
</ContextMenuButton>
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '$lib/constants';
|
||||
import type { TitleType } from '$lib/types';
|
||||
import type { TitleId, TitleType } from '$lib/types';
|
||||
import classNames from 'classnames';
|
||||
import { ChevronLeft, Cross2, DotFilled, ExternalLink } from 'radix-icons-svelte';
|
||||
import Carousel from '../Carousel/Carousel.svelte';
|
||||
import CarouselPlaceholderItems from '../Carousel/CarouselPlaceholderItems.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import LazyImg from '../LazyImg.svelte';
|
||||
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
|
||||
export let tmdbId: number;
|
||||
export let type: TitleType;
|
||||
|
||||
export let backdropUriCandidates: string[];
|
||||
export let posterPath: string;
|
||||
export let title: string;
|
||||
|
||||
export let tagline: string;
|
||||
export let overview: string;
|
||||
export let titleInformation:
|
||||
| {
|
||||
tmdbId: number;
|
||||
type: TitleType;
|
||||
title: string;
|
||||
tagline: string;
|
||||
overview: string;
|
||||
backdropUriCandidates: string[];
|
||||
posterPath: string;
|
||||
}
|
||||
| undefined = undefined;
|
||||
|
||||
let topHeight: number;
|
||||
let bottomHeight: number;
|
||||
@@ -33,31 +36,34 @@
|
||||
|
||||
<svelte:window bind:outerHeight={windowHeight} />
|
||||
|
||||
<!-- Desktop -->
|
||||
<div
|
||||
style={"background-image: url('" +
|
||||
TMDB_IMAGES_ORIGINAL +
|
||||
getBackdropUri(backdropUriCandidates) +
|
||||
"'); height: " +
|
||||
imageHeight.toFixed() +
|
||||
'px'}
|
||||
style={'height: ' + imageHeight.toFixed() + 'px'}
|
||||
class={classNames('hidden sm:block inset-x-0 bg-center bg-cover bg-stone-950', {
|
||||
absolute: isModal,
|
||||
fixed: !isModal
|
||||
})}
|
||||
>
|
||||
<div class="absolute inset-0 bg-darken" />
|
||||
{#if titleInformation}
|
||||
<LazyImg
|
||||
src={TMDB_IMAGES_ORIGINAL + getBackdropUri(titleInformation.backdropUriCandidates)}
|
||||
class="h-full"
|
||||
>
|
||||
<div class="absolute inset-0 bg-darken" />
|
||||
</LazyImg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div
|
||||
style={"background-image: url('" +
|
||||
TMDB_IMAGES_ORIGINAL +
|
||||
posterPath +
|
||||
"'); height: " +
|
||||
imageHeight.toFixed() +
|
||||
'px'}
|
||||
style={'height: ' + imageHeight.toFixed() + 'px'}
|
||||
class="sm:hidden fixed inset-x-0 bg-center bg-cover bg-stone-950"
|
||||
>
|
||||
<div class="absolute inset-0 bg-darken" />
|
||||
{#if titleInformation}
|
||||
<LazyImg src={TMDB_IMAGES_ORIGINAL + titleInformation.posterPath} class="h-full">
|
||||
<div class="absolute inset-0 bg-darken" />
|
||||
</LazyImg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -73,11 +79,16 @@
|
||||
bind:clientHeight={topHeight}
|
||||
>
|
||||
{#if isModal}
|
||||
<a href={`/${type}/${tmdbId}`} class="absolute top-8 right-4 sm:right-8 z-10">
|
||||
<IconButton>
|
||||
<ExternalLink size={20} />
|
||||
</IconButton>
|
||||
</a>
|
||||
{#if titleInformation}
|
||||
<a
|
||||
href={`/${titleInformation.type}/${titleInformation.tmdbId}`}
|
||||
class="absolute top-8 right-4 sm:right-8 z-10"
|
||||
>
|
||||
<IconButton>
|
||||
<ExternalLink size={20} />
|
||||
</IconButton>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="absolute top-8 left-4 sm:left-8 z-10">
|
||||
<button class="flex items-center sm:hidden font-medium" on:click={handleCloseModal}>
|
||||
<ChevronLeft size={20} />
|
||||
@@ -92,26 +103,26 @@
|
||||
{/if}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-30%" />
|
||||
<div class="z-[1] flex-1 flex justify-end gap-8 items-end max-w-screen-2xl mx-auto">
|
||||
<div
|
||||
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
|
||||
style={"background-image: url('" + TMDB_POSTER_SMALL + posterPath + "')"}
|
||||
/>
|
||||
{#if titleInformation}
|
||||
<div
|
||||
class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block"
|
||||
style={"background-image: url('" + TMDB_POSTER_SMALL + titleInformation.posterPath + "')"}
|
||||
/>
|
||||
{:else}
|
||||
<div class="aspect-[2/3] w-52 bg-center bg-cover rounded-md hidden sm:block placeholder" />
|
||||
{/if}
|
||||
<div class="flex-1 flex gap-4 justify-between flex-col lg:flex-row lg:items-end">
|
||||
<div>
|
||||
<div class="text-zinc-300 text-sm uppercase font-semibold flex items-center gap-1">
|
||||
<p class="flex-shrink-0">
|
||||
<slot name="title-info-1" />
|
||||
</p>
|
||||
<DotFilled />
|
||||
<p class="flex-shrink-0">
|
||||
<slot name="title-info-2" />
|
||||
</p>
|
||||
<DotFilled />
|
||||
<p class="flex-shrink-0"><slot name="title-info-3" /></p>
|
||||
<!-- <DotFilled />
|
||||
<p class="line-clamp-1">{series?.genres?.map((g) => g.name).join(', ')}</p> -->
|
||||
<slot name="title-info">
|
||||
<div class="placeholder-text">Placeholder Long</div>
|
||||
</slot>
|
||||
</div>
|
||||
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{title}</h1>
|
||||
{#if titleInformation}
|
||||
<h1 class="text-4xl sm:text-5xl md:text-6xl font-semibold">{titleInformation.title}</h1>
|
||||
{:else}
|
||||
<h1 class="text-4xl sm:text-5xl md:text-6xl placeholder-text mt-2">Placeholder</h1>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<slot name="title-right" />
|
||||
@@ -138,9 +149,10 @@
|
||||
<div
|
||||
class="flex flex-col gap-3 max-w-5xl row-span-3 col-span-4 sm:col-span-6 lg:col-span-3 mb-4 lg:mr-8"
|
||||
>
|
||||
<div class="flex gap-4 justify-between">
|
||||
<h1 class="font-semibold text-xl sm:text-2xl">{tagline}</h1>
|
||||
<!-- <div class="flex items-center gap-4">
|
||||
{#if titleInformation}
|
||||
<div class="flex gap-4 justify-between">
|
||||
<h1 class="font-semibold text-xl sm:text-2xl">{titleInformation.tagline}</h1>
|
||||
<!-- <div class="flex items-center gap-4">
|
||||
<a
|
||||
target="_blank"
|
||||
href={'https://www.themoviedb.org/tv/' + tmdbId}
|
||||
@@ -168,8 +180,22 @@
|
||||
<Globe size={15} />
|
||||
</a>
|
||||
{/if} -->
|
||||
</div>
|
||||
<p class="pl-4 border-l-2 text-sm sm:text-base">{overview}</p>
|
||||
</div>
|
||||
<p class="pl-4 border-l-2 text-sm sm:text-base">{titleInformation.overview}</p>
|
||||
{:else}
|
||||
<div class="flex gap-4 justify-between">
|
||||
<h1 class="font-semibold text-xl sm:text-2xl placeholder-text">Placeholder</h1>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="mr-4 placeholder w-1 flex-shrink-0 rounded" />
|
||||
<p class="text-sm sm:text-base placeholder-text">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur sit amet sem eget
|
||||
dolor lobortis mollis. Aliquam semper imperdiet mi nec viverra. Praesent ac ligula
|
||||
congue, aliquam diam nec, ullamcorper libero. Nunc mattis rhoncus justo, ac pretium
|
||||
urna vehicula et.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="info-components" />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { TitleType } from '$lib/types';
|
||||
import type { TitleId } from '$lib/types';
|
||||
import { fly } from 'svelte/transition';
|
||||
import MoviePage from '../../../routes/movie/[id]/MoviePage.svelte';
|
||||
import SeriesPage from '../../../routes/series/[id]/SeriesPage.svelte';
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let type: TitleType;
|
||||
export let titleId: TitleId;
|
||||
export let modalId: symbol;
|
||||
|
||||
function handleCloseModal() {
|
||||
@@ -22,10 +21,10 @@
|
||||
in:fly|global={{ y: 20, duration: 200, delay: 200 }}
|
||||
out:fly|global={{ y: 20, duration: 200 }}
|
||||
>
|
||||
{#if type === 'movie'}
|
||||
<MoviePage {tmdbId} isModal={true} {handleCloseModal} />
|
||||
{#if titleId.type === 'movie'}
|
||||
<MoviePage tmdbId={titleId.id} isModal={true} {handleCloseModal} />
|
||||
{:else}
|
||||
<SeriesPage {tmdbId} isModal={true} {handleCloseModal} />
|
||||
<SeriesPage {titleId} isModal={true} {handleCloseModal} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,11 @@
|
||||
out:fade|global={{ duration: ANIMATION_DURATION }}
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Button size="lg" type="primary" on:click={() => openTitleModal(tmdbId, type)}>
|
||||
<Button
|
||||
size="lg"
|
||||
type="primary"
|
||||
on:click={() => openTitleModal({ type, id: tmdbId, provider: 'tmdb' })}
|
||||
>
|
||||
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
|
||||
</Button>
|
||||
{#if trailerId}
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
import Slider from './Slider.svelte';
|
||||
import { playerState } from './VideoPlayer';
|
||||
import { linear } from 'svelte/easing';
|
||||
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
|
||||
|
||||
export let modalId: symbol;
|
||||
|
||||
@@ -326,8 +328,37 @@
|
||||
fullscreen = !!getFullscreenElement?.();
|
||||
});
|
||||
}
|
||||
|
||||
function handleRequestFullscreen() {
|
||||
if (reqFullscreenFunc) {
|
||||
fullscreen = !fullscreen;
|
||||
// @ts-ignore
|
||||
} else if (video?.webkitEnterFullScreen) {
|
||||
// Edge case to allow fullscreen on iPhone
|
||||
// @ts-ignore
|
||||
video.webkitEnterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
function handleShortcuts(event: KeyboardEvent) {
|
||||
if (event.key === 'f') {
|
||||
handleRequestFullscreen();
|
||||
} else if (event.key === ' ') {
|
||||
paused = !paused;
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
video.currentTime -= 10;
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
video.currentTime += 10;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
volume = Math.min(volume + 0.1, 1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
volume = Math.max(volume - 0.1, 0);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleShortcuts} />
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class={classNames(
|
||||
@@ -336,15 +367,16 @@
|
||||
'cursor-none': !uiVisible
|
||||
}
|
||||
)}
|
||||
in:fade|global={{ duration: 300, easing: linear }}
|
||||
out:fade|global={{ duration: 200, easing: linear }}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="bg-black w-screen h-screen flex items-center justify-center"
|
||||
class="w-screen h-screen flex items-center justify-center"
|
||||
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 -->
|
||||
<video
|
||||
@@ -365,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}
|
||||
@@ -403,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;
|
||||
@@ -446,20 +473,13 @@
|
||||
<Slider bind:primaryValue={volume} secondaryValue={0} max={1} />
|
||||
</div>
|
||||
|
||||
{#if reqFullscreenFunc}
|
||||
<IconButton on:click={() => (fullscreen = !fullscreen)}>
|
||||
{#if fullscreen}
|
||||
<ExitFullScreen size={20} />
|
||||
{:else if !fullscreen && exitFullscreen}
|
||||
<EnterFullScreen size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<!-- Edge case to allow fullscreen on iPhone -->
|
||||
{:else if video?.webkitEnterFullScreen}
|
||||
<IconButton on:click={() => video.webkitEnterFullScreen()}>
|
||||
<IconButton on:click={handleRequestFullscreen}>
|
||||
{#if fullscreen}
|
||||
<ExitFullScreen size={20} />
|
||||
{:else if !fullscreen && exitFullscreen}
|
||||
<EnterFullScreen size={20} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { library } from '$lib/stores/library.store';
|
||||
import { jellyfinItemsStore } from '$lib/stores/data.store';
|
||||
import { writable } from 'svelte/store';
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
import VideoPlayer from './VideoPlayer.svelte';
|
||||
@@ -17,7 +17,7 @@ function createPlayerState() {
|
||||
},
|
||||
close: () => {
|
||||
store.set({ visible: false, jellyfinId: '' });
|
||||
library.refresh();
|
||||
jellyfinItemsStore.refresh();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,3 +4,5 @@ export const TMDB_IMAGES_ORIGINAL = 'https://www.themoviedb.org/t/p/original';
|
||||
export const TMDB_BACKDROP_SMALL = 'https://www.themoviedb.org/t/p/w780';
|
||||
export const TMDB_POSTER_SMALL = 'https://www.themoviedb.org/t/p/w342';
|
||||
export const TMDB_PROFILE_SMALL = 'https://www.themoviedb.org/t/p/w185';
|
||||
|
||||
export const PLACEHOLDER_BACKDROP = '/plcaeholder.jpg';
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"upcomingSeries": "Upcoming Series",
|
||||
"genres": "Genres",
|
||||
"newDigitalReleases": "New Digital Releases",
|
||||
"streamingNow": "On Streaming Now",
|
||||
"streamingNow": "Streaming Now",
|
||||
"TVNetworks": "TV Networks"
|
||||
},
|
||||
"library": {
|
||||
|
||||
102
src/lib/lang/fr.json
Normal file
102
src/lib/lang/fr.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"appName": "Reiverr",
|
||||
"setupRequiredTitle": "Bienvenue à",
|
||||
"setupRequiredDescription": "Il semble que l'application manque de certaines variables d'environnement nécessaires au fonctionnement de l'application. ",
|
||||
"navbar": {
|
||||
"home": "Accueil",
|
||||
"discover": "Découvrir",
|
||||
"library": "Bibliothèque",
|
||||
"sources": "Sources",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"search": {
|
||||
"placeHolder": "Rechercher des films et des émissions de télévision",
|
||||
"noRecentSearches": "Aucune recherche récente",
|
||||
"noResults": "Aucun résultat trouvé"
|
||||
},
|
||||
"discover": {
|
||||
"trending": "Tendance",
|
||||
"popularPeople": "Personnes populaires",
|
||||
"upcomingMovies": "Films à venir",
|
||||
"upcomingSeries": "Série à venir",
|
||||
"genres": "Genres",
|
||||
"newDigitalReleases": "Nouvelles versions numériques",
|
||||
"streamingNow": "En streaming maintenant",
|
||||
"TVNetworks": "Réseaux de télévision"
|
||||
},
|
||||
"library": {
|
||||
"missingConfiguration": "Configurez Radarr, Sonarr et Jellyfin pour surveiller et gérer votre bibliothèque",
|
||||
"available": "Disponible",
|
||||
"watched": "Regardé",
|
||||
"unavailable": "Indisponible",
|
||||
"sort": {
|
||||
"byTitle": "Par titre"
|
||||
},
|
||||
"content": {
|
||||
"movie": "Film",
|
||||
"show": "Voir",
|
||||
"requestContent": "Demander",
|
||||
"directedBy": "Dirigé par",
|
||||
"releaseDate": "Date de sortie",
|
||||
"budget": "Budget",
|
||||
"status": "Statut",
|
||||
"runtime": "Durée",
|
||||
"castAndCrew": "Casting",
|
||||
"recommendations": "Recommandations",
|
||||
"similarTitles": "Titres similaires"
|
||||
}
|
||||
},
|
||||
"sources": {},
|
||||
"titleShowcase": {
|
||||
"details": "Détails",
|
||||
"watchTrailer": "Regarde la bande-annonce",
|
||||
"releaseDate": "Date de sortie",
|
||||
"directedBy": "Dirigé par"
|
||||
},
|
||||
"settings": {
|
||||
"navbar": {
|
||||
"settings": "Configuration",
|
||||
"general": "Général",
|
||||
"integrations": "Intégrations"
|
||||
},
|
||||
"general": {
|
||||
"userInterface": {
|
||||
"userInterface": "Interface utilisateur",
|
||||
"language": "Langue",
|
||||
"autoplayTrailers": "Bandes-annonces à lecture automatique",
|
||||
"animationDuration": "Durée de l'animation"
|
||||
},
|
||||
"discovery": {
|
||||
"discovery": "Découverte",
|
||||
"none": "Aucun",
|
||||
"region": "Région",
|
||||
"excludeLibraryItemsFromDiscovery": "Exclure les éléments de bibliothèque de Discovery",
|
||||
"includedLanguages": "Langues incluses",
|
||||
"includedLanguagesDescription": "Filtrez les résultats en fonction de la langue parlée. "
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"integrations": "Intégrations",
|
||||
"integrationsNote": "Remarque: Les URL de base doivent être accessibles depuis le navigateur, ce qui signifie que les adresses Docker internes ne fonctionneront pas, par exemple. <span class='font-medium underline'>sera exposé</span> au navigateur.",
|
||||
"baseUrl": "URL de base",
|
||||
"apiKey": "clé API",
|
||||
"testConnection": "Tester la connexion",
|
||||
"status": {
|
||||
"connected": "Connecté",
|
||||
"disconnected": "Déconnecté"
|
||||
},
|
||||
"options": {
|
||||
"options": "Options",
|
||||
"rootFolder": "Dossier racine",
|
||||
"qualityProfile": "Profil de qualité",
|
||||
"languageProfile": "Profil linguistique",
|
||||
"jellyfinUser": "Utilisateur de Jellyfin"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"saveChanges": "Sauvegarder les modifications",
|
||||
"resetToDefaults": "Réinitialiser les paramètres par défaut ",
|
||||
"changelog": "Journal des modifications"
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/lib/lang/it.json
Normal file
102
src/lib/lang/it.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"appName": "Reiverr",
|
||||
"setupRequiredTitle": "Benvenuti in",
|
||||
"setupRequiredDescription": "Sembra che alcune variabili di ambiente necessarie per il funzionamento dell'applicazione siano mancanti. Per favore, inserisci le seguenti variabili d'ambiente:",
|
||||
"navbar": {
|
||||
"home": "Home",
|
||||
"discover": "Esplora",
|
||||
"library": "Libreria",
|
||||
"sources": "Fonti",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"search": {
|
||||
"placeHolder": "Cerca Film o Serie TV",
|
||||
"noRecentSearches": "Nessuna ricerca recente",
|
||||
"noResults": "Nessun risultato"
|
||||
},
|
||||
"discover": {
|
||||
"trending": "In tendenza",
|
||||
"popularPeople": "Personaggi in tendenza",
|
||||
"upcomingMovies": "Film in uscita",
|
||||
"upcomingSeries": "Serie TV in uscita",
|
||||
"genres": "Generi",
|
||||
"newDigitalReleases": "Nuove uscite in digitale",
|
||||
"streamingNow": "In streaming adesso",
|
||||
"TVNetworks": "Network TV"
|
||||
},
|
||||
"library": {
|
||||
"missingConfiguration": "Configura Radarr, Sonarr e Jellyfin per guardare e gestire la tua libreria",
|
||||
"available": "Disponibile",
|
||||
"watched": "Guardato",
|
||||
"unavailable": "Non disponibile",
|
||||
"sort": {
|
||||
"byTitle": "Per Titolo"
|
||||
},
|
||||
"content": {
|
||||
"movie": "Film",
|
||||
"show": "Serie TV",
|
||||
"requestContent": "Richiedi",
|
||||
"directedBy": "Diretto da",
|
||||
"releaseDate": "Data di uscita",
|
||||
"budget": "Budget",
|
||||
"status": "Stato",
|
||||
"runtime": "Durata",
|
||||
"castAndCrew": "Cast e Crew",
|
||||
"recommendations": "Suggerimenti",
|
||||
"similarTitles": "Titoli simili"
|
||||
}
|
||||
},
|
||||
"sources": {},
|
||||
"titleShowcase": {
|
||||
"details": "Dettagli",
|
||||
"watchTrailer": "Guarda il trailer",
|
||||
"releaseDate": "Data di uscita",
|
||||
"directedBy": "Diretto da"
|
||||
},
|
||||
"settings": {
|
||||
"navbar": {
|
||||
"settings": "Configurazione",
|
||||
"general": "Generale",
|
||||
"integrations": "Integrazioni"
|
||||
},
|
||||
"general": {
|
||||
"userInterface": {
|
||||
"userInterface": "Interfaccia utente",
|
||||
"language": "Lingua",
|
||||
"autoplayTrailers": "Riproduci automaticamente i trailer",
|
||||
"animationDuration": "Durata dell'animazione"
|
||||
},
|
||||
"discovery": {
|
||||
"discovery": "Esplora",
|
||||
"none": "Niente",
|
||||
"region": "Paese",
|
||||
"excludeLibraryItemsFromDiscovery": "Escludi i media presenti nella libreria dalla sezione Esplora",
|
||||
"includedLanguages": "Lingue disponibili",
|
||||
"includedLanguagesDescription": "Filtra i risultati in base alla lingua dell'audio. Inserisci i codici della lingua in formato ISO 639-1 separati da virgola. Lascia vuoto per disabilitare."
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"integrations": "Integrazioni",
|
||||
"integrationsNote": "Nota bene: L'indirizzo (URL) deve essere accessibile dal browser, questo vuol dire che indirizzi come quello interno di docker non funzioneranno. Le chiavi API <span class='font-medium underline'>saranno rese disponibili</span> al browser.",
|
||||
"baseUrl": "Indirizzo (URL)",
|
||||
"apiKey": "Chiave API",
|
||||
"testConnection": "Testa la connessione",
|
||||
"status": {
|
||||
"connected": "Connesso",
|
||||
"disconnected": "Disconnesso"
|
||||
},
|
||||
"options": {
|
||||
"options": "Opzioni",
|
||||
"rootFolder": "Percorso",
|
||||
"qualityProfile": "Profilo di qualità",
|
||||
"languageProfile": "Profilo della lingua",
|
||||
"jellyfinUser": "Utente Jellyfin"
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"saveChanges": "Salva",
|
||||
"resetToDefaults": "Impostazioni predefinite",
|
||||
"changelog": "Note di rilascio"
|
||||
}
|
||||
}
|
||||
}
|
||||
219
src/lib/stores/data.store.ts
Normal file
219
src/lib/stores/data.store.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { getJellyfinItems, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import {
|
||||
getRadarrDownloads,
|
||||
getRadarrMovies,
|
||||
type RadarrDownload
|
||||
} from '$lib/apis/radarr/radarrApi';
|
||||
import {
|
||||
getSonarrDownloads,
|
||||
getSonarrSeries,
|
||||
type SonarrDownload,
|
||||
type SonarrSeries
|
||||
} from '$lib/apis/sonarr/sonarrApi';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { settings } from './settings.store';
|
||||
|
||||
async function waitForSettings() {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
settings.subscribe((settings) => {
|
||||
if (settings?.initialised && !resolved) {
|
||||
resolved = true;
|
||||
resolve(settings);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type AwaitableStoreValue<R, T = { data?: R }> = {
|
||||
loading: boolean;
|
||||
} & T;
|
||||
|
||||
function _createDataFetchStore<T>(fn: () => Promise<T>) {
|
||||
const store = writable<AwaitableStoreValue<T>>({
|
||||
loading: true,
|
||||
data: undefined
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
store.update((s) => ({ ...s, loading: true }));
|
||||
return waitForSettings().then(() =>
|
||||
fn().then((data) => {
|
||||
store.set({ loading: false, data });
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
let updateTimeout: NodeJS.Timeout;
|
||||
function refreshIn(ms = 1000) {
|
||||
return new Promise((resolve) => {
|
||||
clearTimeout(updateTimeout);
|
||||
updateTimeout = setTimeout(() => {
|
||||
refresh().then(resolve);
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh,
|
||||
refreshIn,
|
||||
promise: refresh()
|
||||
};
|
||||
}
|
||||
|
||||
export const jellyfinItemsStore = _createDataFetchStore(getJellyfinItems);
|
||||
|
||||
export function createJellyfinItemStore(tmdbId: number | Promise<number>) {
|
||||
const store = writable<{ loading: boolean; item?: JellyfinItem }>({
|
||||
loading: true,
|
||||
item: undefined
|
||||
});
|
||||
|
||||
jellyfinItemsStore.subscribe(async (s) => {
|
||||
const awaited = await tmdbId;
|
||||
|
||||
store.set({
|
||||
loading: s.loading,
|
||||
item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(awaited))
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh: jellyfinItemsStore.refresh,
|
||||
refreshIn: jellyfinItemsStore.refreshIn,
|
||||
promise: new Promise<JellyfinItem | undefined>((resolve) => {
|
||||
store.subscribe((s) => {
|
||||
if (!s.loading) resolve(s.item);
|
||||
});
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export const sonarrSeriesStore = _createDataFetchStore(getSonarrSeries);
|
||||
export const radarrMoviesStore = _createDataFetchStore(getRadarrMovies);
|
||||
|
||||
export function createRadarrMovieStore(tmdbId: number) {
|
||||
const store = derived(radarrMoviesStore, (s) => {
|
||||
return {
|
||||
loading: s.loading,
|
||||
item: s.data?.find((i) => i.tmdbId === tmdbId)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh: radarrMoviesStore.refresh,
|
||||
refreshIn: radarrMoviesStore.refreshIn
|
||||
};
|
||||
}
|
||||
|
||||
export function createSonarrSeriesStore(name: Promise<string> | string) {
|
||||
function shorten(str: string) {
|
||||
return str.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
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(awaited) ||
|
||||
i.alternateTitles?.find((t) => shorten(t.title || '') === shorten(awaited))
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh: sonarrSeriesStore.refresh,
|
||||
refreshIn: sonarrSeriesStore.refreshIn
|
||||
};
|
||||
}
|
||||
|
||||
export const sonarrDownloadsStore = _createDataFetchStore(getSonarrDownloads);
|
||||
export const radarrDownloadsStore = _createDataFetchStore(getRadarrDownloads);
|
||||
export const servarrDownloadsStore = (() => {
|
||||
const store = derived([sonarrDownloadsStore, radarrDownloadsStore], ([sonarr, radarr]) => {
|
||||
return {
|
||||
loading: sonarr.loading || radarr.loading,
|
||||
radarrDownloads: radarr.data,
|
||||
sonarrDownloads: sonarr.data
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe
|
||||
};
|
||||
})();
|
||||
|
||||
export function createRadarrDownloadStore(
|
||||
radarrMovieStore: ReturnType<typeof createRadarrMovieStore>
|
||||
) {
|
||||
const store = writable<{ loading: boolean; downloads?: RadarrDownload[] }>({
|
||||
loading: true,
|
||||
downloads: undefined
|
||||
});
|
||||
|
||||
const combinedStore = derived(
|
||||
[radarrMovieStore, radarrDownloadsStore],
|
||||
([movieStore, downloadsStore]) => ({ movieStore, downloadsStore })
|
||||
);
|
||||
|
||||
combinedStore.subscribe(async (data) => {
|
||||
const movie = data.movieStore.item;
|
||||
const downloads = data.downloadsStore.data;
|
||||
|
||||
if (!movie || !downloads) return;
|
||||
|
||||
store.set({
|
||||
loading: false,
|
||||
downloads: downloads?.filter((d) => d.movie.tmdbId === movie?.tmdbId)
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh: async () => radarrDownloadsStore.refresh()
|
||||
};
|
||||
}
|
||||
|
||||
export function createSonarrDownloadStore(
|
||||
sonarrItemStore: ReturnType<typeof createSonarrSeriesStore>
|
||||
) {
|
||||
const store = writable<{ loading: boolean; downloads?: SonarrDownload[] }>({
|
||||
loading: true,
|
||||
downloads: undefined
|
||||
});
|
||||
|
||||
const combinedStore = derived(
|
||||
[sonarrItemStore, sonarrDownloadsStore],
|
||||
([itemStore, downloadsStore]) => ({ itemStore, downloadsStore })
|
||||
);
|
||||
|
||||
combinedStore.subscribe(async (data) => {
|
||||
const item = data.itemStore.item;
|
||||
const downloads = data.downloadsStore.data;
|
||||
|
||||
if (!item || !downloads) return;
|
||||
|
||||
store.set({
|
||||
loading: false,
|
||||
downloads: downloads?.filter((d) => d.series.id === item?.id)
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
refresh: async () => sonarrDownloadsStore.refresh()
|
||||
};
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
import {
|
||||
getJellyfinContinueWatching,
|
||||
getJellyfinEpisodes,
|
||||
getJellyfinItems,
|
||||
getJellyfinNextUp,
|
||||
type JellyfinItem
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import {
|
||||
getRadarrDownloads,
|
||||
getRadarrMovies,
|
||||
type RadarrDownload,
|
||||
type RadarrMovie
|
||||
} from '$lib/apis/radarr/radarrApi';
|
||||
import {
|
||||
getSonarrDownloads,
|
||||
getSonarrSeries,
|
||||
type SonarrDownload,
|
||||
type SonarrSeries
|
||||
} from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
getTmdbMovieBackdrop,
|
||||
getTmdbMoviePoster,
|
||||
getTmdbSeriesBackdrop,
|
||||
getTmdbSeriesFromTvdbId
|
||||
} from '$lib/apis/tmdb/tmdbApi';
|
||||
import { TMDB_BACKDROP_SMALL, TMDB_POSTER_SMALL } from '$lib/constants';
|
||||
import type { TitleType } from '$lib/types';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { settings } from './settings.store';
|
||||
|
||||
export interface PlayableItem {
|
||||
tmdbRating: number;
|
||||
backdropUrl: string;
|
||||
posterUrl: string;
|
||||
download?: {
|
||||
progress: number;
|
||||
completionTime: string | undefined;
|
||||
};
|
||||
continueWatching?: {
|
||||
progress: number;
|
||||
length: number;
|
||||
};
|
||||
isPlayed: boolean;
|
||||
jellyfinId?: string;
|
||||
|
||||
type: TitleType;
|
||||
tmdbId: number;
|
||||
jellyfinItem?: JellyfinItem;
|
||||
jellyfinEpisodes?: JellyfinItem[];
|
||||
nextJellyfinEpisode?: JellyfinItem;
|
||||
radarrMovie?: RadarrMovie;
|
||||
radarrDownloads?: RadarrDownload[];
|
||||
sonarrSeries?: SonarrSeries;
|
||||
sonarrDownloads?: SonarrDownload[];
|
||||
}
|
||||
|
||||
export interface Library {
|
||||
items: Record<string, PlayableItem>;
|
||||
itemsArray: PlayableItem[];
|
||||
continueWatching: PlayableItem[];
|
||||
}
|
||||
|
||||
async function getLibrary(): Promise<Library> {
|
||||
const radarrMoviesPromise = getRadarrMovies();
|
||||
const radarrDownloadsPromise = getRadarrDownloads();
|
||||
|
||||
const sonarrSeriesPromise = getSonarrSeries();
|
||||
const sonarrDownloadsPromise = getSonarrDownloads();
|
||||
|
||||
const jellyfinContinueWatchingPromise = getJellyfinContinueWatching();
|
||||
const jellyfinNextUpPromise = getJellyfinNextUp();
|
||||
const jellyfinLibraryItemsPromise = getJellyfinItems();
|
||||
const jellyfinEpisodesPromise = getJellyfinEpisodes();
|
||||
|
||||
const radarrMovies = await radarrMoviesPromise;
|
||||
const radarrDownloads = await radarrDownloadsPromise;
|
||||
|
||||
const sonarrSeries = await sonarrSeriesPromise;
|
||||
const sonarrDownloads = await sonarrDownloadsPromise;
|
||||
|
||||
const jellyfinContinueWatching = (await jellyfinContinueWatchingPromise) || [];
|
||||
const jellyfinLibraryItems = (await jellyfinLibraryItemsPromise) || [];
|
||||
const jellyfinEpisodes =
|
||||
(await jellyfinEpisodesPromise.then((episodes) =>
|
||||
episodes?.sort((a, b) => (a.IndexNumber || 99) - (b.IndexNumber || 99))
|
||||
)) || [];
|
||||
const jellyfinNextUp = await jellyfinNextUpPromise;
|
||||
|
||||
const items: Record<string, PlayableItem> = {};
|
||||
|
||||
const moviesPromise: Promise<PlayableItem>[] = radarrMovies.map(async (radarrMovie) => {
|
||||
const itemRadarrDownloads = radarrDownloads.filter(
|
||||
(d) => d.movie.tmdbId === radarrMovie.tmdbId
|
||||
);
|
||||
const radarrDownload = itemRadarrDownloads[0];
|
||||
const jellyfinItem = jellyfinLibraryItems.find(
|
||||
(i) => i.ProviderIds?.Tmdb === String(radarrMovie.tmdbId)
|
||||
);
|
||||
|
||||
const downloadProgress =
|
||||
radarrDownload?.sizeleft && radarrDownload?.size
|
||||
? ((radarrDownload.size - radarrDownload.sizeleft) / radarrDownload.size) * 100
|
||||
: undefined;
|
||||
const completionTime = radarrDownload?.estimatedCompletionTime || undefined;
|
||||
const download = downloadProgress ? { progress: downloadProgress, completionTime } : undefined;
|
||||
|
||||
const length = jellyfinItem?.RunTimeTicks
|
||||
? jellyfinItem.RunTimeTicks / 10_000_000 / 60
|
||||
: undefined;
|
||||
const watchingProgress = jellyfinItem?.UserData?.PlayedPercentage;
|
||||
const continueWatching =
|
||||
length &&
|
||||
watchingProgress &&
|
||||
!!jellyfinContinueWatching.find((i) => i.Id === jellyfinItem?.Id)
|
||||
? { length, progress: watchingProgress }
|
||||
: undefined;
|
||||
|
||||
const backdropUri = await getTmdbMovieBackdrop(radarrMovie.tmdbId || 0);
|
||||
const posterUri = await getTmdbMoviePoster(radarrMovie.tmdbId || 0);
|
||||
|
||||
const playableItem: PlayableItem = {
|
||||
type: 'movie' as const,
|
||||
tmdbId: radarrMovie.tmdbId || 0,
|
||||
tmdbRating: radarrMovie.ratings?.tmdb?.value || 0,
|
||||
backdropUrl: backdropUri ? TMDB_BACKDROP_SMALL + backdropUri : '',
|
||||
posterUrl: posterUri ? TMDB_POSTER_SMALL + posterUri : '',
|
||||
download,
|
||||
continueWatching,
|
||||
isPlayed: jellyfinItem?.UserData?.Played || false,
|
||||
jellyfinId: jellyfinItem?.Id,
|
||||
jellyfinItem,
|
||||
radarrMovie,
|
||||
radarrDownloads: itemRadarrDownloads
|
||||
};
|
||||
|
||||
return playableItem;
|
||||
});
|
||||
|
||||
const seriesPromise: Promise<PlayableItem>[] = sonarrSeries.map(async (sonarrSeries) => {
|
||||
const itemSonarrDownloads = sonarrDownloads.filter(
|
||||
(d) => d.series.tvdbId === sonarrSeries.tvdbId
|
||||
);
|
||||
const sonarrDownload = itemSonarrDownloads[0];
|
||||
const jellyfinItem = jellyfinLibraryItems.find(
|
||||
(i) => i.ProviderIds?.Tvdb === String(sonarrSeries.tvdbId)
|
||||
);
|
||||
|
||||
const downloadProgress =
|
||||
sonarrDownload?.sizeleft && sonarrDownload?.size
|
||||
? ((sonarrDownload.size - sonarrDownload.sizeleft) / sonarrDownload.size) * 100
|
||||
: undefined;
|
||||
const completionTime = sonarrDownload?.estimatedCompletionTime || undefined;
|
||||
const download = downloadProgress ? { progress: downloadProgress, completionTime } : undefined;
|
||||
|
||||
const nextJellyfinEpisode = jellyfinItem
|
||||
? jellyfinContinueWatching.find((i) => i.SeriesId === jellyfinItem?.Id) ||
|
||||
jellyfinNextUp?.find((i) => i.SeriesId === jellyfinItem?.Id)
|
||||
: undefined;
|
||||
|
||||
const length = nextJellyfinEpisode?.RunTimeTicks
|
||||
? nextJellyfinEpisode.RunTimeTicks / 10_000_000 / 60
|
||||
: undefined;
|
||||
const watchingProgress = nextJellyfinEpisode?.UserData?.PlayedPercentage;
|
||||
const continueWatching =
|
||||
length && watchingProgress && !!nextJellyfinEpisode
|
||||
? { length, progress: watchingProgress }
|
||||
: undefined;
|
||||
|
||||
const tmdbItem = sonarrSeries.tvdbId
|
||||
? await getTmdbSeriesFromTvdbId(sonarrSeries.tvdbId)
|
||||
: undefined;
|
||||
const tmdbId = tmdbItem?.id || undefined;
|
||||
|
||||
const backdropUrl = await getTmdbSeriesBackdrop(tmdbId || 0);
|
||||
const posterUri = tmdbItem?.poster_path || '';
|
||||
|
||||
const playableItem: PlayableItem = {
|
||||
type: 'series' as const,
|
||||
tmdbId: tmdbId || 0,
|
||||
tmdbRating: tmdbItem?.vote_average || 0,
|
||||
backdropUrl: backdropUrl ? TMDB_BACKDROP_SMALL + backdropUrl : '',
|
||||
posterUrl: posterUri ? TMDB_POSTER_SMALL + posterUri : '',
|
||||
download,
|
||||
continueWatching,
|
||||
isPlayed: jellyfinItem?.UserData?.Played || false,
|
||||
jellyfinId: jellyfinItem?.Id,
|
||||
jellyfinItem,
|
||||
sonarrSeries,
|
||||
sonarrDownloads: itemSonarrDownloads,
|
||||
jellyfinEpisodes: jellyfinEpisodes.filter((i) => i.SeriesId === jellyfinItem?.Id),
|
||||
nextJellyfinEpisode
|
||||
};
|
||||
|
||||
return playableItem;
|
||||
});
|
||||
|
||||
const jellyfinSourceItems = jellyfinLibraryItems
|
||||
.filter(
|
||||
(i) =>
|
||||
!radarrMovies.find((m) => m.tmdbId === Number(i.ProviderIds?.Tmdb)) &&
|
||||
!sonarrSeries.find((s) => s.tvdbId === Number(i.ProviderIds?.Tvdb))
|
||||
)
|
||||
.map((jellyfinItem) => {
|
||||
const itemJellyfinEpisodes = jellyfinEpisodes.filter((e) => e.SeriesId === jellyfinItem.Id);
|
||||
const jellyfinNextUpEpisode =
|
||||
jellyfinNextUp?.find((e) => e.SeriesId === jellyfinItem.Id) ||
|
||||
jellyfinContinueWatching.find((e) => e.SeriesId === jellyfinItem.Id);
|
||||
|
||||
const length = jellyfinNextUpEpisode?.RunTimeTicks
|
||||
? jellyfinNextUpEpisode.RunTimeTicks / 10_000_000 / 60
|
||||
: undefined;
|
||||
const watchingProgress = jellyfinNextUpEpisode?.UserData?.PlayedPercentage;
|
||||
const continueWatching =
|
||||
length && watchingProgress && !!jellyfinNextUpEpisode
|
||||
? { length, progress: watchingProgress }
|
||||
: undefined;
|
||||
|
||||
const tmdbId = Number(jellyfinItem.ProviderIds?.Tmdb);
|
||||
const backdropUrl = jellyfinItem.BackdropImageTags?.length
|
||||
? `${get(settings).jellyfin.baseUrl}/Items/${
|
||||
jellyfinItem?.Id
|
||||
}/Images/Backdrop?quality=100&tag=${jellyfinItem?.BackdropImageTags?.[0]}`
|
||||
: '';
|
||||
const posterUri = jellyfinItem.ImageTags?.Primary
|
||||
? `${get(settings).jellyfin.baseUrl}/Items/${
|
||||
jellyfinItem?.Id
|
||||
}/Images/Backdrop?quality=100&tag=${jellyfinItem?.ImageTags?.Primary}`
|
||||
: '';
|
||||
|
||||
const type: TitleType = jellyfinItem.Type === 'Movie' ? 'movie' : 'series';
|
||||
|
||||
const playableItem: PlayableItem = {
|
||||
type,
|
||||
tmdbId,
|
||||
tmdbRating: jellyfinItem.CommunityRating || 0,
|
||||
backdropUrl: backdropUrl,
|
||||
posterUrl: posterUri,
|
||||
continueWatching,
|
||||
isPlayed: jellyfinItem.UserData?.Played || false,
|
||||
jellyfinId: jellyfinItem.Id,
|
||||
jellyfinItem,
|
||||
jellyfinEpisodes: itemJellyfinEpisodes,
|
||||
nextJellyfinEpisode: jellyfinNextUpEpisode
|
||||
};
|
||||
|
||||
return playableItem;
|
||||
});
|
||||
|
||||
await Promise.all([...moviesPromise, ...seriesPromise, ...jellyfinSourceItems]).then((r) =>
|
||||
r.forEach((item) => {
|
||||
items[item.tmdbId] = item;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
itemsArray: Object.values(items),
|
||||
continueWatching: Object.values(items).filter(
|
||||
(i) => i.continueWatching || i.nextJellyfinEpisode
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSettings() {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
settings.subscribe((settings) => {
|
||||
if (settings?.initialised && !resolved) {
|
||||
resolved = true;
|
||||
resolve(settings);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let delayedRefreshTimeout: NodeJS.Timeout;
|
||||
function createLibraryStore() {
|
||||
const { update, set, ...library } = writable<Promise<Library>>(
|
||||
waitForSettings().then(() => getLibrary())
|
||||
); //TODO promise to undefined
|
||||
|
||||
async function filterNotInLibrary<T>(toFilter: T[], getTmdbId: (item: T) => number) {
|
||||
const libraryData = await get(library);
|
||||
|
||||
return toFilter.filter((item) => !(getTmdbId(item) in libraryData.items));
|
||||
}
|
||||
|
||||
return {
|
||||
...library,
|
||||
refresh: async () => getLibrary().then((r) => set(Promise.resolve(r))),
|
||||
refreshIn: async (ms: number) => {
|
||||
clearTimeout(delayedRefreshTimeout);
|
||||
delayedRefreshTimeout = setTimeout(() => {
|
||||
getLibrary().then((r) => set(Promise.resolve(r)));
|
||||
}, ms);
|
||||
},
|
||||
filterNotInLibrary
|
||||
};
|
||||
}
|
||||
|
||||
export const library = createLibraryStore();
|
||||
|
||||
function _createLibraryItemStore(tmdbId: number) {
|
||||
const store = writable<{ loading: boolean; item?: PlayableItem }>({
|
||||
loading: true,
|
||||
item: undefined
|
||||
});
|
||||
|
||||
library.subscribe(async (library) => {
|
||||
const item = (await library).items[tmdbId];
|
||||
store.set({ loading: false, item });
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe
|
||||
};
|
||||
}
|
||||
|
||||
const itemStores: Record<string, ReturnType<typeof _createLibraryItemStore>> = {};
|
||||
|
||||
export type LibraryItemStore = ReturnType<typeof _createLibraryItemStore>;
|
||||
export function createLibraryItemStore(tmdbId: number) {
|
||||
if (!itemStores[tmdbId]) {
|
||||
itemStores[tmdbId] = _createLibraryItemStore(tmdbId);
|
||||
}
|
||||
|
||||
return itemStores[tmdbId];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TitleType } from '$lib/types';
|
||||
import type { TitleId, TitleType } from '$lib/types';
|
||||
import { writable } from 'svelte/store';
|
||||
import TitlePageModal from '../components/TitlePageLayout/TitlePageModal.svelte';
|
||||
|
||||
@@ -61,9 +61,11 @@ function createDynamicModalStack() {
|
||||
export const modalStack = createDynamicModalStack();
|
||||
|
||||
let lastTitleModal: symbol | undefined = undefined;
|
||||
export function openTitleModal(tmdbId: number, type: TitleType) {
|
||||
export function openTitleModal(titleId: TitleId) {
|
||||
if (lastTitleModal) {
|
||||
modalStack.close(lastTitleModal);
|
||||
}
|
||||
lastTitleModal = modalStack.create(TitlePageModal, { tmdbId, type });
|
||||
lastTitleModal = modalStack.create(TitlePageModal, {
|
||||
titleId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export type TitleType = 'movie' | 'series';
|
||||
export type TitleId = {
|
||||
id: number;
|
||||
provider: 'tmdb' | 'tvdb';
|
||||
type: TitleType;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import type { SonarrSeries } from './apis/sonarr/sonarrApi';
|
||||
import type { RadarrMovie } from './apis/radarr/radarrApi';
|
||||
import { settings } from './stores/settings.store';
|
||||
|
||||
export function formatMinutesToTime(minutes: number) {
|
||||
const days = Math.floor(minutes / 60 / 24);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
getJellyfinBackdrop,
|
||||
getJellyfinContinueWatching,
|
||||
getJellyfinNextUp
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { getTmdbMovie, getTmdbPopularMovies } from '$lib/apis/tmdb/tmdbApi';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.svelte';
|
||||
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
|
||||
import Poster from '$lib/components/Poster/Poster.svelte';
|
||||
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
|
||||
import TitleShowcase from '$lib/components/TitleShowcase/TitleShowcase.svelte';
|
||||
import { library } from '$lib/stores/library.store';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { jellyfinItemsStore } from '$lib/stores/data.store';
|
||||
import { log } from '$lib/utils';
|
||||
|
||||
let continueWatchingVisible = true;
|
||||
|
||||
@@ -14,41 +18,46 @@
|
||||
.then((movies) => Promise.all(movies.map((movie) => getTmdbMovie(movie.id || 0))))
|
||||
.then((movies) => movies.filter((m) => !!m).slice(0, 10));
|
||||
|
||||
let continueWatchingProps: Promise<(ComponentProps<Poster> & { runtime: number })[]> = $library
|
||||
.then((libraryData) => libraryData.continueWatching)
|
||||
let nextUpP = getJellyfinNextUp();
|
||||
let continueWatchingP = getJellyfinContinueWatching();
|
||||
|
||||
let nextUpProps = Promise.all([nextUpP, continueWatchingP])
|
||||
.then(([nextUp, continueWatching]) => [
|
||||
...(continueWatching || []),
|
||||
...(nextUp?.filter((i) => !continueWatching?.find((c) => c.SeriesId === i.SeriesId)) || [])
|
||||
])
|
||||
.then((items) =>
|
||||
items.map((item) =>
|
||||
item.type === 'movie'
|
||||
? {
|
||||
type: 'movie',
|
||||
tmdbId: item.tmdbId || 0,
|
||||
jellyfinId: item.jellyfinId,
|
||||
backdropUrl: item.posterUrl || '',
|
||||
title: item.radarrMovie?.title || item.jellyfinItem?.Name || '',
|
||||
subtitle: item.radarrMovie?.genres?.join(', ') || '',
|
||||
progress: item.continueWatching?.progress,
|
||||
runtime: item.radarrMovie?.runtime || 0
|
||||
}
|
||||
: {
|
||||
tmdbId: item.tmdbId || 0,
|
||||
jellyfinId: item.nextJellyfinEpisode?.Id,
|
||||
type: 'series',
|
||||
backdropUrl: item.posterUrl || '',
|
||||
title: item.nextJellyfinEpisode?.Name || item.sonarrSeries?.title || '',
|
||||
subtitle:
|
||||
(item.nextJellyfinEpisode?.IndexNumber &&
|
||||
'Episode ' + item.nextJellyfinEpisode?.IndexNumber) ||
|
||||
item.sonarrSeries?.genres?.join(', ') ||
|
||||
'',
|
||||
progress: item.continueWatching?.progress,
|
||||
runtime: item.nextJellyfinEpisode?.RunTimeTicks
|
||||
? item.nextJellyfinEpisode?.RunTimeTicks / 10_000_000 / 60
|
||||
: item.sonarrSeries?.runtime || 0
|
||||
}
|
||||
Promise.all(
|
||||
items?.map(async (item) => {
|
||||
const parentSeries = await jellyfinItemsStore.promise.then((items) =>
|
||||
items.find((i) => i.Id === item.SeriesId)
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbId: Number(item.ProviderIds?.Tmdb) || Number(parentSeries?.ProviderIds?.Tmdb) || 0,
|
||||
jellyfinId: item.Id,
|
||||
backdropUrl: getJellyfinBackdrop(item),
|
||||
title: item.Name || '',
|
||||
progress: item.UserData?.PlayedPercentage || undefined,
|
||||
runtime: item.RunTimeTicks ? item.RunTimeTicks / 10_000_000 / 60 : 0,
|
||||
...(item.Type === 'Movie'
|
||||
? {
|
||||
type: 'movie',
|
||||
subtitle: item.Genres?.join(', ') || ''
|
||||
}
|
||||
: {
|
||||
type: 'series',
|
||||
subtitle:
|
||||
(item?.IndexNumber && 'Episode ' + item.IndexNumber) ||
|
||||
item.Genres?.join(', ') ||
|
||||
''
|
||||
})
|
||||
} as const;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
continueWatchingProps.then((props) => {
|
||||
nextUpProps.then((props) => {
|
||||
if (props.length === 0) {
|
||||
continueWatchingVisible = false;
|
||||
}
|
||||
@@ -96,17 +105,14 @@
|
||||
<div class="py-8" hidden={!continueWatchingVisible}>
|
||||
<Carousel gradientFromColor="from-stone-950" class="px-4 lg:px-16 2xl:px-32">
|
||||
<div slot="title" class="text-xl font-medium text-zinc-200">Continue Watching</div>
|
||||
{#await continueWatchingProps}
|
||||
{#await nextUpProps}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
<Poster {...prop}>
|
||||
<div slot="bottom-left" class="text-sm font-medium text-zinc-300">
|
||||
{#if prop.progress}
|
||||
{(prop.runtime - (prop.runtime / 100) * prop.progress).toFixed()} Minutes Left
|
||||
{/if}
|
||||
</div>
|
||||
</Poster>
|
||||
<EpisodeCard
|
||||
on:click={() => (window.location.href = `/${prop.type}/${prop.tmdbId}`)}
|
||||
{...prop}
|
||||
/>
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { TmdbApiOpen, type TmdbMovie2, type TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
|
||||
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
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 { library } from '$lib/stores/library.store';
|
||||
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 { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
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;
|
||||
resolve(data.data || []);
|
||||
});
|
||||
});
|
||||
|
||||
const fetchCardProps = async (items: TmdbMovie2[] | TmdbSeries2[]) =>
|
||||
Promise.all(
|
||||
(
|
||||
await ($settings.discover.excludeLibraryItems
|
||||
? library.filterNotInLibrary(items, (t) => t.id || 0)
|
||||
: items)
|
||||
).map(fetchCardTmdbProps)
|
||||
const fetchCardProps = async (
|
||||
items: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
id?: number;
|
||||
vote_average?: number;
|
||||
number_of_seasons?: number;
|
||||
first_air_date?: string;
|
||||
}[],
|
||||
type: TitleType | undefined = undefined
|
||||
): 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(
|
||||
filtered.map(async (item) => {
|
||||
const backdropUri = await getTmdbMovieBackdrop(item.id || 0);
|
||||
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
|
||||
} as const;
|
||||
})
|
||||
).then((props) => props.filter((p) => p.backdropUrl));
|
||||
};
|
||||
|
||||
const fetchTrendingProps = () =>
|
||||
TmdbApiOpen.get('/3/trending/all/{time_window}', {
|
||||
@@ -89,7 +125,7 @@
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.results || [])
|
||||
.then(fetchCardProps);
|
||||
.then((i) => fetchCardProps(i, 'series'));
|
||||
|
||||
const fetchDigitalReleases = () =>
|
||||
TmdbApiOpen.get('/3/discover/movie', {
|
||||
@@ -120,7 +156,11 @@
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.results || [])
|
||||
.then(fetchCardProps);
|
||||
.then((i) => fetchCardProps(i, 'series'));
|
||||
|
||||
function parseIncludedLanguages(includedLanguages: string) {
|
||||
return includedLanguages.replace(' ', '').split(',').join('|');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -141,7 +181,7 @@
|
||||
<CarouselPlaceholderItems size="lg" />
|
||||
{:then props}
|
||||
{#each props as prop (prop.tmdbId)}
|
||||
<Card size="lg" {...prop} />
|
||||
<Poster {...prop} size="lg" />
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
@@ -149,7 +189,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
|
||||
@@ -170,7 +210,7 @@
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop (prop.tmdbId)}
|
||||
<Card {...prop} />
|
||||
<Poster {...prop} />
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
@@ -179,7 +219,7 @@
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop (prop.tmdbId)}
|
||||
<Card {...prop} />
|
||||
<Poster {...prop} />
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
@@ -193,7 +233,7 @@
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop (prop.tmdbId)}
|
||||
<Card {...prop} />
|
||||
<Poster {...prop} />
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
@@ -202,7 +242,7 @@
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop (prop.tmdbId)}
|
||||
<Card {...prop} />
|
||||
<Poster {...prop} />
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
|
||||
@@ -1,190 +1,69 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/Card/Card.svelte';
|
||||
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
|
||||
import {
|
||||
getJellyfinBackdrop,
|
||||
getJellyfinPosterUrl,
|
||||
type JellyfinItem
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Carousel from '$lib/components/Carousel/Carousel.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 Poster from '$lib/components/Poster/Poster.svelte';
|
||||
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
|
||||
import { PLACEHOLDER_BACKDROP } from '$lib/constants';
|
||||
import { jellyfinItemsStore, servarrDownloadsStore } from '$lib/stores/data.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import classNames from 'classnames';
|
||||
import { CaretDown, Gear } from 'radix-icons-svelte';
|
||||
import { ChevronRight } from 'radix-icons-svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
let itemsVisible: 'all' | 'movies' | 'shows' = 'all';
|
||||
let sortBy: 'added' | 'rating' | 'release' | 'size' | 'name' = 'name';
|
||||
|
||||
let loading = true;
|
||||
let noItems = false;
|
||||
let searchInput: HTMLInputElement | undefined;
|
||||
let searchInputValue = '';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LibraryItems from './LibraryItems.svelte';
|
||||
import { capitalize } from '$lib/utils';
|
||||
import LazyImg from '$lib/components/LazyImg.svelte';
|
||||
|
||||
let openNextUpTab: 'downloading' | 'nextUp' = 'downloading';
|
||||
let openTab: 'available' | 'watched' | 'unavailable' = 'available';
|
||||
let noItems = false;
|
||||
|
||||
let items: PlayableItem[] = [];
|
||||
let showcasePromise: Promise<JellyfinItem | undefined> = jellyfinItemsStore.promise.then(
|
||||
(items) =>
|
||||
items
|
||||
?.slice()
|
||||
?.sort((a, b) =>
|
||||
(a.DateCreated || a.DateLastMediaAdded || '') <
|
||||
(b.DateCreated || b.DateLastMediaAdded || '')
|
||||
? 1
|
||||
: -1
|
||||
)?.[0]
|
||||
);
|
||||
|
||||
let downloadingProps: ComponentProps<Card>[] = [];
|
||||
let availableProps: ComponentProps<Card>[] = [];
|
||||
let watchedProps: ComponentProps<Card>[] = [];
|
||||
let unavailableProps: ComponentProps<Card>[] = [];
|
||||
|
||||
let nextUpProps: ComponentProps<Card>[] = [];
|
||||
|
||||
$: if (items.length) updateComponentProps(searchInputValue);
|
||||
$: if (!downloadingProps.length && nextUpProps.length) openNextUpTab = 'nextUp';
|
||||
|
||||
library.subscribe(async (libraryPromise) => {
|
||||
const libraryData = await libraryPromise;
|
||||
items = libraryData.itemsArray;
|
||||
loading = false;
|
||||
noItems = !items.length;
|
||||
});
|
||||
|
||||
function getComponentProps(item: PlayableItem) {
|
||||
let props: ComponentProps<Card> | undefined;
|
||||
|
||||
const series = item.sonarrSeries;
|
||||
const movie = item.radarrMovie;
|
||||
|
||||
if (item.type === 'series') {
|
||||
props = {
|
||||
size: 'dynamic',
|
||||
let downloadProps: ComponentProps<Poster>[] = [];
|
||||
$: {
|
||||
const sonarrProps: ComponentProps<Poster>[] =
|
||||
$servarrDownloadsStore.sonarrDownloads?.map((item) => ({
|
||||
tvdbId: item.series.tvdbId,
|
||||
title: item.series.title || '',
|
||||
subtitle:
|
||||
`S${item.episode?.seasonNumber}E${item.episode?.episodeNumber} • ` +
|
||||
capitalize(item.status || ''),
|
||||
type: 'series',
|
||||
tmdbId: item.tmdbId,
|
||||
title: series?.title || item.jellyfinItem?.Name || '',
|
||||
genres: series?.genres || [],
|
||||
backdropUrl: item.backdropUrl,
|
||||
rating: series?.ratings?.value || series?.ratings?.value || item.tmdbRating || 0,
|
||||
seasons: series?.seasons?.length || 0,
|
||||
progress: item.nextJellyfinEpisode?.UserData?.PlayedPercentage || undefined
|
||||
};
|
||||
} else if (item.type === 'movie') {
|
||||
props = {
|
||||
size: 'dynamic',
|
||||
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
|
||||
backdropUrl: item.series.images?.find((i) => i.coverType === 'poster')?.url || '',
|
||||
orientation: 'portrait'
|
||||
})) || [];
|
||||
|
||||
const radarrProps: ComponentProps<Poster>[] =
|
||||
$servarrDownloadsStore.radarrDownloads?.map((item) => ({
|
||||
tmdbId: item.movie.tmdbId,
|
||||
title: item.movie.title || '',
|
||||
subtitle: capitalize(item.status || ''),
|
||||
type: 'movie',
|
||||
tmdbId: item.tmdbId,
|
||||
title: movie?.title || item.jellyfinItem?.Name || '',
|
||||
genres: movie?.genres || [],
|
||||
backdropUrl: item.backdropUrl,
|
||||
rating: movie?.ratings?.tmdb?.value || movie?.ratings?.imdb?.value || 0,
|
||||
runtimeMinutes: movie?.runtime || 0,
|
||||
progress: item.jellyfinItem?.UserData?.PlayedPercentage || undefined
|
||||
};
|
||||
} else props = undefined;
|
||||
backdropUrl: item.movie.images?.find((i) => i.coverType === 'poster')?.url || '',
|
||||
progress: 100 * (((item.size || 0) - (item.sizeleft || 0)) / (item.size || 1)),
|
||||
orientation: 'portrait'
|
||||
})) || [];
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
function sortItems(items: PlayableItem[], criteria: typeof sortBy) {
|
||||
return items.sort((a, b) => {
|
||||
switch (criteria) {
|
||||
case 'added':
|
||||
return (b.radarrMovie?.added || b.sonarrSeries?.added || '') <
|
||||
(a.radarrMovie?.added || a.sonarrSeries?.added || '')
|
||||
? -1
|
||||
: 1;
|
||||
case 'rating':
|
||||
return (b.tmdbRating || 0) - (a.tmdbRating || 0);
|
||||
case 'release':
|
||||
return (b.radarrMovie?.inCinemas || b.sonarrSeries?.firstAired || '') <
|
||||
(a.radarrMovie?.inCinemas || a.sonarrSeries?.firstAired || '')
|
||||
? -1
|
||||
: 1;
|
||||
case 'size':
|
||||
return (
|
||||
(b.radarrMovie?.sizeOnDisk || b.sonarrSeries?.statistics?.sizeOnDisk || 0) -
|
||||
(a.radarrMovie?.sizeOnDisk || a.sonarrSeries?.statistics?.sizeOnDisk || 0)
|
||||
);
|
||||
case 'name':
|
||||
return (b.radarrMovie?.title?.toLowerCase() ||
|
||||
b.sonarrSeries?.title?.toLowerCase() ||
|
||||
'') >
|
||||
(a.radarrMovie?.title?.toLowerCase() || a.sonarrSeries?.title?.toLowerCase() || '')
|
||||
? -1
|
||||
: 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function updateComponentProps(searchInputValue: string) {
|
||||
const nextUpItems = sortItems([...items], 'added')
|
||||
.filter(
|
||||
(item) =>
|
||||
(!item.isPlayed && item?.radarrMovie?.isAvailable && item?.radarrMovie?.movieFile) ||
|
||||
item?.sonarrSeries?.seasons?.find((season) => !!season?.statistics?.episodeFileCount)
|
||||
)
|
||||
.splice(0, 6);
|
||||
|
||||
const libraryItems = sortItems([...items], sortBy).filter((item) => {
|
||||
if (searchInputValue) {
|
||||
return (
|
||||
item.radarrMovie?.title?.toLowerCase().includes(searchInputValue.toLowerCase()) ||
|
||||
item.sonarrSeries?.title?.toLowerCase().includes(searchInputValue.toLowerCase())
|
||||
);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
downloadingProps = [];
|
||||
availableProps = [];
|
||||
watchedProps = [];
|
||||
unavailableProps = [];
|
||||
nextUpProps = [];
|
||||
|
||||
for (let item of libraryItems) {
|
||||
let props = getComponentProps(item);
|
||||
|
||||
if (!props) continue;
|
||||
|
||||
if (item.download) {
|
||||
downloadingProps.push({
|
||||
...props,
|
||||
progress: item.download.progress,
|
||||
completionTime: item.download.completionTime
|
||||
});
|
||||
} else if (item.isPlayed) {
|
||||
watchedProps.push({ ...props, available: false });
|
||||
} else if (
|
||||
(item.type === 'movie' && item.jellyfinItem?.RunTimeTicks) ||
|
||||
item.jellyfinEpisodes?.length
|
||||
) {
|
||||
availableProps.push(props);
|
||||
} else {
|
||||
unavailableProps.push({ ...props, available: false });
|
||||
}
|
||||
}
|
||||
|
||||
for (let item of nextUpItems) {
|
||||
let props = getComponentProps(item);
|
||||
|
||||
if (!props) continue;
|
||||
|
||||
nextUpProps.push(props);
|
||||
}
|
||||
|
||||
downloadingProps = downloadingProps;
|
||||
availableProps = availableProps;
|
||||
watchedProps = watchedProps;
|
||||
unavailableProps = unavailableProps;
|
||||
nextUpProps = nextUpProps;
|
||||
}
|
||||
|
||||
function handleShortcuts(event: KeyboardEvent) {
|
||||
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
searchInput?.focus();
|
||||
}
|
||||
downloadProps = [...(sonarrProps || []), ...(radarrProps || [])];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleShortcuts} />
|
||||
|
||||
{#if noItems}
|
||||
<div
|
||||
class="h-screen flex items-center justify-center text-zinc-500 p-8"
|
||||
@@ -206,62 +85,58 @@
|
||||
}}
|
||||
out:fade|global={{ duration: $settings.animationDuration }}
|
||||
>
|
||||
<div
|
||||
style={"background-image: url('" +
|
||||
TMDB_IMAGES_ORIGINAL +
|
||||
(downloadingProps[0]?.backdropUrl || nextUpProps[0]?.backdropUrl) +
|
||||
"');"}
|
||||
class="absolute inset-0 h-[50vh] bg-center bg-cover"
|
||||
>
|
||||
<div class="relative pt-24">
|
||||
{#await showcasePromise then showcase}
|
||||
<LazyImg
|
||||
src={(showcase && getJellyfinBackdrop(showcase)) || PLACEHOLDER_BACKDROP}
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
{/await}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-stone-950 to-80% to-darken" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pt-24 pb-4 px-2 md:px-8 relative bg-center bg-cover"
|
||||
in:fade|global={{
|
||||
duration: $settings.animationDuration,
|
||||
delay: $settings.animationDuration
|
||||
}}
|
||||
out:fade|global={{ duration: $settings.animationDuration }}
|
||||
>
|
||||
<div class="relative grid grid-cols-3 grid-rows-3 z-[1] max-w-screen-2xl mx-auto">
|
||||
<div class="col-start-1 row-start-2 row-span-2 col-span-3 flex justify-end flex-col">
|
||||
{#if downloadingProps.length || nextUpProps.length}
|
||||
<Carousel>
|
||||
<div slot="title" class="flex items-center gap-6 font-medium text-xl text-zinc-400">
|
||||
{#if downloadingProps.length}
|
||||
<button
|
||||
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
|
||||
'text-zinc-200': openNextUpTab === 'downloading'
|
||||
})}
|
||||
on:click={() => (openNextUpTab = 'downloading')}
|
||||
>
|
||||
Download Queue
|
||||
</button>
|
||||
{/if}
|
||||
{#if nextUpProps.length}
|
||||
<button
|
||||
class={classNames('hover:text-zinc-300 selectable rounded px-1 -mx-1', {
|
||||
'text-zinc-200': openNextUpTab === 'nextUp'
|
||||
})}
|
||||
on:click={() => (openNextUpTab = 'nextUp')}
|
||||
>
|
||||
Next Up
|
||||
</button>
|
||||
{/if}
|
||||
<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">
|
||||
{#await showcasePromise}
|
||||
<div class="w-32 aspect-[2/3] placeholder rounded-lg shadow-lg" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="placeholder-text w-20">Placeholder</div>
|
||||
<div class="placeholder-text w-[50vw] text-3xl sm:text-4xl md:text-5xl">
|
||||
Placeholder
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="placeholder-text w-28 h-10" />
|
||||
<div class="placeholder-text w-28 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
{#if downloadingProps.length && openNextUpTab === 'downloading'}
|
||||
{#each downloadingProps as props (props.tmdbId)}
|
||||
<Card {...props} size="md" />
|
||||
{/each}
|
||||
{:else if openNextUpTab === 'nextUp'}
|
||||
{#each nextUpProps as props (props.tmdbId)}
|
||||
<Card {...props} size="md" />
|
||||
{/each}
|
||||
{/if}
|
||||
</Carousel>
|
||||
{/if}
|
||||
{:then showcase}
|
||||
<div
|
||||
style={"background-image: url('" +
|
||||
(showcase ? getJellyfinPosterUrl(showcase) : '') +
|
||||
"');"}
|
||||
class="w-32 aspect-[2/3] rounded-lg bg-center bg-cover flex-shrink-0 shadow-lg"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-zinc-400 font-medium">Latest Addition</p>
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-semibold">
|
||||
{showcase?.Name}
|
||||
</h1>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button
|
||||
type="primary"
|
||||
on:click={() => showcase?.Id && playerState.streamJellyfinId(showcase?.Id)}
|
||||
>
|
||||
Play<ChevronRight size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${showcase?.Type === 'Movie' ? 'movie' : 'series'}/${
|
||||
showcase?.ProviderIds?.Tmdb || showcase?.ProviderIds?.Tvdb
|
||||
}`}
|
||||
>
|
||||
<span>{$_('titleShowcase.details')}</span><ChevronRight size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,70 +149,18 @@
|
||||
}}
|
||||
out:fade|global={{ duration: $settings.animationDuration }}
|
||||
>
|
||||
<div class="max-w-screen-2xl m-auto 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={() => (openTab = 'available')}
|
||||
>
|
||||
{$_('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')}
|
||||
>
|
||||
{$_('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')}
|
||||
>
|
||||
{$_('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>
|
||||
</IconButton>
|
||||
<IconButton>
|
||||
<Gear size={20} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div
|
||||
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
{#each [...Array(20).keys()] as index (index)}
|
||||
<CardPlaceholder size="dynamic" {index} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-x-2 sm:gap-x-4 gap-y-4 sm:gap-y-8 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
{#each { available: availableProps, watched: watchedProps, unavailable: unavailableProps }[openTab] as props (props.tmdbId)}
|
||||
<Card {...props} />
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center text-zinc-500">No items.</div>
|
||||
{/each}
|
||||
<div class="max-w-screen-2xl m-auto flex flex-col gap-12">
|
||||
{#if downloadProps?.length}
|
||||
<div>
|
||||
<Carousel heading="Downloading">
|
||||
{#each downloadProps as props}
|
||||
<Poster {...props} />
|
||||
{/each}
|
||||
</Carousel>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<LibraryItems />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
318
src/routes/library/LibraryItems.svelte
Normal file
318
src/routes/library/LibraryItems.svelte
Normal file
@@ -0,0 +1,318 @@
|
||||
<script lang="ts">
|
||||
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import classNames from 'classnames';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { CaretDown, ChevronDown, Cross2, Gear, MagnifyingGlass } from 'radix-icons-svelte';
|
||||
import CardPlaceholder from '$lib/components/Card/CardPlaceholder.svelte';
|
||||
import { tick, type ComponentProps } from 'svelte';
|
||||
import Poster from '$lib/components/Poster/Poster.svelte';
|
||||
import { getJellyfinPosterUrl, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { getRadarrPosterUrl, type RadarrMovie } from '$lib/apis/radarr/radarrApi';
|
||||
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';
|
||||
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';
|
||||
let page = 0;
|
||||
|
||||
let searchVisible = false;
|
||||
|
||||
let searchInput: HTMLInputElement | undefined;
|
||||
|
||||
let libraryLoading = true;
|
||||
let posterProps: ComponentProps<Poster>[] = [];
|
||||
let hasMore = true;
|
||||
$: loadPosterProps(openTab, page, $sortBy, $sortOrder, searchQuery);
|
||||
|
||||
function getPropsFromJellyfinItem(item: JellyfinItem): ComponentProps<Poster> {
|
||||
return {
|
||||
tmdbId: Number(item.ProviderIds?.Tmdb) || 0,
|
||||
jellyfinId: item.Id,
|
||||
title: item.Name || undefined,
|
||||
subtitle: item.Genres?.join(', ') || undefined,
|
||||
backdropUrl: getJellyfinPosterUrl(item, 80),
|
||||
size: 'dynamic',
|
||||
...(item.Type === 'Movie' ? { type: 'movie' } : { type: 'series' }),
|
||||
orientation: 'portrait',
|
||||
rating: item.CommunityRating || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function getPropsfromServarrItem(item: RadarrMovie | SonarrSeries): ComponentProps<Poster> {
|
||||
if ((<any>item)?.tmdbId) {
|
||||
const movie = item as RadarrMovie;
|
||||
|
||||
return {
|
||||
tmdbId: movie.tmdbId || 0,
|
||||
title: movie.title || undefined,
|
||||
subtitle: movie.genres?.join(', ') || undefined,
|
||||
backdropUrl: getRadarrPosterUrl(movie),
|
||||
size: 'dynamic',
|
||||
type: 'movie',
|
||||
orientation: 'portrait',
|
||||
rating: movie.ratings?.tmdb?.value || undefined
|
||||
};
|
||||
} else {
|
||||
const series = item as SonarrSeries;
|
||||
|
||||
return {
|
||||
tvdbId: series.tvdbId || 0,
|
||||
title: item.title || undefined,
|
||||
subtitle: item.genres?.join(', ') || undefined,
|
||||
backdropUrl: getSonarrPosterUrl(series),
|
||||
size: 'dynamic',
|
||||
type: 'series',
|
||||
tmdbId: undefined,
|
||||
orientation: 'portrait',
|
||||
rating: series.ratings?.value || undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosterProps(
|
||||
tab: typeof openTab,
|
||||
page: number,
|
||||
sort: SortBy,
|
||||
order: SortOrder,
|
||||
searchQuery: string
|
||||
) {
|
||||
if (page === 0) posterProps = [];
|
||||
|
||||
const jellyfinItemsPromise = jellyfinItemsStore.promise
|
||||
.then((i) => i.filter((i) => i.Name?.toLowerCase().includes(searchQuery.toLowerCase())) || [])
|
||||
.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') {
|
||||
props = await jellyfinItemsPromise.then((items) =>
|
||||
items.filter((i) => !i.UserData?.Played).map((item) => getPropsFromJellyfinItem(item))
|
||||
);
|
||||
} else if (tab === 'watched') {
|
||||
props = await jellyfinItemsPromise.then((items) =>
|
||||
items.filter((i) => i.UserData?.Played).map((item) => getPropsFromJellyfinItem(item))
|
||||
);
|
||||
} else if (tab === 'unavailable') {
|
||||
props = await Promise.all([
|
||||
radarrMoviesStore.promise,
|
||||
sonarrSeriesStore.promise,
|
||||
jellyfinItemsPromise
|
||||
])
|
||||
.then(([radarr, sonarr, jellyfinItems]) => ({
|
||||
items: [...radarr, ...sonarr],
|
||||
jellyfinItems
|
||||
}))
|
||||
.then(({ items, jellyfinItems }) =>
|
||||
items
|
||||
.filter((i) => i.title?.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.filter(
|
||||
(i) =>
|
||||
!jellyfinItems.find((j) => j.ProviderIds?.Tmdb === String((<any>i).tmdbId || '-'))
|
||||
)
|
||||
.filter(
|
||||
(i) =>
|
||||
!jellyfinItems.find((j) => j.ProviderIds?.Tvdb === String((<any>i).tvdbId || '-'))
|
||||
)
|
||||
.map((i) => getPropsfromServarrItem(i))
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function handleOpenSearch() {
|
||||
searchVisible = true;
|
||||
await tick();
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
async function handleCloseSearch() {
|
||||
searchVisible = false;
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
async function handleShortcuts(event: KeyboardEvent) {
|
||||
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
handleOpenSearch();
|
||||
} else if (event.key === 'Escape') {
|
||||
handleCloseSearch();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleShortcuts} />
|
||||
|
||||
{#if searchVisible}
|
||||
<div
|
||||
transition:fly={{ y: 5, duration: 200 }}
|
||||
class="fixed top-20 left-1/2 w-80 -ml-40 z-10 bg-[#33333388] backdrop-blur-xl rounded-full
|
||||
flex items-center text-zinc-300"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-4 flex items-center justify-center">
|
||||
<MagnifyingGlass size={20} />
|
||||
</div>
|
||||
<div class="absolute inset-y-0 right-4 flex items-center justify-center">
|
||||
<IconButton on:click={handleCloseSearch}>
|
||||
<Cross2 size={20} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={searchQuery}
|
||||
placeholder="Seach in library"
|
||||
class="appearance-none mx-2.5 my-2.5 px-10 bg-transparent outline-none placeholder:text-zinc-400 font-medium w-full"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<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>
|
||||
</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 on:click={handleOpenSearch}>
|
||||
<MagnifyingGlass size={20} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
{#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>
|
||||
|
||||
{#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>
|
||||
@@ -9,99 +9,120 @@
|
||||
import Card from '$lib/components/Card/Card.svelte';
|
||||
import { fetchCardTmdbProps } from '$lib/components/Card/card';
|
||||
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
|
||||
import { modalStack } from '$lib/stores/modal.store';
|
||||
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import RequestModal from '$lib/components/RequestModal/RequestModal.svelte';
|
||||
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
|
||||
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
|
||||
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
|
||||
import { createLibraryItemStore, library } from '$lib/stores/library.store';
|
||||
import {
|
||||
createJellyfinItemStore,
|
||||
createRadarrDownloadStore,
|
||||
createRadarrMovieStore
|
||||
} from '$lib/stores/data.store';
|
||||
import { modalStack } from '$lib/stores/modal.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import { formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { Archive, ChevronRight, Plus } from 'radix-icons-svelte';
|
||||
import { Archive, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
|
||||
const tmdbUrl = 'https://www.themoviedb.org/movie/' + tmdbId;
|
||||
const data = loadInitialPageData();
|
||||
|
||||
const itemStore = createLibraryItemStore(tmdbId);
|
||||
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
|
||||
const radarrMovieStore = createRadarrMovieStore(tmdbId);
|
||||
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
|
||||
|
||||
const tmdbMoviePromise = getTmdbMovie(tmdbId);
|
||||
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
|
||||
Promise.all(
|
||||
m?.credits?.cast?.slice(0, 20).map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.character || m.known_for_department || ''
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
async function loadInitialPageData() {
|
||||
const tmdbMoviePromise = getTmdbMovie(tmdbId);
|
||||
|
||||
function play() {
|
||||
if ($itemStore.item?.jellyfinItem?.Id)
|
||||
playerState.streamJellyfinId($itemStore.item?.jellyfinItem?.Id);
|
||||
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
const tmdbSimilarProps = getTmdbMovieSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
|
||||
const castPropsPromise: Promise<ComponentProps<PeopleCard>[]> = tmdbMoviePromise.then((m) =>
|
||||
Promise.all(
|
||||
m?.credits?.cast?.slice(0, 20).map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.character || m.known_for_department || ''
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbMovie: await tmdbMoviePromise,
|
||||
tmdbRecommendationProps: await tmdbRecommendationProps,
|
||||
tmdbSimilarProps: await tmdbSimilarProps,
|
||||
castProps: await castPropsPromise
|
||||
};
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await library.refresh();
|
||||
function play() {
|
||||
if ($jellyfinItemStore.item?.Id) playerState.streamJellyfinId($jellyfinItemStore.item?.Id);
|
||||
}
|
||||
|
||||
async function refreshRadarr() {
|
||||
await radarrMovieStore.refreshIn();
|
||||
}
|
||||
|
||||
let addToRadarrLoading = false;
|
||||
function addToRadarr() {
|
||||
addToRadarrLoading = true;
|
||||
addMovieToRadarr(tmdbId)
|
||||
.then(refresh)
|
||||
.then(refreshRadarr)
|
||||
.finally(() => (addToRadarrLoading = false));
|
||||
}
|
||||
|
||||
function openRequestModal() {
|
||||
if (!$itemStore.item?.radarrMovie?.id) return;
|
||||
if (!$radarrMovieStore.item?.id) return;
|
||||
|
||||
modalStack.create(RequestModal, {
|
||||
radarrId: $itemStore.item?.radarrMovie?.id
|
||||
radarrId: $radarrMovieStore.item?.id
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await tmdbMoviePromise then movie}
|
||||
{#await data}
|
||||
<TitlePageLayout {isModal} {handleCloseModal} />
|
||||
{:then { tmdbMovie, tmdbRecommendationProps, tmdbSimilarProps, castProps }}
|
||||
{@const movie = tmdbMovie}
|
||||
<TitlePageLayout
|
||||
titleInformation={{
|
||||
tmdbId,
|
||||
type: 'movie',
|
||||
title: movie?.title || 'Movie',
|
||||
backdropUriCandidates: movie?.images?.backdrops?.map((b) => b.file_path || '') || [],
|
||||
posterPath: movie?.poster_path || '',
|
||||
tagline: movie?.tagline || movie?.title || '',
|
||||
overview: movie?.overview || ''
|
||||
}}
|
||||
{isModal}
|
||||
{tmdbId}
|
||||
{handleCloseModal}
|
||||
type="movie"
|
||||
title={movie?.title || 'Movie'}
|
||||
backdropUriCandidates={movie?.images?.backdrops?.map((b) => b.file_path || '') || []}
|
||||
posterPath={movie?.poster_path || ''}
|
||||
tagline={movie?.tagline || movie?.title || ''}
|
||||
overview={movie?.overview || ''}
|
||||
>
|
||||
<svelte:fragment slot="title-info-1"
|
||||
>{new Date(movie?.release_date || Date.now()).getFullYear()}</svelte:fragment
|
||||
>
|
||||
<svelte:fragment slot="title-info-2">
|
||||
{@const progress = $itemStore.item?.continueWatching?.progress}
|
||||
<svelte:fragment slot="title-info">
|
||||
{new Date(movie?.release_date || Date.now()).getFullYear()}
|
||||
<DotFilled />
|
||||
{@const progress = $jellyfinItemStore.item?.UserData?.PlayedPercentage}
|
||||
{#if progress}
|
||||
{progress.toFixed()} min left
|
||||
{:else}
|
||||
{movie?.runtime} min
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title-info-3">
|
||||
<DotFilled />
|
||||
<a href={tmdbUrl} target="_blank">{movie?.vote_average?.toFixed(1)} TMDB</a>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="episodes-carousel">
|
||||
{@const progress = $itemStore.item?.continueWatching?.progress}
|
||||
{@const progress = $jellyfinItemStore.item?.UserData?.PlayedPercentage}
|
||||
{#if progress}
|
||||
<div
|
||||
class={classNames('px-2 sm:px-4 lg:px-8', {
|
||||
@@ -117,19 +138,21 @@
|
||||
<div
|
||||
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
|
||||
>
|
||||
{#if $itemStore.loading}
|
||||
{#if $jellyfinItemStore.loading || $radarrMovieStore.loading}
|
||||
<div class="placeholder h-10 w-48 rounded-xl" />
|
||||
{:else}
|
||||
<OpenInButton title={movie?.title} {itemStore} type="movie" {tmdbId} />
|
||||
{#if $itemStore.item?.jellyfinItem}
|
||||
{@const jellyfinItem = $jellyfinItemStore.item}
|
||||
{@const radarrMovie = $radarrMovieStore.item}
|
||||
<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 !$itemStore.item?.radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey}
|
||||
{:else if !radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey}
|
||||
<Button type="primary" disabled={addToRadarrLoading} on:click={addToRadarr}>
|
||||
<span>Add to Radarr</span><Plus size={20} />
|
||||
</Button>
|
||||
{:else if $itemStore.item?.radarrMovie}
|
||||
{:else if radarrMovie}
|
||||
<Button type="primary" on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Movie</span><Plus size={20} />
|
||||
</Button>
|
||||
@@ -192,85 +215,35 @@
|
||||
{movie?.runtime} Minutes
|
||||
</h2>
|
||||
</div>
|
||||
<!-- {#if series?.first_air_date}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">First Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(series?.first_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if series?.next_episode_to_air}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Next Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(series.next_episode_to_air?.air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{:else if series?.last_air_date}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Last Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(series.last_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Networks</p>
|
||||
<h2 class="font-medium">{series?.networks?.map((n) => n.name).join(', ')}</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Episode Run Time</p>
|
||||
<h2 class="font-medium">{series?.episode_run_time} Minutes</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Spoken Languages</p>
|
||||
<h2 class="font-medium">
|
||||
{series?.spoken_languages?.map((l) => capitalize(l.name || '')).join(', ')}
|
||||
</h2>
|
||||
</div> -->
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="servarr-components">
|
||||
{#if !$itemStore.loading && $itemStore.item}
|
||||
{@const item = $itemStore.item}
|
||||
{#if item.radarrMovie?.movieFile?.quality}
|
||||
{@const radarrMovie = $radarrMovieStore.item}
|
||||
{#if radarrMovie}
|
||||
{#if radarrMovie?.movieFile?.quality}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Video</p>
|
||||
<h2 class="font-medium">
|
||||
{item.radarrMovie?.movieFile?.quality.quality?.name}
|
||||
{radarrMovie?.movieFile?.quality.quality?.name}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.radarrMovie?.movieFile?.size}
|
||||
{#if radarrMovie?.movieFile?.size}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Size On Disk</p>
|
||||
<h2 class="font-medium">
|
||||
{formatSize(item.radarrMovie?.movieFile?.size || 0)}
|
||||
{formatSize(radarrMovie?.movieFile?.size || 0)}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $itemStore.item?.download}
|
||||
{#if $radarrDownloadStore.downloads?.length}
|
||||
{@const download = $radarrDownloadStore.downloads[0]}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Download Completed In</p>
|
||||
<p class="text-zinc-400 text-sm">Downloaded In</p>
|
||||
<h2 class="font-medium">
|
||||
{$itemStore.item?.download.completionTime
|
||||
{download?.estimatedCompletionTime
|
||||
? formatMinutesToTime(
|
||||
(new Date($itemStore.item?.download.completionTime).getTime() - Date.now()) /
|
||||
1000 /
|
||||
60
|
||||
(new Date(download.estimatedCompletionTime).getTime() - Date.now()) / 1000 / 60
|
||||
)
|
||||
: 'Stalled'}
|
||||
</h2>
|
||||
@@ -285,16 +258,7 @@
|
||||
<span class="mr-2">Manage</span><Archive size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="flex-1 flex justify-between py-2 gap-4 items-start sm:items-center flex-col sm:flex-row"
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-medium text-lg">No sources found</h1>
|
||||
<p class="text-sm text-zinc-400">Check your source settings</p>
|
||||
</div>
|
||||
<Button type="secondary"><span>Add to Sonarr</span><Plus size={20} /></Button>
|
||||
</div> -->
|
||||
{:else if $itemStore.loading}
|
||||
{:else if $radarrMovieStore.loading}
|
||||
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
|
||||
<div class="placeholder h-10 w-40 rounded-xl" />
|
||||
<div class="placeholder h-10 w-40 rounded-xl" />
|
||||
@@ -336,11 +300,3 @@
|
||||
</svelte:fragment>
|
||||
</TitlePageLayout>
|
||||
{/await}
|
||||
|
||||
<!-- {#if requestModalVisible} -->
|
||||
<!-- {@const radarrMovie = $itemStore.item?.radarrMovie} -->
|
||||
<!-- {#if radarrMovie && radarrMovie.id} -->
|
||||
<!-- <RequestModal modalProps={requestModalProps} radarrId={radarrMovie.id} /> -->
|
||||
<!-- {/if} -->
|
||||
<!-- {/if} -->
|
||||
<!-- -->
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { TitleId } from '$lib/types';
|
||||
import type { PageData } from './$types';
|
||||
import SeriesPage from './SeriesPage.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let tmdbId: number;
|
||||
$: tmdbId = Number(data.tmdbId);
|
||||
let titleId: TitleId;
|
||||
$: titleId = { provider: 'tmdb', id: data.tmdbId, type: 'series' };
|
||||
</script>
|
||||
|
||||
{#key tmdbId}
|
||||
<SeriesPage {tmdbId} />
|
||||
{#key titleId}
|
||||
<SeriesPage {titleId} />
|
||||
{/key}
|
||||
|
||||
@@ -2,6 +2,6 @@ import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
return {
|
||||
tmdbId: params.id
|
||||
tmdbId: Number(params.id)
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
|
||||
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import { addSeriesToSonarr, type SonarrSeries } from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
getTmdbIdFromTvdbId,
|
||||
getTmdbSeries,
|
||||
getTmdbSeriesRecommendations,
|
||||
getTmdbSeriesSeasons,
|
||||
@@ -14,119 +15,162 @@
|
||||
import CarouselPlaceholderItems from '$lib/components/Carousel/CarouselPlaceholderItems.svelte';
|
||||
import UiCarousel from '$lib/components/Carousel/UICarousel.svelte';
|
||||
import EpisodeCard from '$lib/components/EpisodeCard/EpisodeCard.svelte';
|
||||
import { modalStack } from '$lib/stores/modal.store';
|
||||
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
|
||||
import SeriesRequestModal from '$lib/components/RequestModal/SeriesRequestModal.svelte';
|
||||
import OpenInButton from '$lib/components/TitlePageLayout/OpenInButton.svelte';
|
||||
import TitlePageLayout from '$lib/components/TitlePageLayout/TitlePageLayout.svelte';
|
||||
import { playerState } from '$lib/components/VideoPlayer/VideoPlayer';
|
||||
import { createLibraryItemStore, library } from '$lib/stores/library.store';
|
||||
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
|
||||
import {
|
||||
createJellyfinItemStore,
|
||||
createSonarrDownloadStore,
|
||||
createSonarrSeriesStore
|
||||
} from '$lib/stores/data.store';
|
||||
import { modalStack } from '$lib/stores/modal.store';
|
||||
import { settings } from '$lib/stores/settings.store';
|
||||
import type { TitleId } from '$lib/types';
|
||||
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
|
||||
import classNames from 'classnames';
|
||||
import { Archive, ChevronLeft, ChevronRight, Plus } from 'radix-icons-svelte';
|
||||
import { Archive, ChevronLeft, ChevronRight, DotFilled, Plus } from 'radix-icons-svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export let tmdbId: number;
|
||||
export let titleId: TitleId;
|
||||
export let isModal = false;
|
||||
export let handleCloseModal: () => void = () => {};
|
||||
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
|
||||
|
||||
const itemStore = createLibraryItemStore(tmdbId);
|
||||
let data = loadInitialPageData();
|
||||
|
||||
const jellyfinItemStore = createJellyfinItemStore(data.then((d) => d.tmdbId));
|
||||
const sonarrSeriesStore = createSonarrSeriesStore(data.then((d) => d.tmdbSeries?.name || ''));
|
||||
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
|
||||
|
||||
let seasonSelectVisible = false;
|
||||
let visibleSeasonNumber: number | undefined = undefined;
|
||||
let visibleSeasonNumber: number = 1;
|
||||
let visibleEpisodeIndex: number | undefined = undefined;
|
||||
|
||||
function openRequestModal() {
|
||||
if (
|
||||
!$itemStore.item?.sonarrSeries?.id ||
|
||||
!$itemStore.item?.sonarrSeries?.statistics?.seasonCount
|
||||
)
|
||||
return;
|
||||
|
||||
modalStack.create(SeriesRequestModal, {
|
||||
sonarrId: $itemStore.item?.sonarrSeries?.id || 0,
|
||||
seasons: $itemStore.item?.sonarrSeries?.statistics?.seasonCount || 0,
|
||||
heading: $itemStore.item?.sonarrSeries?.title || 'Series'
|
||||
});
|
||||
}
|
||||
|
||||
let episodeProps: ComponentProps<EpisodeCard>[][] = [];
|
||||
let jellyfinEpisodeData: {
|
||||
[key: string]: {
|
||||
jellyfinId: string | undefined;
|
||||
progress: number;
|
||||
watched: boolean;
|
||||
};
|
||||
} = {};
|
||||
let episodeComponents: HTMLDivElement[] = [];
|
||||
let nextJellyfinEpisode: JellyfinItem | undefined = undefined;
|
||||
|
||||
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
|
||||
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
|
||||
getTmdbSeriesSeasons(tmdbId, s?.number_of_seasons || 0)
|
||||
);
|
||||
// Refresh jellyfin episode data
|
||||
jellyfinItemStore.subscribe(async (value) => {
|
||||
const item = value.item;
|
||||
if (!item?.Id) return;
|
||||
const episodes = await getJellyfinEpisodes(item.Id);
|
||||
|
||||
const tmdbRecommendationProps = getTmdbSeriesRecommendations(tmdbId).then((r) =>
|
||||
Promise.all(r.map(fetchCardTmdbProps))
|
||||
);
|
||||
const tmdbSimilarProps = getTmdbSeriesSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
const castProps: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
|
||||
Promise.all(
|
||||
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
episodes?.forEach((episode) => {
|
||||
const key = `S${episode?.ParentIndexNumber}E${episode?.IndexNumber}`;
|
||||
|
||||
itemStore.subscribe(async (libraryItem) => {
|
||||
const tmdbSeasons = await tmdbSeasonsPromise;
|
||||
if (!nextJellyfinEpisode && episode?.UserData?.Played === false) {
|
||||
nextJellyfinEpisode = episode;
|
||||
}
|
||||
|
||||
tmdbSeasons.forEach((season) => {
|
||||
const episodes: ComponentProps<EpisodeCard>[] = [];
|
||||
season?.episodes?.forEach((tmdbEpisode) => {
|
||||
const jellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.find(
|
||||
(e) =>
|
||||
e?.IndexNumber === tmdbEpisode?.episode_number &&
|
||||
e?.ParentIndexNumber === tmdbEpisode?.season_number
|
||||
);
|
||||
const jellyfinEpisodeId = jellyfinEpisode?.Id;
|
||||
|
||||
if (!nextJellyfinEpisode && jellyfinEpisode?.UserData?.Played === false) {
|
||||
nextJellyfinEpisode = jellyfinEpisode;
|
||||
}
|
||||
|
||||
episodes.push({
|
||||
title: tmdbEpisode?.name || '',
|
||||
subtitle: `Episode ${tmdbEpisode?.episode_number}`,
|
||||
backdropPath: tmdbEpisode?.still_path || '',
|
||||
progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0,
|
||||
watched: jellyfinEpisode?.UserData?.Played || false,
|
||||
jellyfinId: jellyfinEpisodeId
|
||||
});
|
||||
});
|
||||
episodeProps[season?.season_number || 0] = episodes;
|
||||
jellyfinEpisodeData[key] = {
|
||||
jellyfinId: episode?.Id,
|
||||
progress: episode?.UserData?.PlayedPercentage || 0,
|
||||
watched: episode?.UserData?.Played || false
|
||||
};
|
||||
});
|
||||
|
||||
if (!nextJellyfinEpisode) nextJellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.[0];
|
||||
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
|
||||
if (!nextJellyfinEpisode) nextJellyfinEpisode = episodes?.[0];
|
||||
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber;
|
||||
});
|
||||
|
||||
async function loadInitialPageData() {
|
||||
const tmdbId = await (titleId.provider === 'tvdb'
|
||||
? getTmdbIdFromTvdbId(titleId.id)
|
||||
: Promise.resolve(titleId.id));
|
||||
|
||||
const tmdbSeriesPromise = getTmdbSeries(tmdbId);
|
||||
const tmdbSeasonsPromise = tmdbSeriesPromise.then((s) =>
|
||||
getTmdbSeriesSeasons(s?.id || 0, s?.number_of_seasons || 0)
|
||||
);
|
||||
|
||||
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
|
||||
|
||||
const tmdbRecommendationPropsPromise = getTmdbSeriesRecommendations(tmdbId).then((r) =>
|
||||
Promise.all(r.map(fetchCardTmdbProps))
|
||||
);
|
||||
const tmdbSimilarPropsPromise = getTmdbSeriesSimilar(tmdbId)
|
||||
.then((r) => Promise.all(r.map(fetchCardTmdbProps)))
|
||||
.then((r) => r.filter((p) => p.backdropUrl));
|
||||
|
||||
const castPropsPromise: Promise<ComponentProps<PeopleCard>[]> = tmdbSeriesPromise.then((s) =>
|
||||
Promise.all(
|
||||
s?.aggregate_credits?.cast?.slice(0, 20)?.map((m) => ({
|
||||
tmdbId: m.id || 0,
|
||||
backdropUri: m.profile_path || '',
|
||||
name: m.name || '',
|
||||
subtitle: m.roles?.[0]?.character || m.known_for_department || ''
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
|
||||
const tmdbEpisodePropsPromise: Promise<ComponentProps<EpisodeCard>[][]> =
|
||||
tmdbSeasonsPromise.then((seasons) =>
|
||||
seasons.map(
|
||||
(season) =>
|
||||
season?.episodes?.map((episode) => ({
|
||||
title: episode?.name || '',
|
||||
subtitle: `Episode ${episode?.episode_number}`,
|
||||
backdropUrl: TMDB_BACKDROP_SMALL + episode?.still_path || '',
|
||||
airDate:
|
||||
episode.air_date && new Date(episode.air_date) > new Date()
|
||||
? new Date(episode.air_date)
|
||||
: undefined
|
||||
})) || []
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
tmdbId,
|
||||
tmdbSeries: await tmdbSeriesPromise,
|
||||
tmdbSeasons: await tmdbSeasonsPromise,
|
||||
tmdbUrl,
|
||||
tmdbRecommendationProps: await tmdbRecommendationPropsPromise,
|
||||
tmdbSimilarProps: await tmdbSimilarPropsPromise,
|
||||
castProps: await castPropsPromise,
|
||||
tmdbEpisodeProps: await tmdbEpisodePropsPromise
|
||||
};
|
||||
}
|
||||
|
||||
function playNextEpisode() {
|
||||
if (nextJellyfinEpisode?.Id) playerState.streamJellyfinId(nextJellyfinEpisode?.Id || '');
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await library.refresh();
|
||||
async function refreshSonarr() {
|
||||
await sonarrSeriesStore.refreshIn();
|
||||
}
|
||||
|
||||
let addToSonarrLoading = false;
|
||||
function addToSonarr() {
|
||||
async function addToSonarr() {
|
||||
const tmdbId = await data.then((d) => d.tmdbId);
|
||||
addToSonarrLoading = true;
|
||||
addSeriesToSonarr(tmdbId)
|
||||
.then(refresh)
|
||||
.then(refreshSonarr)
|
||||
.finally(() => (addToSonarrLoading = false));
|
||||
}
|
||||
|
||||
async function openRequestModal() {
|
||||
const sonarrSeries = get(sonarrSeriesStore).item;
|
||||
|
||||
if (!sonarrSeries?.id || !sonarrSeries?.statistics?.seasonCount) return;
|
||||
|
||||
modalStack.create(SeriesRequestModal, {
|
||||
sonarrId: sonarrSeries?.id || 0,
|
||||
seasons: sonarrSeries?.statistics?.seasonCount || 0,
|
||||
heading: sonarrSeries?.title || 'Series'
|
||||
});
|
||||
}
|
||||
|
||||
// Focus next episode on load
|
||||
let didFocusNextEpisode = false;
|
||||
$: {
|
||||
if (episodeComponents && !didFocusNextEpisode) {
|
||||
@@ -153,46 +197,68 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await tmdbSeriesPromise then series}
|
||||
{#await data}
|
||||
<TitlePageLayout {isModal} {handleCloseModal}>
|
||||
<div slot="episodes-carousel">
|
||||
<Carousel
|
||||
gradientFromColor="from-stone-950"
|
||||
class={classNames('px-2 sm:px-4 lg:px-8', {
|
||||
'2xl:px-0': !isModal
|
||||
})}
|
||||
heading="Episodes"
|
||||
>
|
||||
<CarouselPlaceholderItems />
|
||||
</Carousel>
|
||||
</div>
|
||||
</TitlePageLayout>
|
||||
{:then { tmdbSeries, tmdbId, ...data }}
|
||||
<TitlePageLayout
|
||||
{tmdbId}
|
||||
type="series"
|
||||
titleInformation={{
|
||||
tmdbId,
|
||||
type: 'series',
|
||||
backdropUriCandidates: tmdbSeries?.images?.backdrops?.map((b) => b.file_path || '') || [],
|
||||
posterPath: tmdbSeries?.poster_path || '',
|
||||
title: tmdbSeries?.name || '',
|
||||
tagline: tmdbSeries?.tagline || tmdbSeries?.name || '',
|
||||
overview: tmdbSeries?.overview || ''
|
||||
}}
|
||||
{isModal}
|
||||
{handleCloseModal}
|
||||
backdropUriCandidates={series?.images?.backdrops?.map((b) => b.file_path || '') || []}
|
||||
posterPath={series?.poster_path || ''}
|
||||
title={series?.name || ''}
|
||||
tagline={series?.tagline || series?.name || ''}
|
||||
overview={series?.overview || ''}
|
||||
>
|
||||
<svelte:fragment slot="title-info-1">
|
||||
{new Date(series?.first_air_date || Date.now()).getFullYear()}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title-info-2">{series?.status}</svelte:fragment>
|
||||
<svelte:fragment slot="title-info-3">
|
||||
<a href={tmdbUrl} target="_blank">{series?.vote_average?.toFixed(1)} TMDB</a>
|
||||
<svelte:fragment slot="title-info">
|
||||
{new Date(tmdbSeries?.first_air_date || Date.now()).getFullYear()}
|
||||
<DotFilled />
|
||||
{tmdbSeries?.status}
|
||||
<DotFilled />
|
||||
<a href={data.tmdbUrl} target="_blank">{tmdbSeries?.vote_average?.toFixed(1)} TMDB</a>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="title-right">
|
||||
<div
|
||||
class="flex gap-2 items-center flex-row-reverse justify-end lg:flex-row lg:justify-start"
|
||||
>
|
||||
{#if $itemStore.loading}
|
||||
{#if $jellyfinItemStore.loading || $sonarrSeriesStore.loading}
|
||||
<div class="placeholder h-10 w-48 rounded-xl" />
|
||||
{:else}
|
||||
<OpenInButton title={series?.name} {itemStore} type="series" {tmdbId} />
|
||||
{#if $itemStore.item?.jellyfinEpisodes?.length && !!nextJellyfinEpisode}
|
||||
<OpenInButton
|
||||
title={tmdbSeries?.name}
|
||||
jellyfinItem={$jellyfinItemStore.item}
|
||||
sonarrSeries={$sonarrSeriesStore.item}
|
||||
type="series"
|
||||
{tmdbId}
|
||||
/>
|
||||
{#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>
|
||||
{:else if !$itemStore.item?.sonarrSeries && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
|
||||
{:else if !$sonarrSeriesStore.item && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
|
||||
<Button type="primary" disabled={addToSonarrLoading} on:click={addToSonarr}>
|
||||
<span>Add to Sonarr</span><Plus size={20} />
|
||||
</Button>
|
||||
{:else if $itemStore.item?.sonarrSeries}
|
||||
{:else if $sonarrSeriesStore.item}
|
||||
<Button type="primary" on:click={openRequestModal}>
|
||||
<span class="mr-2">Request Series</span><Plus size={20} />
|
||||
</Button>
|
||||
@@ -209,9 +275,9 @@
|
||||
})}
|
||||
>
|
||||
<UiCarousel slot="title" class="flex gap-6">
|
||||
{#each [...Array(series?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber}
|
||||
{@const season = series?.seasons?.find((s) => s.season_number === seasonNumber)}
|
||||
{@const isSelected = season?.season_number === (visibleSeasonNumber || 1)}
|
||||
{#each [...Array(tmdbSeries?.number_of_seasons || 0).keys()].map((i) => i + 1) as seasonNumber}
|
||||
{@const season = tmdbSeries?.seasons?.find((s) => s.season_number === seasonNumber)}
|
||||
{@const isSelected = season?.season_number === visibleSeasonNumber}
|
||||
<button
|
||||
class={classNames(
|
||||
'font-medium tracking-wide transition-colors flex-shrink-0 flex items-center gap-1',
|
||||
@@ -219,14 +285,14 @@
|
||||
'text-zinc-200': isSelected && seasonSelectVisible,
|
||||
'text-zinc-500 hover:text-zinc-200 cursor-pointer':
|
||||
(!isSelected || seasonSelectVisible === false) &&
|
||||
series?.number_of_seasons !== 1,
|
||||
'text-zinc-500 cursor-default': series?.number_of_seasons === 1,
|
||||
tmdbSeries?.number_of_seasons !== 1,
|
||||
'text-zinc-500 cursor-default': tmdbSeries?.number_of_seasons === 1,
|
||||
hidden:
|
||||
!seasonSelectVisible && visibleSeasonNumber !== (season?.season_number || 1)
|
||||
}
|
||||
)}
|
||||
on:click={() => {
|
||||
if (series?.number_of_seasons === 1) return;
|
||||
if (tmdbSeries?.number_of_seasons === 1) return;
|
||||
|
||||
if (seasonSelectVisible) {
|
||||
visibleSeasonNumber = season?.season_number || 1;
|
||||
@@ -238,16 +304,27 @@
|
||||
>
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
class={(seasonSelectVisible || series?.number_of_seasons === 1) && 'hidden'}
|
||||
class={(seasonSelectVisible || tmdbSeries?.number_of_seasons === 1) && 'hidden'}
|
||||
/>
|
||||
Season {season?.season_number}
|
||||
</button>
|
||||
{/each}
|
||||
</UiCarousel>
|
||||
{#key visibleSeasonNumber}
|
||||
{#each episodeProps[visibleSeasonNumber || 1] || [] as props, i}
|
||||
{#each data.tmdbEpisodeProps[visibleSeasonNumber - 1] || [] as props, i}
|
||||
{@const jellyfinData = jellyfinEpisodeData[`S${visibleSeasonNumber}E${i + 1}`]}
|
||||
<div bind:this={episodeComponents[i]}>
|
||||
<EpisodeCard {...props} on:click={() => (visibleEpisodeIndex = i)} />
|
||||
<EpisodeCard
|
||||
{...props}
|
||||
{...jellyfinData
|
||||
? {
|
||||
watched: jellyfinData.watched,
|
||||
progress: jellyfinData.progress,
|
||||
jellyfinId: jellyfinData.jellyfinId
|
||||
}
|
||||
: {}}
|
||||
on:click={() => (visibleEpisodeIndex = i)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<CarouselPlaceholderItems />
|
||||
@@ -259,13 +336,13 @@
|
||||
<svelte:fragment slot="info-components">
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Created By</p>
|
||||
<h2 class="font-medium">{series?.created_by?.map((c) => c.name).join(', ')}</h2>
|
||||
<h2 class="font-medium">{tmdbSeries?.created_by?.map((c) => c.name).join(', ')}</h2>
|
||||
</div>
|
||||
{#if series?.first_air_date}
|
||||
{#if tmdbSeries?.first_air_date}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">First Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(series?.first_air_date).toLocaleDateString('en', {
|
||||
{new Date(tmdbSeries?.first_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -273,22 +350,22 @@
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if series?.next_episode_to_air}
|
||||
{#if tmdbSeries?.next_episode_to_air}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Next Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(series.next_episode_to_air?.air_date).toLocaleDateString('en', {
|
||||
{new Date(tmdbSeries.next_episode_to_air?.air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{:else if series?.last_air_date}
|
||||
{:else if tmdbSeries?.last_air_date}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Last Air Date</p>
|
||||
<h2 class="font-medium">
|
||||
{new Date(series.last_air_date).toLocaleDateString('en', {
|
||||
{new Date(tmdbSeries.last_air_date).toLocaleDateString('en', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -298,48 +375,47 @@
|
||||
{/if}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Networks</p>
|
||||
<h2 class="font-medium">{series?.networks?.map((n) => n.name).join(', ')}</h2>
|
||||
<h2 class="font-medium">{tmdbSeries?.networks?.map((n) => n.name).join(', ')}</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Episode Run Time</p>
|
||||
<h2 class="font-medium">{series?.episode_run_time} Minutes</h2>
|
||||
<h2 class="font-medium">{tmdbSeries?.episode_run_time} Minutes</h2>
|
||||
</div>
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Spoken Languages</p>
|
||||
<h2 class="font-medium">
|
||||
{series?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
|
||||
{tmdbSeries?.spoken_languages?.map((l) => capitalize(l.english_name || '')).join(', ')}
|
||||
</h2>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="servarr-components">
|
||||
{#if !$itemStore.loading && $itemStore.item?.sonarrSeries}
|
||||
{@const item = $itemStore.item}
|
||||
{#if item.sonarrSeries?.statistics?.episodeFileCount}
|
||||
{@const sonarrSeries = $sonarrSeriesStore.item}
|
||||
{#if sonarrSeries}
|
||||
{#if sonarrSeries?.statistics?.episodeFileCount}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Available</p>
|
||||
<h2 class="font-medium">
|
||||
{item.sonarrSeries?.statistics?.episodeFileCount || 0} Episodes
|
||||
{sonarrSeries?.statistics?.episodeFileCount || 0} Episodes
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.sonarrSeries?.statistics?.sizeOnDisk}
|
||||
{#if sonarrSeries?.statistics?.sizeOnDisk}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Size On Disk</p>
|
||||
<h2 class="font-medium">
|
||||
{formatSize(item.sonarrSeries?.statistics?.sizeOnDisk || 0)}
|
||||
{formatSize(sonarrSeries?.statistics?.sizeOnDisk || 0)}
|
||||
</h2>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $itemStore.item?.download}
|
||||
{#if $sonarrDownloadStore.downloads?.length}
|
||||
{@const download = $sonarrDownloadStore.downloads?.[0]}
|
||||
<div class="col-span-2 lg:col-span-1">
|
||||
<p class="text-zinc-400 text-sm">Download Completed In</p>
|
||||
<h2 class="font-medium">
|
||||
{$itemStore.item?.download.completionTime
|
||||
{download?.estimatedCompletionTime
|
||||
? formatMinutesToTime(
|
||||
(new Date($itemStore.item?.download.completionTime).getTime() - Date.now()) /
|
||||
1000 /
|
||||
60
|
||||
(new Date(download?.estimatedCompletionTime).getTime() - Date.now()) / 1000 / 60
|
||||
)
|
||||
: 'Stalled'}
|
||||
</h2>
|
||||
@@ -354,7 +430,7 @@
|
||||
<span class="mr-2">Manage</span><Archive size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if $itemStore.loading}
|
||||
{:else if $sonarrSeriesStore.loading}
|
||||
<div class="flex gap-4 flex-wrap col-span-4 sm:col-span-6 mt-4">
|
||||
<div class="placeholder h-10 w-40 rounded-xl" />
|
||||
<div class="placeholder h-10 w-40 rounded-xl" />
|
||||
@@ -364,7 +440,7 @@
|
||||
|
||||
<div slot="cast-crew-carousel-title" class="font-medium text-lg">Cast & Crew</div>
|
||||
<svelte:fragment slot="cast-crew-carousel">
|
||||
{#await castProps}
|
||||
{#await data.castProps}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
@@ -375,7 +451,7 @@
|
||||
|
||||
<div slot="recommendations-carousel-title" class="font-medium text-lg">Recommendations</div>
|
||||
<svelte:fragment slot="recommendations-carousel">
|
||||
{#await tmdbRecommendationProps}
|
||||
{#await data.tmdbRecommendationProps}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
@@ -386,7 +462,7 @@
|
||||
|
||||
<div slot="similar-carousel-title" class="font-medium text-lg">Similar Series</div>
|
||||
<svelte:fragment slot="similar-carousel">
|
||||
{#await tmdbSimilarProps}
|
||||
{#await data.tmdbSimilarProps}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop}
|
||||
|
||||
BIN
static/placeholder.jpg
Normal file
BIN
static/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user