feat: Series episode playback
This commit is contained in:
@@ -4,7 +4,11 @@
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { type NavigationActions, type FocusHandler, Selectable } from './lib/selectable';
|
||||
import classNames from 'classnames';
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher<{
|
||||
click: MouseEvent;
|
||||
select: null;
|
||||
clickOrSelect: null;
|
||||
}>();
|
||||
|
||||
export let name: string = '';
|
||||
export let direction: 'vertical' | 'horizontal' | 'grid' = 'vertical';
|
||||
@@ -27,6 +31,10 @@
|
||||
.setTrapFocus(trapFocus)
|
||||
.setCanFocusEmpty(canFocusEmpty)
|
||||
.setOnFocus(handleFocus)
|
||||
.setOnSelect(() => {
|
||||
dispatch('select');
|
||||
dispatch('clickOrSelect');
|
||||
})
|
||||
.getStores();
|
||||
export const container = rest.container;
|
||||
export const hasFocus = rest.hasFocus;
|
||||
@@ -44,6 +52,7 @@
|
||||
}
|
||||
|
||||
dispatch('click', e);
|
||||
dispatch('clickOrSelect');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
$$restProps.class
|
||||
)}
|
||||
on:click
|
||||
on:select
|
||||
on:clickOrSelect
|
||||
let:hasFocus
|
||||
{focusOnMount}
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<Container
|
||||
active={focusable}
|
||||
on:click={() => {
|
||||
on:clickOrSelect={() => {
|
||||
if (tmdbId || tvdbId) {
|
||||
navigate(`${type}/${tmdbId || tvdbId}`);
|
||||
}
|
||||
|
||||
24
src/lib/components/HeroCarousel/HeroInfoLayout.svelte
Normal file
24
src/lib/components/HeroCarousel/HeroInfoLayout.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let title = '';
|
||||
export let overview = '';
|
||||
</script>
|
||||
|
||||
<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': title.length || 0 < 15,
|
||||
'text-3xl sm:text-4xl 2xl:text-5xl': title.length || 0 >= 15
|
||||
}
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 uppercase text-zinc-300 font-semibold tracking-wider mt-2 text-lg"
|
||||
/>
|
||||
<div class="text-stone-300 font-medium line-clamp-3 opacity-75 max-w-4xl mt-4">
|
||||
{overview}
|
||||
</div>
|
||||
@@ -3,16 +3,26 @@
|
||||
import Container from '../../../Container.svelte';
|
||||
import classNames from 'classnames';
|
||||
import { TMDB_BACKDROP_SMALL } from '../../constants';
|
||||
import { Play, TriangleRight } from 'radix-icons-svelte';
|
||||
import type { JellyfinItem } from '../../apis/jellyfin/jellyfin-api';
|
||||
import { playerState } from '../VideoPlayer/VideoPlayer';
|
||||
|
||||
export let episode: TmdbEpisode;
|
||||
export let jellyfinEpisode: JellyfinItem | undefined;
|
||||
|
||||
function handlePlay() {
|
||||
if (jellyfinEpisode?.Id) playerState.streamJellyfinId(jellyfinEpisode.Id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class={classNames(
|
||||
'rounded-xl overflow-hidden cursor-pointer',
|
||||
'h-56 w-96 px-4 py-3',
|
||||
'rounded-xl overflow-hidden cursor-pointer group',
|
||||
'w-[428px] h-[240.75px] px-4 py-3',
|
||||
'flex flex-col shrink-0 relative selectable'
|
||||
)}
|
||||
let:hasFocus
|
||||
on:select={handlePlay}
|
||||
>
|
||||
<div class="flex-1 flex flex-col justify-end z-10">
|
||||
<h2 class="text-zinc-300 font-medium">Episode {episode.episode_number}</h2>
|
||||
@@ -25,4 +35,18 @@
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-stone-950 via-transparent via-40% to-transparent"
|
||||
/>
|
||||
{#if jellyfinEpisode}
|
||||
<div
|
||||
class={classNames(
|
||||
'opacity-0 group-hover:opacity-100 absolute inset-0 z-20 flex items-center justify-center',
|
||||
{
|
||||
'opacity-100': hasFocus
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div class="rounded-full bg-stone-950/50 p-2.5 cursor-pointer" on:click={handlePlay}>
|
||||
<TriangleRight size={32} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
import { scrollElementIntoView, scrollIntoView } from '../../selectable';
|
||||
import UICarousel from '../Carousel/UICarousel.svelte';
|
||||
import classNames from 'classnames';
|
||||
import ScrollHelper from '../ScrollHelper.svelte';
|
||||
|
||||
export let id: number;
|
||||
export let tmdbSeries: Readable<TmdbSeriesFull2 | undefined>;
|
||||
export let jellyfinEpisodes: Readable<JellyfinItem[] | undefined>;
|
||||
export let nextJellyfinEpisode: Readable<JellyfinItem | undefined>;
|
||||
export let selectedTmdbEpisode: TmdbEpisode | undefined = undefined;
|
||||
|
||||
@@ -27,6 +29,7 @@
|
||||
);
|
||||
|
||||
const containers: Record<string, Container> = {};
|
||||
let scrollTop: number;
|
||||
|
||||
function focusFirstEpisodeOf(season: TmdbSeason) {
|
||||
let isAlreadySelected = false;
|
||||
@@ -78,15 +81,27 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<ScrollHelper bind:scrollTop />
|
||||
|
||||
{#if $isTmdbSeasonsLoading}
|
||||
Loading...
|
||||
{:else if $tmdbSeasons}
|
||||
<Carousel scrollClass="px-20">
|
||||
<UICarousel slot="title" class="text-xl flex -mx-2 max-w-2xl">
|
||||
<Carousel
|
||||
scrollClass="px-20"
|
||||
class={classNames('transition-transform', {
|
||||
'-translate-y-16': scrollTop < 140
|
||||
})}
|
||||
>
|
||||
<UICarousel
|
||||
slot="title"
|
||||
class={classNames('text-xl flex -mx-2 max-w-2xl transition-opacity', {
|
||||
'opacity-0': scrollTop < 140
|
||||
})}
|
||||
>
|
||||
{#each $tmdbSeasons as season}
|
||||
<Container
|
||||
let:hasFocus
|
||||
class="mx-2 text-nowrap"
|
||||
class="mx-2"
|
||||
on:click={() => focusFirstEpisodeOf(season)}
|
||||
handleFocus={(s, options) => {
|
||||
scrollIntoView({ horizontal: 64 })(s);
|
||||
@@ -96,7 +111,7 @@
|
||||
>
|
||||
<div
|
||||
class={classNames(
|
||||
'cursor-pointer hover:font-semibold hover:tracking-wide hover:text-white',
|
||||
'cursor-pointer whitespace-nowrap hover:font-semibold hover:tracking-wide hover:text-white',
|
||||
{
|
||||
'font-semibold tracking-wide': hasFocus,
|
||||
'text-zinc-300 font-medium': !hasFocus
|
||||
@@ -121,7 +136,14 @@
|
||||
}}
|
||||
focusOnClick
|
||||
>
|
||||
<EpisodeCard {episode} />
|
||||
<EpisodeCard
|
||||
jellyfinEpisode={$jellyfinEpisodes?.find(
|
||||
(i) =>
|
||||
i.IndexNumber === episode.episode_number &&
|
||||
i.ParentIndexNumber === episode.season_number
|
||||
)}
|
||||
{episode}
|
||||
/>
|
||||
</Container>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
(id: string) => jellyfinApi.getLibraryItemFromTmdbId(id),
|
||||
id
|
||||
);
|
||||
const { data: jellyfinSeriesItemsData } = useDependantRequest(
|
||||
const { data: jellyfinEpisodes } = useDependantRequest(
|
||||
jellyfinApi.getJellyfinEpisodes,
|
||||
jellyfinItemData,
|
||||
(data) => (data?.Id ? ([data.Id] as const) : undefined)
|
||||
);
|
||||
const nextJellyfinEpisode = derived(jellyfinSeriesItemsData, ($items) =>
|
||||
const nextJellyfinEpisode = derived(jellyfinEpisodes, ($items) =>
|
||||
($items || []).find((i) => i.UserData?.Played === false)
|
||||
);
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
{#if $nextJellyfinEpisode}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() =>
|
||||
on:clickOrSelect={() =>
|
||||
$nextJellyfinEpisode?.Id && playerState.streamJellyfinId($nextJellyfinEpisode.Id)}
|
||||
>
|
||||
Play Season {$nextJellyfinEpisode?.ParentIndexNumber} Episode
|
||||
@@ -155,7 +155,8 @@
|
||||
{#if sonarrItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => modalStack.create(ManageMediaModal, { id: sonarrItem.id || -1 })}
|
||||
on:clickOrSelect={() =>
|
||||
modalStack.create(ManageMediaModal, { id: sonarrItem.id || -1 })}
|
||||
>
|
||||
{#if jellyfinItem}
|
||||
Manage Files
|
||||
@@ -167,7 +168,7 @@
|
||||
{:else}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => addSeriesToSonarr(Number(id))}
|
||||
on:clickOrSelect={() => addSeriesToSonarr(Number(id))}
|
||||
inactive={$addSeriesToSonarrFetching}
|
||||
>
|
||||
Add to Sonarr
|
||||
@@ -193,6 +194,7 @@
|
||||
<EpisodeCarousel
|
||||
id={Number(id)}
|
||||
tmdbSeries={tmdbSeriesData}
|
||||
{jellyfinEpisodes}
|
||||
{nextJellyfinEpisode}
|
||||
bind:selectedTmdbEpisode
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Bookmark, CardStack, Gear, Laptop, MagnifyingGlass } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import type { Readable, Writable } from 'svelte/store';
|
||||
import Container from '../../../Container.svelte';
|
||||
import { useNavigate } from 'svelte-navigator';
|
||||
|
||||
let isNavBarOpen: Readable<boolean>;
|
||||
let focusIndex: Readable<number>;
|
||||
let focusIndex: Writable<number>;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const itemContainer = (index: number, _focusIndex: number) =>
|
||||
@@ -33,29 +33,29 @@
|
||||
|
||||
<div class={'flex flex-col flex-1 relative z-20 items-center'}>
|
||||
<div class={'flex flex-col flex-1 justify-center'}>
|
||||
<Container on:click={() => navigate('/')}>
|
||||
<Container on:clickOrSelect={() => navigate('/')}>
|
||||
<div class={itemContainer(0, $focusIndex)}>
|
||||
<Laptop class="w-8 h-8" slot="icon" />
|
||||
</div>
|
||||
</Container>
|
||||
<Container on:click={() => navigate('movies')}>
|
||||
<Container on:clickOrSelect={() => navigate('movies')}>
|
||||
<div class={itemContainer(1, $focusIndex)}>
|
||||
<CardStack class="w-8 h-8" slot="icon" />
|
||||
</div>
|
||||
</Container>
|
||||
<Container on:click={() => navigate('library')}>
|
||||
<Container on:clickOrSelect={() => navigate('library')}>
|
||||
<div class={itemContainer(2, $focusIndex)}>
|
||||
<Bookmark class="w-8 h-8" slot="icon" />
|
||||
</div>
|
||||
</Container>
|
||||
<Container on:click={() => navigate('search')}>
|
||||
<Container on:clickOrSelect={() => navigate('search')}>
|
||||
<div class={itemContainer(3, $focusIndex)}>
|
||||
<MagnifyingGlass class="w-8 h-8" slot="icon" />
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<Container on:click={() => navigate('manage')}>
|
||||
<Container on:clickOrSelect={() => navigate('manage')}>
|
||||
<div class={itemContainer(4, $focusIndex)}>
|
||||
<Gear class="w-8 h-8" slot="icon" />
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,8 @@
|
||||
{#if jellyfinItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => jellyfinItem.Id && playerState.streamJellyfinId(jellyfinItem.Id)}
|
||||
on:clickOrSelect={() =>
|
||||
jellyfinItem.Id && playerState.streamJellyfinId(jellyfinItem.Id)}
|
||||
>
|
||||
Play
|
||||
<Play size={19} slot="icon" />
|
||||
@@ -91,7 +92,8 @@
|
||||
{#if radarrItem}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => modalStack.create(ManageMediaModal, { id: radarrItem.id || -1 })}
|
||||
on:clickOrSelect={() =>
|
||||
modalStack.create(ManageMediaModal, { id: radarrItem.id || -1 })}
|
||||
>
|
||||
{#if jellyfinItem}
|
||||
Manage Files
|
||||
@@ -103,7 +105,7 @@
|
||||
{:else}
|
||||
<Button
|
||||
class="mr-2"
|
||||
on:click={() => requests.handleAddToRadarr(Number(id))}
|
||||
on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))}
|
||||
inactive={$isFetching.handleAddToRadarr}
|
||||
>
|
||||
Add to Radarr
|
||||
|
||||
@@ -51,6 +51,7 @@ export class Selectable {
|
||||
private navigationActions: NavigationActions = {};
|
||||
private isActive: boolean = true;
|
||||
private onFocus?: FocusHandler;
|
||||
private onSelect?: () => void;
|
||||
|
||||
private direction: FlowDirection = 'vertical';
|
||||
private gridColumns: number = 0;
|
||||
@@ -422,10 +423,8 @@ export class Selectable {
|
||||
return this.focusByDefault || this.parent?.shouldFocusByDefault() || false;
|
||||
}
|
||||
|
||||
click() {
|
||||
if (this.htmlElement) {
|
||||
this.htmlElement.click();
|
||||
}
|
||||
select() {
|
||||
this.onSelect?.();
|
||||
}
|
||||
|
||||
getFocusedChild() {
|
||||
@@ -473,6 +472,11 @@ export class Selectable {
|
||||
this.onFocus = onFocus;
|
||||
return this;
|
||||
}
|
||||
|
||||
setOnSelect(onSelect: () => void) {
|
||||
this.onSelect = onSelect;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
@@ -501,7 +505,9 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
} else if (event.key === 'Enter') {
|
||||
if (navigationActions.enter && navigationActions.enter(currentlyFocusedObject))
|
||||
event.preventDefault();
|
||||
else currentlyFocusedObject.click();
|
||||
else {
|
||||
currentlyFocusedObject.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user