feat: Episode page

This commit is contained in:
Aleksi Lassila
2024-04-23 18:24:05 +03:00
parent 423d8d9af4
commit 5ece8dd6f5
17 changed files with 303 additions and 57 deletions

2
package-lock.json generated
View File

@@ -33,7 +33,7 @@
"prettier-plugin-svelte": "^2.10.1",
"radix-icons-svelte": "^1.2.1",
"reflect-metadata": "^0.1.13",
"svelte": "^3.59.1",
"svelte": "^3.59.2",
"svelte-check": "^3.6.2",
"svelte-i18n": "^4.0.0",
"svelte-navigator": "^3.2.2",

View File

@@ -46,7 +46,7 @@
"prettier-plugin-svelte": "^2.10.1",
"radix-icons-svelte": "^1.2.1",
"reflect-metadata": "^0.1.13",
"svelte": "^3.59.1",
"svelte": "^3.59.2",
"svelte-check": "^3.6.2",
"svelte-i18n": "^4.0.0",
"svelte-navigator": "^3.2.2",

View File

@@ -5,6 +5,7 @@ import type { Api } from '../api.interface';
import { appState } from '../../stores/app-state.store';
import type { DeviceProfile } from './playback-profiles';
import axios from 'axios';
import { log } from '../../utils';
export type JellyfinItem = components['schemas']['BaseItemDto'];
@@ -82,7 +83,13 @@ export class JellyfinApi implements Api<paths> {
hasTmdbId: true,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated']
fields: [
'ProviderIds',
'Genres',
'DateLastMediaAdded',
'DateCreated',
'MediaSources'
]
}
}
})
@@ -265,6 +272,39 @@ export class JellyfinApi implements Api<paths> {
// }
// }).then((r) => r.data?.Items || []);
episodesCache: JellyfinItem[] = [];
getEpisode = async (
seriesId: string,
season: number,
episode: number,
refreshCache = false
): Promise<JellyfinItem | undefined> =>
this.getClient()
.GET('/Users/{userId}/Items', {
params: {
path: {
userId: this.getUserId()
},
query: {
// @ts-ignore
seriesId,
parentIndexNumber: season,
indexNumber: episode,
recursive: true,
includeItemTypes: ['Episode'],
fields: ['ProviderIds', 'Genres', 'DateLastMediaAdded', 'DateCreated', 'MediaSources']
}
}
})
.then((r) =>
r.data?.Items?.find(
(i) =>
i?.ParentIndexNumber === season &&
i?.IndexNumber === episode &&
i?.SeriesId === seriesId
)
);
getJellyfinEpisodes = async (parentId = '') =>
this.getClient()
?.GET('/Users/{userId}/Items', {
@@ -415,28 +455,33 @@ export class JellyfinApi implements Api<paths> {
}
});
setJellyfinItemWatched = async (jellyfinId: string) =>
this.getClient()?.POST('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: this.getUserId(),
itemId: jellyfinId
},
query: {
datePlayed: new Date().toISOString()
markAsWatched = async (jellyfinId: string) =>
this.getClient()
?.POST('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: this.getUserId(),
itemId: jellyfinId
},
query: {
datePlayed: new Date().toISOString()
}
}
}
});
})
setJellyfinItemUnwatched = async (jellyfinId: string) =>
this.getClient()?.DELETE('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: this.getUserId(),
itemId: jellyfinId
.then((res) => res.response.status === 200);
markAsUnwatched = async (jellyfinId: string) =>
this.getClient()
?.DELETE('/Users/{userId}/PlayedItems/{itemId}', {
params: {
path: {
userId: this.getUserId(),
itemId: jellyfinId
}
}
}
});
})
.then((res) => res.response.status === 200);
getJellyfinHealth = async (
baseUrl: string | undefined = undefined,

View File

@@ -15,12 +15,14 @@ export type TmdbSeries2 =
operations['tv-series-details']['responses']['200']['content']['application/json'];
export type TmdbSeason =
operations['tv-season-details']['responses']['200']['content']['application/json'];
export type TmdbEpisode = NonNullable<TmdbSeason['episodes']>[0];
export type TmdbSeasonEpisode = NonNullable<TmdbSeason['episodes']>[0];
export type TmdbPerson =
operations['person-details']['responses']['200']['content']['application/json'];
export type TmdbCredit =
| NonNullable<TmdbSeriesFull2['aggregate_credits']['cast']>[0]
| NonNullable<TmdbMovieFull2['credits']['cast']>[0];
export type TmdbEpisode =
operations['tv-episode-details']['responses']['200']['content']['application/json'];
export interface TmdbPersonFull extends TmdbPerson {
images: operations['person-images']['responses']['200']['content']['application/json'];
@@ -176,6 +178,26 @@ export class TmdbApi implements Api<paths> {
}
}).then((res) => res.data?.results || []);
getEpisode = (
seriesId: number,
season: number,
episode: number
): Promise<TmdbEpisode | undefined> =>
this.getClient()
.GET('/3/tv/{series_id}/season/{season_number}/episode/{episode_number}', {
params: {
path: {
series_id: seriesId,
season_number: season,
episode_number: episode
},
query: {
append_to_response: 'credits,external_ids,images'
}
}
})
.then((res) => res.data);
// OTHER
}

