feat: Show episode details

This commit is contained in:
Aleksi Lassila
2024-04-05 17:05:30 +03:00
parent 2a319148df
commit bd02bd3193
12 changed files with 135 additions and 45 deletions

View File

@@ -16,12 +16,12 @@
export let active = true;
export let navigationActions: NavigationActions = {};
export let handleNavigateOut: NavigationActions = {};
const { registerer, ...rest } = new Selectable(name)
.setDirection(direction === 'grid' ? 'horizontal' : direction)
.setGridColumns(gridCols)
.setNavigationActions(navigationActions)
.setNavigationActions(handleNavigateOut)
.setTrapFocus(trapFocus)
.setCanFocusEmpty(canFocusEmpty)
.setOnFocus(handleFocus)

View File

@@ -3,6 +3,7 @@
import { ChevronLeft, ChevronRight } from 'radix-icons-svelte';
import classNames from 'classnames';
import Container from '../../../Container.svelte';
import { PLATFORM_TV } from '../../constants';
export let gradientFromColor = 'from-stone-950';
export let heading = '';
@@ -22,7 +23,7 @@
'flex gap-2 ml-4',
//'sm:opacity-0 transition-opacity sm:group-hover/carousel:opacity-100',
{
hidden: (carousel?.scrollWidth || 0) === (carousel?.clientWidth || 0)
hidden: (carousel?.scrollWidth || 0) === (carousel?.clientWidth || 0) || PLATFORM_TV
}
)}
>
@@ -44,7 +45,7 @@
</div>
<div class="relative">
<Container direction="horizontal" navigationActions={{ left: () => true }}>
<Container direction="horizontal" handleNavigateOut={{ left: () => true }}>
<div
class={classNames(
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide',

View File

@@ -32,7 +32,7 @@
}black 5%, black 95%, ${fadeRight ? '' : 'black 100%, '}transparent 100%);`}
on:scroll={updateScrollPosition}
bind:this={element}
navigationActions={{ left: () => true }}
handleNavigateOut={{ left: () => true }}
>
<slot />
</Container>

View File

@@ -3,7 +3,7 @@
</script>
<Container
navigationActions={{
handleNavigateOut={{
left: () => {
history.back();
return false;

View File

@@ -42,7 +42,7 @@
<Container class="flex-1 flex">
<HeroShowcaseBackground {urls} {index} />
<Container
navigationActions={{
handleNavigateOut={{
right: onNext,
left: onPrevious,
up: () => Selectable.giveFocus('left', true) || true

View File

@@ -8,7 +8,7 @@
</script>
<Container
navigationActions={{
handleNavigateOut={{
left: () => {
modalStack.close(modalId);
return true;

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getScrollParent } from '../utils';
export let scrollTop: number = 0;
export let scrollLeft: number = 0;
let div: HTMLElement;
onMount(() => {
const verticalScrollParent = getScrollParent(div, 'vertical');
const horizontalScrollParent = getScrollParent(div, 'horizontal');
function handler() {
scrollTop = verticalScrollParent ? verticalScrollParent.scrollTop : scrollTop;
scrollLeft = horizontalScrollParent ? horizontalScrollParent.scrollLeft : scrollLeft;
}
verticalScrollParent?.addEventListener('scroll', handler);
return () => {
verticalScrollParent?.removeEventListener('scroll', handler);
};
});
</script>
<div bind:this={div} />

View File

@@ -18,6 +18,7 @@
export let id: number;
export let tmdbSeries: Readable<TmdbSeriesFull2 | undefined>;
export let nextEpisode: JellyfinItem | undefined = undefined;
export let selectedTmdbEpisode: TmdbEpisode | undefined = undefined;
const { data: tmdbSeasons, isLoading: isTmdbSeasonsLoading } = useDependantRequest(
(seasons: number) => tmdbApi.getTmdbSeriesSeasons(id, seasons),
@@ -49,6 +50,10 @@
const seasonSelectable = containers[`season-${episode.season_number}`]?.container;
if (seasonSelectable) seasonSelectable.focus(false);
}
tmdbSeasons.subscribe((seasons) => {
selectedTmdbEpisode = seasons?.[0]?.episodes?.[0];
});
</script>
{#if $isTmdbSeasonsLoading}
@@ -79,7 +84,7 @@
</Container>
{/each}
</UICarousel>
<div class="flex">
<div class="flex -mx-2">
{#each $tmdbSeasons as season}
{#each season?.episodes || [] as episode}
<Container
@@ -87,6 +92,7 @@
bind:this={containers[`episode-${episode.id}`]}
handleFocus={(s, didNavigate) => {
scrollIntoView({ left: 64 + 16 })(s);
selectedTmdbEpisode = episode;
if (didNavigate) handleFocusEpisode(episode);
}}
>

View File

@@ -1,10 +1,9 @@
<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 { tmdbApi, type TmdbEpisode } 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';
@@ -16,7 +15,8 @@
import ManageMediaModal from '../ManageMedia/ManageMediaModal.svelte';
import { derived } from 'svelte/store';
import EpisodeCarousel from './EpisodeCarousel.svelte';
import { scrollIntoView } from '../../selectable';
import { scrollIntoView, Selectable } from '../../selectable';
import ScrollHelper from '../ScrollHelper.svelte';
export let id: string;
@@ -44,12 +44,26 @@
const { send: addSeriesToSonarr, isFetching: addSeriesToSonarrFetching } = useActionRequest(
sonarrApi.addSeriesToSonarr
);
let selectedTmdbEpisode: TmdbEpisode | undefined;
let episodesSelectable: Selectable;
let scrollTop: number;
$: showEpisodeInfo = scrollTop > 200;
</script>
<DetachedPage>
<ScrollHelper bind:scrollTop />
<Container
class="h-screen flex flex-col py-12 px-20 relative"
handleFocus={scrollIntoView({ top: 0 })}
handleNavigateOut={{
down: () => {
console.log('Here', episodesSelectable);
episodesSelectable?.focusChildren(1);
return true;
}
}}
>
<HeroCarousel
urls={$tmdbSeries.then(
@@ -62,7 +76,38 @@
>
<div class="h-full flex-1 flex flex-col justify-end">
{#await $tmdbSeries then series}
{#if series}
{#if showEpisodeInfo && selectedTmdbEpisode}
{@const episode = selectedTmdbEpisode}
<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': episode.name?.length || 0 < 15,
'text-3xl sm:text-4xl 2xl:text-5xl': episode?.name?.length || 0 >= 15
}
)}
>
{episode.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">
{episode.runtime} Minutes
</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}/episode/${episode.id}`}
>{episode.vote_average} TMDB</a
>
</p>
</div>
<div class="text-stone-300 font-medium line-clamp-3 opacity-75 max-w-4xl mt-4">
{episode.overview}
</div>
{:else if series}
<div
class={classNames(
'text-left font-medium tracking-wider text-stone-200 hover:text-amber-200 mt-2',
@@ -148,7 +193,7 @@
</div>
</HeroCarousel>
</Container>
<Container handleFocus={scrollIntoView({ vertical: 64 })}>
<EpisodeCarousel id={Number(id)} tmdbSeries={tmdbSeriesData} />
<Container handleFocus={scrollIntoView({ vertical: 64 })} bind:container={episodesSelectable}>
<EpisodeCarousel id={Number(id)} tmdbSeries={tmdbSeriesData} bind:selectedTmdbEpisode />
</Container>
</DetachedPage>

View File

@@ -10,7 +10,7 @@
</script>
<Container
navigationActions={{}}
handleNavigateOut={{}}
focusOnMount
trapFocus
class={classNames('fixed inset-0 bg-black overflow-auto', {

View File

@@ -1,4 +1,5 @@
import { derived, get, type Readable, type Writable, writable } from 'svelte/store';
import { getScrollParent } from './utils';
export type Registerer = (htmlElement: HTMLElement) => { destroy: () => void };
@@ -11,7 +12,7 @@ export type NavigationActions = {
enter?: (selectable: Selectable) => boolean;
};
export type FocusHandler = (target: Selectable) => void;
export type FocusHandler = (selectable: Selectable, didNavigate: boolean) => void;
export class Selectable {
id: symbol;
@@ -31,7 +32,7 @@ export class Selectable {
private isInitialized: boolean = false;
private navigationActions: NavigationActions = {};
private isActive: boolean = true;
private onFocus?: (selectable: Selectable, didNavigate: boolean) => void;
private onFocus?: FocusHandler;
private direction: FlowDirection = 'vertical';
private gridColumns: number = 0;
@@ -76,18 +77,18 @@ export class Selectable {
}
focus(didNavigate: boolean = true) {
function updateFocusIndex(currentSelectable: Selectable, selectable?: Selectable) {
if (selectable) {
const index = currentSelectable.children.indexOf(selectable);
currentSelectable.focusIndex.update((prev) => (index === -1 ? prev : index));
function updateFocusIndex(parent: Selectable, child?: Selectable) {
if (!get(parent.hasFocusWithin)) parent.onFocus?.(parent, didNavigate);
if (child) {
const index = parent.children.indexOf(child);
parent.focusIndex.update((prev) => (index === -1 ? prev : index));
}
if (currentSelectable.parent) {
updateFocusIndex(currentSelectable.parent, currentSelectable);
if (parent.parent) {
updateFocusIndex(parent.parent, parent);
}
}
if (!get(this.hasFocusWithin)) this.onFocus?.(this, didNavigate);
if (this.children.length > 0) {
const focusIndex = get(this.focusIndex);
@@ -123,6 +124,16 @@ export class Selectable {
}
}
focusChildren(index: number, didNavigate = true): boolean {
const child = this.children[index];
if (child && child.isFocusable()) {
child.focus(didNavigate);
return true;
}
return false;
}
/**
* @returns {boolean} whether the selectable is focusable
*/
@@ -477,24 +488,6 @@ type Offsets = Partial<
>
>;
export const scrollElementIntoView = (htmlElement: HTMLElement, offsets: Offsets = { all: 16 }) => {
function getScrollParent(
node: HTMLElement,
direction: 'vertical' | 'horizontal'
): HTMLElement | undefined {
const parent = node.parentElement;
if (parent) {
if (
(direction === 'vertical' && parent.scrollHeight > parent.clientHeight) ||
(direction === 'horizontal' && parent.scrollWidth > parent.clientWidth)
) {
return parent;
} else {
return getScrollParent(parent, direction);
}
}
}
if (offsets.vertical !== undefined) {
offsets.top = offsets.vertical;
offsets.bottom = offsets.vertical;
@@ -592,7 +585,7 @@ export const scrollElementIntoView = (htmlElement: HTMLElement, offsets: Offsets
}
};
export const scrollIntoView: (...args: [Offsets]) => FocusHandler =
export const scrollIntoView: (...args: [Offsets]) => (s: Selectable) => void =
(...args) =>
(s) => {
const element = s.getHtmlElement();

View File

@@ -87,3 +87,21 @@ export function capitalize(str: string) {
const strings = str.split(' ');
return strings.map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
}
export function getScrollParent(
node: HTMLElement,
direction: 'vertical' | 'horizontal'
): HTMLElement | undefined {
const parent = node.parentElement;
if (parent) {
if (
(direction === 'vertical' && parent.scrollHeight > parent.clientHeight) ||
(direction === 'horizontal' && parent.scrollWidth > parent.clientWidth)
) {
return parent;
} else {
return getScrollParent(parent, direction);
}
}
}