diff --git a/src/lib/apis/jellyfin/jellyfinApi.ts b/src/lib/apis/jellyfin/jellyfinApi.ts index 405fe85..7a14dfa 100644 --- a/src/lib/apis/jellyfin/jellyfinApi.ts +++ b/src/lib/apis/jellyfin/jellyfinApi.ts @@ -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 = {}; + + 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) || []); diff --git a/src/lib/components/Card/Card.svelte b/src/lib/components/Card/Card.svelte index 10db614..440ffed 100644 --- a/src/lib/components/Card/Card.svelte +++ b/src/lib/components/Card/Card.svelte @@ -1,13 +1,16 @@ - + {#if trailerId} diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index 91f13f0..cf378d3 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -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); + } + } + +
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 }} >
- {#if reqFullscreenFunc} - (fullscreen = !fullscreen)}> - {#if fullscreen} - - {:else if !fullscreen && exitFullscreen} - - {/if} - - - {:else if video?.webkitEnterFullScreen} - video.webkitEnterFullScreen()}> + + {#if fullscreen} + + {:else if !fullscreen && exitFullscreen} - - {/if} + {/if} +
diff --git a/src/lib/stores/library.store.ts b/src/lib/stores/library.store.ts index 7fe3b53..8108f01 100644 --- a/src/lib/stores/library.store.ts +++ b/src/lib/stores/library.store.ts @@ -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(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 = { + loading: boolean; +} & T; + +function _createDataFetchStore(fn: () => Promise) { + const store = writable>({ + 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((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 +) { + 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 +) { + 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() }; } diff --git a/src/lib/stores/modal.store.ts b/src/lib/stores/modal.store.ts index a5ae225..2aab7b0 100644 --- a/src/lib/stores/modal.store.ts +++ b/src/lib/stores/modal.store.ts @@ -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 }); } diff --git a/src/routes/discover/+page.svelte b/src/routes/discover/+page.svelte index d556ef2..e898fa0 100644 --- a/src/routes/discover/+page.svelte +++ b/src/routes/discover/+page.svelte @@ -1,5 +1,6 @@ @@ -90,7 +100,7 @@ >{new Date(movie?.release_date || Date.now()).getFullYear()} - {@const progress = $itemStore.item?.continueWatching?.progress} + {@const progress = jellyfinItem?.UserData?.PlayedPercentage} {#if progress} {progress.toFixed()} min left {:else} @@ -101,7 +111,7 @@ {movie?.vote_average?.toFixed(1)} TMDB - {@const progress = $itemStore.item?.continueWatching?.progress} + {@const progress = jellyfinItem?.UserData?.PlayedPercentage} {#if progress}
- {#if $itemStore.loading} + {#if $jellyfinItemStore.loading || $radarrMovieStore.loading}
{:else} - - {#if $itemStore.item?.jellyfinItem} + + {#if jellyfinItem} - {:else if !$itemStore.item?.radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey} + {:else if !radarrMovie && $settings.radarr.baseUrl && $settings.radarr.apiKey} - {:else if $itemStore.item?.radarrMovie} + {:else if radarrMovie} @@ -192,85 +202,34 @@ {movie?.runtime} Minutes
- - {#if !$itemStore.loading && $itemStore.item} - {@const item = $itemStore.item} - {#if item.radarrMovie?.movieFile?.quality} + {#if radarrMovie} + {#if radarrMovie?.movieFile?.quality}

Video

- {item.radarrMovie?.movieFile?.quality.quality?.name} + {radarrMovie?.movieFile?.quality.quality?.name}

{/if} - {#if item.radarrMovie?.movieFile?.size} + {#if radarrMovie?.movieFile?.size}

Size On Disk

- {formatSize(item.radarrMovie?.movieFile?.size || 0)} + {formatSize(radarrMovie?.movieFile?.size || 0)}

{/if} - {#if $itemStore.item?.download} + {#if $radarrDownloadStore.downloads?.length} + {@const download = $radarrDownloadStore.downloads[0]}
-

Download Completed In

+

Downloaded In

- {$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'}

@@ -285,16 +244,7 @@ Manage
- - {:else if $itemStore.loading} + {:else if $radarrMovieStore.loading}
diff --git a/src/routes/series/[id]/+page.svelte b/src/routes/series/[id]/+page.svelte index 4c024df..bce8e29 100644 --- a/src/routes/series/[id]/+page.svelte +++ b/src/routes/series/[id]/+page.svelte @@ -6,8 +6,10 @@ let tmdbId: number; $: tmdbId = Number(data.tmdbId); + let name: string; + $: name = data.name || ''; {#key tmdbId} - + {/key} diff --git a/src/routes/series/[id]/+page.ts b/src/routes/series/[id]/+page.ts index 7f19f60..3232ff4 100644 --- a/src/routes/series/[id]/+page.ts +++ b/src/routes/series/[id]/+page.ts @@ -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; diff --git a/src/routes/series/[id]/SeriesPage.svelte b/src/routes/series/[id]/SeriesPage.svelte index 7aa00a1..0d84ed8 100644 --- a/src/routes/series/[id]/SeriesPage.svelte +++ b/src/routes/series/[id]/SeriesPage.svelte @@ -1,5 +1,5 @@