mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-24 01:35:12 +02:00
feat: Episode card frames
This commit is contained in:
28
src/lib/components/SeriesPage/EpisodeCard.svelte
Normal file
28
src/lib/components/SeriesPage/EpisodeCard.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { TmdbEpisode, TmdbSeason } from '../../apis/tmdb/tmdb-api';
|
||||
import Container from '../../../Container.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { TMDB_BACKDROP_SMALL } from '../../constants';
|
||||
|
||||
export let episode: TmdbEpisode;
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class={classNames(
|
||||
'rounded-xl overflow-hidden',
|
||||
'h-56 w-96 px-4 py-3',
|
||||
'flex flex-col shrink-0 relative selectable'
|
||||
)}
|
||||
>
|
||||
<div class="flex-1 flex flex-col justify-end z-10">
|
||||
<h2 class="text-zinc-300 font-medium">Episode {episode.episode_number}</h2>
|
||||
<h1 class="text-zinc-100 text-lg font-medium line-clamp-2">{episode.name}</h1>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 bg-center bg-cover"
|
||||
style={`background-image: url('${TMDB_BACKDROP_SMALL + episode.still_path}')`}
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-stone-950 via-transparent via-40% to-transparent"
|
||||
/>
|
||||
</Container>
|
||||
60
src/lib/components/SeriesPage/EpisodeCarousel.svelte
Normal file
60
src/lib/components/SeriesPage/EpisodeCarousel.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { JellyfinItem } from '../../apis/jellyfin/jellyfin-api';
|
||||
import EpisodeCard from './EpisodeCard.svelte';
|
||||
import { useDependantRequest } from '../../stores/data.store';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { tmdbApi, type TmdbSeason, type TmdbSeriesFull2 } from '../../apis/tmdb/tmdb-api';
|
||||
import Carousel from '../Carousel/Carousel.svelte';
|
||||
import Container from '../../../Container.svelte';
|
||||
import { scrollWithOffset } from '../../selectable';
|
||||
import UICarousel from '../Carousel/UICarousel.svelte';
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let id: number;
|
||||
export let tmdbSeries: Readable<TmdbSeriesFull2 | undefined>;
|
||||
export let nextEpisode: JellyfinItem | undefined = undefined;
|
||||
|
||||
const { data: tmdbSeasons, isLoading: isTmdbSeasonsLoading } = useDependantRequest(
|
||||
(seasons: number) => tmdbApi.getTmdbSeriesSeasons(id, seasons),
|
||||
tmdbSeries,
|
||||
(series) => (series?.seasons?.length ? ([series.seasons.length] as const) : undefined)
|
||||
);
|
||||
|
||||
function handleSelectSeason(season: TmdbSeason) {
|
||||
console.log(season);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isTmdbSeasonsLoading}
|
||||
Loading...
|
||||
{:else if $tmdbSeasons}
|
||||
<Carousel scrollClass="px-20">
|
||||
<UICarousel slot="title" class="text-xl flex -mx-2 max-w-2xl">
|
||||
{#each $tmdbSeasons as season}
|
||||
<Container
|
||||
let:hasFocus
|
||||
class="mx-2 text-nowrap"
|
||||
on:click={() => handleSelectSeason(season)}
|
||||
>
|
||||
<div
|
||||
class={classNames({
|
||||
'font-semibold tracking-wide': hasFocus,
|
||||
'text-zinc-300 font-medium': !hasFocus
|
||||
})}
|
||||
>
|
||||
Season {season.season_number}
|
||||
</div>
|
||||
</Container>
|
||||
{/each}
|
||||
</UICarousel>
|
||||
<Container revealStrategy={scrollWithOffset('all', 64)} class="flex">
|
||||
{#each $tmdbSeasons as season}
|
||||
{#each season?.episodes || [] as episode}
|
||||
<div class="mx-2">
|
||||
<EpisodeCard {episode} />
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</Container>
|
||||
</Carousel>
|
||||
{/if}
|
||||
148
src/lib/components/SeriesPage/SeriesPage.svelte
Normal file
148
src/lib/components/SeriesPage/SeriesPage.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../../Container.svelte';
|
||||
import SidebarMargin from '../SidebarMargin.svelte';
|
||||
import HeroCarousel from '../HeroCarousel/HeroCarousel.svelte';
|
||||
import DetachedPage from '../DetachedPage/DetachedPage.svelte';
|
||||
import { useActionRequest, useDependantRequest, useRequest } from '../../stores/data.store';
|
||||
import { tmdbApi } 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';
|
||||
import { jellyfinApi } from '../../apis/jellyfin/jellyfin-api';
|
||||
import { sonarrApi } from '../../apis/sonarr/sonarr-api';
|
||||
import Button from '../Button.svelte';
|
||||
import { playerState } from '../VideoPlayer/VideoPlayer';
|
||||
import { modalStack } from '../Modal/modal.store';
|
||||
import ManageMediaModal from '../ManageMedia/ManageMediaModal.svelte';
|
||||
import { derived } from 'svelte/store';
|
||||
import EpisodeCarousel from './EpisodeCarousel.svelte';
|
||||
|
||||
export let id: string;
|
||||
|
||||
const { promise: tmdbSeries, data: tmdbSeriesData } = useRequest(
|
||||
tmdbApi.getTmdbSeries,
|
||||
Number(id)
|
||||
);
|
||||
const { promise: sonarrItem, data: sonarrItemData } = useRequest(
|
||||
sonarrApi.getSeriesByTmdbId,
|
||||
Number(id)
|
||||
);
|
||||
const { promise: jellyfinItem, data: jellyfinItemData } = useRequest(
|
||||
(id: string) => jellyfinApi.getLibraryItemFromTmdbId(id),
|
||||
id
|
||||
);
|
||||
const { data: jellyfinSeriesItemsData } = useDependantRequest(
|
||||
jellyfinApi.getJellyfinEpisodes,
|
||||
jellyfinItemData,
|
||||
(data) => (data?.Id ? ([data.Id] as const) : undefined)
|
||||
);
|
||||
const nextJellyfinEpisode = derived(jellyfinSeriesItemsData, ($items) =>
|
||||
($items || []).find((i) => i.UserData?.Played === false)
|
||||
);
|
||||
|
||||
const { send: addSeriesToSonarr, isFetching: addSeriesToSonarrFetching } = useActionRequest(
|
||||
sonarrApi.addSeriesToSonarr
|
||||
);
|
||||
</script>
|
||||
|
||||
<DetachedPage>
|
||||
<div class="h-screen flex flex-col py-12 px-20 relative">
|
||||
<HeroCarousel
|
||||
urls={$tmdbSeries.then(
|
||||
(series) =>
|
||||
series?.images.backdrops
|
||||
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
|
||||
?.map((i) => TMDB_IMAGES_ORIGINAL + i.file_path)
|
||||
.slice(0, 5) || []
|
||||
)}
|
||||
>
|
||||
<div class="h-full flex-1 flex flex-col justify-end">
|
||||
{#await $tmdbSeries then series}
|
||||
{#if series}
|
||||
<div
|
||||
class={classNames(
|
||||
'text-left font-medium tracking-wider text-stone-200 hover:text-amber-200 mt-2',
|
||||
{
|
||||
'text-4xl sm:text-5xl 2xl:text-6xl': series.name?.length || 0 < 15,
|
||||
'text-3xl sm:text-4xl 2xl:text-5xl': series?.name?.length || 0 >= 15
|
||||
}
|
||||
)}
|
||||
>
|
||||
{series?.name}
|
||||
</div>
|
||||
<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">{movie.runtime}</p> -->
|
||||
<DotFilled />
|
||||
<p class="flex-shrink-0">
|
||||
<a href={'https://www.themoviedb.org/movie/' + series.id}
|
||||
>{series.vote_average} TMDB</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-stone-300 font-medium line-clamp-3 opacity-75 max-w-4xl mt-4">
|
||||
{series.overview}
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
{#await Promise.all([$jellyfinItem, $sonarrItem]) then [jellyfinItem, sonarrItem]}
|
||||
<Container direction="horizontal" class="flex mt-8" focusOnMount>
|
||||
{#if $nextJellyfinEpisode}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() =>
|
||||
$nextJellyfinEpisode?.Id && playerState.streamJellyfinId($nextJellyfinEpisode.Id)}
|
||||
>
|
||||
Play Season {$nextJellyfinEpisode?.ParentIndexNumber} Episode
|
||||
{$nextJellyfinEpisode?.IndexNumber}
|
||||
<Play size={19} slot="icon" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if sonarrItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => modalStack.create(ManageMediaModal, { id: sonarrItem.id || -1 })}
|
||||
>
|
||||
{#if jellyfinItem}
|
||||
Manage Files
|
||||
{:else}
|
||||
Request
|
||||
{/if}
|
||||
<svelte:component this={jellyfinItem ? File : Download} size={19} slot="icon" />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => addSeriesToSonarr(Number(id))}
|
||||
inactive={$addSeriesToSonarrFetching}
|
||||
>
|
||||
Add to Sonarr
|
||||
<Plus slot="icon" size={19} />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if PLATFORM_WEB}
|
||||
<Button class="mr-2">
|
||||
Open In TMDB
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
<Button class="mr-2">
|
||||
Open In Jellyfin
|
||||
<ExternalLink size={19} slot="icon-after" />
|
||||
</Button>
|
||||
{/if}
|
||||
</Container>
|
||||
{/await}
|
||||
</div>
|
||||
</HeroCarousel>
|
||||
</div>
|
||||
<EpisodeCarousel id={Number(id)} tmdbSeries={tmdbSeriesData} />
|
||||
</DetachedPage>
|
||||
Reference in New Issue
Block a user