View File

@@ -6,6 +6,7 @@
export let inactive: boolean = false;
export let focusOnMount: boolean = false;
export let style: 'primary' | 'secondary' = 'primary';
let hasFocus: Readable<boolean>;
</script>
@@ -14,12 +15,9 @@
<Container
bind:hasFocus
class={classNames(
'px-6 py-2 rounded-lg font-medium tracking-wide flex items-center',
'px-6 py-2 rounded-lg font-medium tracking-wide flex items-center selectable',
{
// 'bg-primary-500 text-secondary-700': $hasFocus,
// 'bg-secondary-700': !$hasFocus,
// 'hover:bg-primary-500 hover:text-secondary-700': true,
'bg-secondary-700 selectable': true,
'bg-secondary-700': style === 'primary',
'cursor-pointer': !inactive,
'cursor-not-allowed pointer-events-none opacity-40': inactive
},

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Container from '../../../Container.svelte';
import classNames from 'classnames';
import { Check, CheckCircled, TriangleRight } from 'radix-icons-svelte';
import { ArrowDown, Check, TriangleRight } from 'radix-icons-svelte';
import type { Readable } from 'svelte/store';
import AnimateScale from '../AnimateScale.svelte';
@@ -22,10 +22,7 @@
class={classNames(
'w-full h-64',
'flex flex-col shrink-0',
'overflow-hidden rounded-2xl cursor-pointer group relative px-4 py-3 selectable transition-opacity',
{
'opacity-75': !isOnDeck && !$hasFocus
}
'overflow-hidden rounded-2xl cursor-pointer group relative px-4 py-3 selectable transition-opacity'
)}
on:clickOrSelect
on:enter
@@ -55,24 +52,28 @@
</div>
<!-- Background Image -->
<div
class="absolute inset-0 bg-center bg-cover"
class={classNames('absolute inset-0 bg-center bg-cover', {
// 'opacity-75': !isOnDeck && !$hasFocus
})}
style={`background-image: url('${backdropUrl}')`}
/>
<!-- Background Overlay / Tint -->
<div
class={classNames('absolute inset-0', {
'bg-gradient-to-t from-secondary-900/75 from-10% to-40% to-transparent': true
'bg-gradient-to-t from-secondary-900/75 from-10% to-40% ': true,
'to-secondary-900/25': !isOnDeck && !$hasFocus
// isOnDeck || $hasFocus,
// 'bg-gradient-to-t from-secondary-900/75 from-10% to-40% to-secondary-900/25':
// !isOnDeck && !$hasFocus
})}
/>
{#if handlePlay}
<div
class={classNames(
'group-hover:opacity-100 absolute inset-0 z-20 flex items-center justify-center'
)}
>
<div
class={classNames(
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 absolute inset-0 z-20 flex items-center justify-center'
)}
>
{#if handlePlay}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class={classNames(
@@ -84,7 +85,17 @@
>
<TriangleRight size={32} />
</div>
</div>
{/if}
{:else if !isOnDeck}
<div
class={classNames(
'rounded-full p-4 cursor-pointer',
'bg-zinc-900/90 text-zinc-200',
'hover:bg-primary-500 hover:text-secondary-800' // group-focus-within:text-secondary-800 group-focus-within:bg-primary-500
)}
>
<ArrowDown size={19} />
</div>
{/if}
</div>
</Container>
</AnimateScale>

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import EpisodeCard from './EpisodeCard.svelte';
import type { TmdbEpisode } from '../../apis/tmdb/tmdb-api';
import type { TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api';
import { TMDB_BACKDROP_SMALL } from '../../constants';
export let episode: TmdbEpisode;
export let episode: TmdbSeasonEpisode;
export let handlePlay: (() => void) | undefined;
export let isWatched = false;
export let playbackProgress = 0;

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import classNames from 'classnames';
export let title: string | undefined;
</script>
<div
class={classNames(
'text-left font-medium tracking-wider text-zinc-200 hover:text-amber-200 mt-1',
{
'text-4xl sm:text-5xl lg:text-6xl': (title?.length || 0) < 15,
'text-3xl sm:text-4xl lg:text-5xl': (title?.length || 0) >= 15
}
)}
>
{title}
</div>

View File

@@ -5,7 +5,7 @@
import { derived, get, type Readable } from 'svelte/store';
import {
tmdbApi,
type TmdbEpisode,
type TmdbSeasonEpisode,
type TmdbSeason,
type TmdbSeriesFull2
} from '../../apis/tmdb/tmdb-api';
@@ -24,9 +24,9 @@
export let nextJellyfinEpisode: Readable<JellyfinItem | undefined>;
// Exports
export let selectedTmdbEpisode: TmdbEpisode | undefined;
export let selectedTmdbEpisode: TmdbSeasonEpisode | undefined;
const containers = new Map<TmdbSeason | TmdbEpisode, Selectable>();
const containers = new Map<TmdbSeason | TmdbSeasonEpisode, Selectable>();
let scrollTop: number;
const { data: tmdbSeasons, isLoading: isTmdbSeasonsLoading } = useDependantRequest(
@@ -58,7 +58,7 @@
if (seasonSelectable) seasonSelectable.focus({ setFocusedElement: false });
}
function handleEpisodeMount(event: CustomEvent<Selectable>, tmdbEpisode: TmdbEpisode) {
function handleEpisodeMount(event: CustomEvent<Selectable>, tmdbEpisode: TmdbSeasonEpisode) {
containers.set(tmdbEpisode, event.detail);
const selectable = event.detail;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { type Readable } from 'svelte/store';
import { tmdbApi, type TmdbEpisode, type TmdbSeriesFull2 } from '../../apis/tmdb/tmdb-api';
import { tmdbApi, type TmdbSeasonEpisode, type TmdbSeriesFull2 } from '../../apis/tmdb/tmdb-api';
import Container from '../../../Container.svelte';
import { useDependantRequest } from '../../stores/data.store';
import type { JellyfinItem } from '../../apis/jellyfin/jellyfin-api';
@@ -44,7 +44,7 @@
episodeCard.subscribe((e) => e?.focus({ setFocusedElement: false, propagate: false }));
});
function handleOpenEpisodePage(episode: TmdbEpisode) {
function handleOpenEpisodePage(episode: TmdbSeasonEpisode) {
navigate(`season/${episode.season_number}/episode/${episode.episode_number}`);
}
</script>

View File

@@ -3,7 +3,7 @@
import HeroCarousel from '../HeroCarousel/HeroCarousel.svelte';
import DetachedPage from '../DetachedPage/DetachedPage.svelte';
import { useActionRequest, useDependantRequest, useRequest } from '../../stores/data.store';
import { tmdbApi, type TmdbEpisode } from '../../apis/tmdb/tmdb-api';
import { tmdbApi, type TmdbSeasonEpisode } from '../../apis/tmdb/tmdb-api';
import { PLATFORM_WEB, TMDB_IMAGES_ORIGINAL } from '../../constants';
import classNames from 'classnames';
import { DotFilled, Download, ExternalLink, File, Play, Plus } from 'radix-icons-svelte';
@@ -48,7 +48,7 @@
sonarrApi.addSeriesToSonarr
);
let selectedTmdbEpisode: TmdbEpisode | undefined;
let selectedTmdbEpisode: TmdbSeasonEpisode | undefined;
const episodeCards = useRegistrar();
let scrollTop: number;
@@ -267,4 +267,4 @@
</div>
</DetachedPage>
<Route path="/season/:season/episode/:episode/*" component={EpisodePage} />
<Route path="/season/:season/episode/:episode/*" component={EpisodePage} {id} />

View File

@@ -1,10 +1,127 @@
<script lang="ts">
import Container from '../../Container.svelte';
import { tmdbApi } from '../apis/tmdb/tmdb-api';
import DetachedPage from '../components/DetachedPage/DetachedPage.svelte';
import { useActionRequest, useDependantRequest, useRequest } from '../stores/data.store';
import { TMDB_IMAGES_ORIGINAL } from '../constants';
import classNames from 'classnames';
import { Check, DotFilled, Download, Play, Trash } from 'radix-icons-svelte';
import HeroInfoTitle from '../components/HeroInfo/HeroInfoTitle.svelte';
import Button from '../components/Button.svelte';
import { jellyfinApi } from '../apis/jellyfin/jellyfin-api';
import { playerState } from '../components/VideoPlayer/VideoPlayer';
import { formatSize } from '../utils';
import { tick } from 'svelte';
export let id: string; // Series ID
export let season: string;
export let episode: string;
let isWatched = false;
const tmdbEpisode = tmdbApi.getEpisode(Number(id), Number(season), Number(episode));
const jellyfinSeries = jellyfinApi.getLibraryItemFromTmdbId(id);
let jellyfinEpisode = jellyfinSeries.then((series) =>
jellyfinApi.getEpisode(series?.Id || '', Number(season), Number(episode))
);
const { send: toggleMarkAs, isFetching: markAsLoading } = useActionRequest(async () => {
const episode = await jellyfinEpisode;
const jellyfinId = episode?.Id;
if (!jellyfinId) return;
if (isWatched) {
return jellyfinApi.markAsUnwatched(jellyfinId).then((ok) => (isWatched = !ok));
} else {
return jellyfinApi.markAsWatched(jellyfinId).then((ok) => (isWatched = ok));
}
});
jellyfinEpisode.then((e) => {
isWatched = e?.UserData?.Played || false;
});
</script>
<DetachedPage>
Episode Page for {season}x{episode}
<DetachedPage let:handleGoBack let:registrar>
{#await tmdbEpisode then episode}
<div
class="bg-center bg-cover absolute inset-x-0 h-screen -z-10"
style={`background-image: url('${TMDB_IMAGES_ORIGINAL + episode?.still_path}')`}
/>
<div class="absolute inset-0 flex flex-col -z-10">
<div class="h-screen bg-gradient-to-t from-secondary-500 to-transparent" />
<div class="flex-1 bg-secondary-500" />
</div>
<!-- <HeroCarousel /> -->
<Container
on:navigate={handleGoBack}
on:mount={registrar}
focusOnMount
class="h-screen flex flex-col justify-end mx-20 py-16"
>
<div class="mt-2 text-zinc-200 font-medium text-lg tracking-wider">
Season {episode?.season_number} Episode {episode?.episode_number}
</div>
<HeroInfoTitle title={episode?.name} />
<div
class="flex items-center gap-1 uppercase text-zinc-300 font-semibold tracking-wider mt-2 text-lg"
>
<!-- <p class="flex-shrink-0">-->
<!--{#if series.status !== 'Ended'}-->
<!-- Since {new Date(series.first_air_date || Date.now())?.getFullYear()}-->
<!--{:else}-->
<!-- Ended {new Date(series.last_air_date || Date.now())?.getFullYear()}-->
<!--{/if}-->
<!-- </p>-->
<!-- <DotFilled /> -->
<p class="flex-shrink-0">
<a href={'https://www.themoviedb.org/movie/' + episode?.id}>
{episode?.vote_average} TMDB
</a>
</p>
<DotFilled />
<p class="flex-shrink-0">{episode?.runtime} Minutes</p>
{#await jellyfinEpisode then episode}
{#if episode?.MediaSources?.[0]?.Size}
<DotFilled />
<p class="flex-shrink-0">{formatSize(episode?.MediaSources?.[0]?.Size)}</p>
{/if}
{#if episode?.MediaSources?.[0]?.MediaStreams?.[0]?.DisplayTitle}
<DotFilled />
<p class="flex-shrink-0">
{episode?.MediaSources?.[0]?.MediaStreams?.[0]?.DisplayTitle}
</p>
{/if}
{/await}
</div>
<div class="text-stone-300 font-medium line-clamp-3 opacity-75 max-w-4xl mt-4">
{episode?.overview}
</div>
<Container direction="horizontal" class="flex mt-8">
{#await jellyfinEpisode then episode}
<Button
class="mr-4"
on:clickOrSelect={() => episode?.Id && playerState.streamJellyfinId(episode.Id)}
>
Play
<Play size={19} slot="icon" />
</Button>
<Button class="mr-4" inactive={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
{#if isWatched}
Mark as Unwatched
{:else}
Mark as Watched
{/if}
<Check slot="icon" size={19} />
</Button>
{/await}
<Button class="mr-4">Request <Download slot="icon" size={19} /></Button>
<Button class="mr-4">Delete Files <Trash slot="icon" size={19} /></Button>
</Container>
</Container>
{/await}
</DetachedPage>

View File

@@ -702,7 +702,7 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
event.preventDefault();
}
Selectable.focusedObject.subscribe(console.debug);
Selectable.focusedObject.subscribe((e) => console.debug('Focused object', e));
type Offsets = Partial<
Record<

View File

@@ -328,3 +328,39 @@ export const useActionRequest = <P extends (...args: A) => Promise<any>, A exten
send
};
};
export const useActionRequest2 = <P extends (...args: A) => Promise<any>, A extends any[]>(
fn: P
) => {
const request = writable<ReturnType<P>>(undefined);
const data = writable<Awaited<ReturnType<P>> | undefined>(undefined);
const isFetching = writable(false);
function send(...args: Parameters<P>): ReturnType<P> {
isFetching.set(true);
// @ts-ignore
const p: ReturnType<P> = fn(...args)
.then((res) => {
data.set(res);
return res;
})
.finally(() => {
isFetching.set(false);
});
request.set(p);
return p;
}
return {
promise: request,
data: {
subscribe: data.subscribe
},
isFetching: {
subscribe: isFetching.subscribe
},
send
};
};