feat: Updated movie and series pages to not use library.store, added keyboard controls to video player

This commit is contained in:
Aleksi Lassila
2023-08-29 00:01:47 +03:00
parent 7d5afa7b7e
commit b5c39c656d
15 changed files with 435 additions and 226 deletions

View File

@@ -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) || []);

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { createLibraryItemStore } from '$lib/stores/library.store';
import {
createJellyfinItemStore,
createRadarrMovieStore,
createSonarrItemStore
} from '$lib/stores/library.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 = createSonarrItemStore(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(tmdbId, type, title);
} else {
window.location.href = `/${type}/${tmdbId}`;
}

View File

@@ -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 { library } from '$lib/stores/library.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(() => library.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(() => library.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

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import { TMDB_BACKDROP_SMALL } from '$lib/constants';
import { setJellyfinItemUnwatched, setJellyfinItemWatched } from '$lib/apis/jellyfin/jellyfinApi';
import { library } from '$lib/stores/library.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 backdropUrl: string;
@@ -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;
@@ -96,7 +96,16 @@
<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>

View File

@@ -1,17 +1,23 @@
<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 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>
</ContextMenuButton>

View File

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

View File

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

View File

@@ -33,6 +33,7 @@
import { modalStack } from '../../stores/modal.store';
import Slider from './Slider.svelte';
import { playerState } from './VideoPlayer';
import { linear } from 'svelte/easing';
export let modalId: symbol;
@@ -326,8 +327,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 +366,18 @@
'cursor-none': !uiVisible
}
)}
in:fade|global={{ duration: 500, easing: linear }}
out:fade|global={{ duration: 300, 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
@@ -446,20 +479,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>

View File

@@ -25,7 +25,7 @@ import {
} 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 { derived, get, writable, type Stores, type Writable } from 'svelte/store';
import { settings } from './settings.store';
export interface PlayableItem {
@@ -280,6 +280,7 @@ function createLibraryStore() {
); //TODO promise to undefined
async function filterNotInLibrary<T>(toFilter: T[], getTmdbId: (item: T) => number) {
return toFilter;
const libraryData = await get(library);
return toFilter.filter((item) => !(getTmdbId(item) in libraryData.items));
@@ -287,7 +288,8 @@ function createLibraryStore() {
return {
...library,
refresh: async () => getLibrary().then((r) => set(Promise.resolve(r))),
refresh: async (tmdbId: number | undefined = undefined) =>
getLibrary().then((r) => set(Promise.resolve(r))),
refreshIn: async (ms: number) => {
clearTimeout(delayedRefreshTimeout);
delayedRefreshTimeout = setTimeout(() => {
@@ -300,19 +302,191 @@ function createLibraryStore() {
export const library = createLibraryStore();
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) {
const store = derived(jellyfinItemsStore, (s) => {
return {
loading: s.loading,
item: s.data?.find((i) => i.ProviderIds?.Tmdb === String(tmdbId))
};
});
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) {
return derived(radarrMoviesStore, (s) => {
return {
loading: s.loading,
item: s.data?.find((i) => i.tmdbId === tmdbId),
refresh: radarrMoviesStore.refresh,
refreshIn: radarrMoviesStore.refreshIn
};
});
}
export function createSonarrItemStore(name: string) {
function shorten(str: string) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]/g, '');
}
const store = derived(sonarrSeriesStore, (s) => {
return {
loading: s.loading,
item: s.data?.find(
(i) =>
shorten(i.titleSlug || '') === shorten(name) ||
i.alternateTitles?.find((t) => shorten(t.title || '') === shorten(name))
)
};
});
return {
subscribe: store.subscribe,
refresh: sonarrSeriesStore.refresh,
refreshIn: sonarrSeriesStore.refreshIn
};
}
export const sonarrDownloadsStore = _createDataFetchStore(getSonarrDownloads);
export const radarrDownloadsStore = _createDataFetchStore(getRadarrDownloads);
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 createSonarrItemStore>
) {
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()
};
}
export type LibraryItem = {
jellyfinItem?: JellyfinItem;
};
function _createLibraryItemStore(tmdbId: number) {
const store = writable<{ loading: boolean; item?: PlayableItem }>({
function getValue(jellyfinItems: JellyfinItem[]) {
return jellyfinItems.find((i) => i.ProviderIds?.Tmdb === String(tmdbId));
}
const store = writable<{ loading: boolean; item?: LibraryItem }>({
loading: true,
item: undefined
});
library.subscribe(async (library) => {
const item = (await library).items[tmdbId];
jellyfinItemsStore.subscribe(async (data) => {
const item = {
jellyfinItem: getValue((await data).jellyfinItems || [])
};
store.set({ loading: false, item });
});
return {
subscribe: store.subscribe
subscribe: store.subscribe,
refresh: async () => jellyfinItemsStore.refresh()
};
}

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { TmdbApiOpen, type TmdbMovie2, type TmdbSeries2 } from '$lib/apis/tmdb/tmdbApi';
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { TmdbApiOpen } 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';
@@ -8,24 +9,35 @@
import NetworkCard from '$lib/components/NetworkCard.svelte';
import PeopleCard from '$lib/components/PeopleCard/PeopleCard.svelte';
import { genres, networks } from '$lib/discover';
import { library } from '$lib/stores/library.store';
import { jellyfinItemsStore } from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatDateToYearMonthDay } from '$lib/utils';
import { fade } from 'svelte/transition';
import { _ } from 'svelte-i18n';
import { fade } from 'svelte/transition';
function parseIncludedLanguages(includedLanguages: string) {
return includedLanguages.replace(' ', '').split(',').join('|');
}
const fetchCardProps = async (items: TmdbMovie2[] | TmdbSeries2[]) =>
Promise.all(
(
await ($settings.discover.excludeLibraryItems
? library.filterNotInLibrary(items, (t) => t.id || 0)
: items)
).map(fetchCardTmdbProps)
).then((props) => props.filter((p) => p.backdropUrl));
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
jellyfinItemsStore.subscribe((data) => {
if (data.loading) return;
resolve(data.data || []);
});
});
const fetchCardProps = async (items: { id?: number }[]) => {
const i = $settings.discover.excludeLibraryItems
? items.filter(
async (item) =>
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
)
: items;
return Promise.all(i.map(fetchCardTmdbProps)).then((props) =>
props.filter((p) => p.backdropUrl)
);
};
const fetchTrendingProps = () =>
TmdbApiOpen.get('/3/trending/all/{time_window}', {

View File

@@ -16,7 +16,13 @@
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,
createLibraryItemStore,
createRadarrDownloadStore,
createRadarrMovieStore,
library
} from '$lib/stores/library.store';
import { settings } from '$lib/stores/settings.store';
import { formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
@@ -28,7 +34,12 @@
export let handleCloseModal: () => void = () => {};
const tmdbUrl = 'https://www.themoviedb.org/movie/' + tmdbId;
const itemStore = createLibraryItemStore(tmdbId);
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
const radarrMovieStore = createRadarrMovieStore(tmdbId);
const radarrDownloadStore = createRadarrDownloadStore(radarrMovieStore);
const jellyfinItem = $jellyfinItemStore.item;
const radarrMovie = $radarrMovieStore.item;
const tmdbMoviePromise = getTmdbMovie(tmdbId);
const tmdbRecommendationProps = getTmdbMovieRecommendations(tmdbId)
@@ -49,27 +60,26 @@
);
function play() {
if ($itemStore.item?.jellyfinItem?.Id)
playerState.streamJellyfinId($itemStore.item?.jellyfinItem?.Id);
if (jellyfinItem?.Id) playerState.streamJellyfinId(jellyfinItem?.Id);
}
async function refresh() {
await library.refresh();
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 (!radarrMovie?.id) return;
modalStack.create(RequestModal, {
radarrId: $itemStore.item?.radarrMovie?.id
radarrId: radarrMovie?.id
});
}
</script>
@@ -90,7 +100,7 @@
>{new Date(movie?.release_date || Date.now()).getFullYear()}</svelte:fragment
>
<svelte:fragment slot="title-info-2">
{@const progress = $itemStore.item?.continueWatching?.progress}
{@const progress = jellyfinItem?.UserData?.PlayedPercentage}
{#if progress}
{progress.toFixed()} min left
{:else}
@@ -101,7 +111,7 @@
<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 = jellyfinItem?.UserData?.PlayedPercentage}
{#if progress}
<div
class={classNames('px-2 sm:px-4 lg:px-8', {
@@ -117,19 +127,19 @@
<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}
<OpenInButton title={movie?.title} {jellyfinItem} {radarrMovie} type="movie" {tmdbId} />
{#if jellyfinItem}
<Button type="primary" on:click={play}>
<span>Watch</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 +202,34 @@
{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}
{#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 +244,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" />

View File

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

View File

@@ -1,7 +1,11 @@
import { getTmdbSeries } from '$lib/apis/tmdb/tmdbApi';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
const tmdbSeries = await getTmdbSeries(Number(params.id));
return {
tmdbId: params.id
tmdbId: params.id,
name: tmdbSeries?.name
};
}) satisfies PageLoad;

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { getJellyfinEpisodes, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
import { addSeriesToSonarr } from '$lib/apis/sonarr/sonarrApi';
import {
getTmdbSeries,
@@ -14,13 +14,19 @@
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,
createSonarrItemStore,
library
} from '$lib/stores/library.store';
import { modalStack } from '$lib/stores/modal.store';
import { settings } from '$lib/stores/settings.store';
import { capitalize, formatMinutesToTime, formatSize } from '$lib/utils';
import classNames from 'classnames';
@@ -28,27 +34,29 @@
import type { ComponentProps } from 'svelte';
export let tmdbId: number;
export let title: string;
export let isModal = false;
export let handleCloseModal: () => void = () => {};
const tmdbUrl = 'https://www.themoviedb.org/tv/' + tmdbId;
const itemStore = createLibraryItemStore(tmdbId);
const jellyfinItemStore = createJellyfinItemStore(tmdbId);
const sonarrSeriesStore = createSonarrItemStore(title);
const sonarrDownloadStore = createSonarrDownloadStore(sonarrSeriesStore);
let sonarrSeries = $sonarrSeriesStore.item;
let jellyfinItem = $jellyfinItemStore.item;
let seasonSelectVisible = false;
let visibleSeasonNumber: number | undefined = undefined;
let visibleEpisodeIndex: number | undefined = undefined;
function openRequestModal() {
if (
!$itemStore.item?.sonarrSeries?.id ||
!$itemStore.item?.sonarrSeries?.statistics?.seasonCount
)
return;
if (!sonarrSeries?.id || !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'
sonarrId: sonarrSeries?.id || 0,
seasons: sonarrSeries?.statistics?.seasonCount || 0,
heading: sonarrSeries?.title || 'Series'
});
}
@@ -78,18 +86,18 @@
)
);
itemStore.subscribe(async (libraryItem) => {
jellyfinItemStore.promise.then(async (jellyfinItem) => {
const jellyfinEpisodes = jellyfinItem?.Id ? await getJellyfinEpisodes(jellyfinItem?.Id) : [];
const tmdbSeasons = await tmdbSeasonsPromise;
tmdbSeasons.forEach((season) => {
const episodes: ComponentProps<EpisodeCard>[] = [];
season?.episodes?.forEach((tmdbEpisode) => {
const jellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.find(
const jellyfinEpisode = 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;
@@ -98,16 +106,17 @@
episodes.push({
title: tmdbEpisode?.name || '',
subtitle: `Episode ${tmdbEpisode?.episode_number}`,
backdropUrl: tmdbEpisode?.still_path || '',
backdropUrl: TMDB_BACKDROP_SMALL + tmdbEpisode?.still_path || '',
progress: jellyfinEpisode?.UserData?.PlayedPercentage || 0,
watched: jellyfinEpisode?.UserData?.Played || false,
jellyfinId: jellyfinEpisodeId
jellyfinId: jellyfinEpisode?.Id,
airDate: tmdbEpisode.air_date ? new Date(tmdbEpisode.air_date) : undefined
});
});
episodeProps[season?.season_number || 0] = episodes;
});
if (!nextJellyfinEpisode) nextJellyfinEpisode = libraryItem.item?.jellyfinEpisodes?.[0];
if (!nextJellyfinEpisode) nextJellyfinEpisode = jellyfinEpisodes?.[0];
visibleSeasonNumber = nextJellyfinEpisode?.ParentIndexNumber || visibleSeasonNumber || 1;
});
@@ -116,7 +125,7 @@
}
async function refresh() {
await library.refresh();
await library.refresh(tmdbId);
}
let addToSonarrLoading = false;
@@ -177,22 +186,22 @@
<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={series?.name} {jellyfinItem} {sonarrSeries} type="series" {tmdbId} />
{#if !!nextJellyfinEpisode}
<Button type="primary" on:click={playNextEpisode}>
<span>
Watch {`S${nextJellyfinEpisode?.ParentIndexNumber}E${nextJellyfinEpisode?.IndexNumber}`}
</span>
<ChevronRight size={20} />
</Button>
{:else if !$itemStore.item?.sonarrSeries && $settings.sonarr.apiKey && $settings.sonarr.baseUrl}
{:else if !sonarrSeries && $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 sonarrSeries}
<Button type="primary" on:click={openRequestModal}>
<span class="mr-2">Request Series</span><Plus size={20} />
</Button>
@@ -313,33 +322,31 @@
</svelte:fragment>
<svelte:fragment slot="servarr-components">
{#if !$itemStore.loading && $itemStore.item?.sonarrSeries}
{@const item = $itemStore.item}
{#if item.sonarrSeries?.statistics?.episodeFileCount}
{#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 +361,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" />