feat: Show episode details
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</script>
|
||||
|
||||
<Container
|
||||
navigationActions={{
|
||||
handleNavigateOut={{
|
||||
left: () => {
|
||||
history.back();
|
||||
return false;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</script>
|
||||
|
||||
<Container
|
||||
navigationActions={{
|
||||
handleNavigateOut={{
|
||||
left: () => {
|
||||
modalStack.close(modalId);
|
||||
return true;
|
||||
|
||||
27
src/lib/components/ScrollHelper.svelte
Normal file
27
src/lib/components/ScrollHelper.svelte
Normal 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} />
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<Container
|
||||
navigationActions={{}}
|
||||
handleNavigateOut={{}}
|
||||
focusOnMount
|
||||
trapFocus
|
||||
class={classNames('fixed inset-0 bg-black overflow-auto', {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user