feat: Implement back button and selectable registrars

This commit is contained in:
Aleksi Lassila
2024-04-16 01:50:13 +03:00
parent f519fb7447
commit 32bde1ff9e
16 changed files with 197 additions and 89 deletions

View File

@@ -3,10 +3,11 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import {
type NavigationActions,
Selectable,
type EnterEvent,
type NavigateEvent
type NavigateEvent,
type KeyEvent,
type Registrar
} from './lib/selectable';
import classNames from 'classnames';
@@ -17,6 +18,8 @@
enter: EnterEvent;
mount: Selectable;
navigate: NavigateEvent;
back: KeyEvent;
playPause: KeyEvent;
}>();
export let name: string = '';
@@ -28,14 +31,14 @@
export let debugOutline = false;
export let focusOnClick = false;
export let active = true;
export let registrars: Registrar[] = [];
export let registrar: Registrar = () => {};
export let handleNavigateOut: NavigationActions = {};
export let active = true;
const { registerer, ...rest } = new Selectable(name)
.setDirection(direction === 'grid' ? 'horizontal' : direction)
.setGridColumns(gridCols)
.setNavigationActions(handleNavigateOut)
.setTrapFocus(trapFocus)
.setCanFocusEmpty(canFocusEmpty)
.setOnFocus((selectable, options) => {
@@ -67,20 +70,46 @@
dispatch('select');
dispatch('clickOrSelect');
})
.setOnBack((selectable, options) => {
function stopPropagation() {
options.propagate = false;
}
function bubble() {
options.propagate = true;
}
dispatch('back', { selectable, options, stopPropagation, bubble });
})
.setOnPlayPause((selectable, options) => {
function stopPropagation() {
options.propagate = false;
}
function bubble() {
options.propagate = true;
}
dispatch('playPause', { selectable, options, stopPropagation, bubble });
})
.getStores();
export const container = rest.container;
$: registrars.forEach((r) => r(rest.container));
$: registrar(rest.container);
export const selectable = rest.container;
export const hasFocus = rest.hasFocus;
export const hasFocusWithin = rest.hasFocusWithin;
export const focusIndex = rest.focusIndex;
export let tag = 'div';
$: container.setIsActive(active);
$: container.setGridColumns(gridCols);
$: selectable.setIsActive(active);
$: selectable.setGridColumns(gridCols);
function handleClick(e: MouseEvent) {
if (focusOnClick) {
container.focus();
selectable.focus();
}
dispatch('click', e);
@@ -109,5 +138,10 @@
})}
use:registerer
>
<slot hasFocus={$hasFocus} hasFocusWithin={$hasFocusWithin} focusIndex={$focusIndex} />
<slot
hasFocus={$hasFocus}
hasFocusWithin={$hasFocusWithin}
focusIndex={$focusIndex}
{selectable}
/>
</svelte:element>

View File

@@ -83,7 +83,6 @@
async function onJump(index: number) {
showcaseIndex = index;
console.log(showcaseIndex);
}
// Cycle movies every 5 seconds

View File

@@ -45,12 +45,7 @@
</div>
<div class="relative">
<Container
direction="horizontal"
handleNavigateOut={{ left: () => true }}
let:focusIndex
on:enter
>
<Container direction="horizontal" let:focusIndex on:enter>
<div
class={classNames(
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide',

View File

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

View File

@@ -1,21 +1,36 @@
<script>
<script lang="ts">
import Container from '../../../Container.svelte';
</script>
import { type KeyEvent, type NavigateEvent, useRegistrar } from '../../selectable.js';
import { get } from 'svelte/store';
<Container
on:navigate={({ detail }) => {
if (
detail.direction === 'left' &&
detail.willLeaveContainer &&
detail.selectable === detail.options.target
) {
history.back();
const selectable = useRegistrar();
function handleGoBack({ detail }: CustomEvent<KeyEvent> | CustomEvent<NavigateEvent>) {
if ('willLeaveContainer' in detail) {
if (detail.direction !== 'left' || !detail.willLeaveContainer) return;
detail.preventNavigation();
}
}}
focusOnMount
trapFocus
class="fixed inset-0 z-20 bg-stone-950 overflow-y-auto"
>
<slot />
history.back();
}
function handleGoToTop({ detail }: CustomEvent<KeyEvent> | CustomEvent<NavigateEvent>) {
if ('willLeaveContainer' in detail) {
// Navigate event
if (detail.direction === 'left' && detail.willLeaveContainer) {
detail.preventNavigation();
get(selectable)?.focus();
}
} else {
// Back event
get(selectable)?.focus();
}
}
</script>
<Container class="fixed inset-0 z-20 bg-stone-950 overflow-y-auto" trapFocus direction="horizontal">
<Container />
<Container on:navigate={handleGoToTop} on:back={handleGoToTop} focusOnMount>
<slot {handleGoBack} registrar={selectable.registrar} />
</Container>
</Container>

View File

@@ -7,6 +7,9 @@
import PageDots from '../HeroShowcase/PageDots.svelte';
import SidebarMargin from '../SidebarMargin.svelte';
import type { Readable, Writable } from 'svelte/store';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let urls: Promise<string[]>;
@@ -47,15 +50,21 @@
<Container
class="flex-1 flex"
on:enter
on:navigate={({ detail }) => {
on:navigate={(event) => {
const detail = event.detail;
if (!backgroundHasFocus) return;
if (detail.options.direction === 'right') {
if (onNext()) detail.preventNavigation();
} else if (detail.options.direction === 'left') {
if (onPrevious()) detail.preventNavigation();
} else if (detail.options.direction === 'up') {
Selectable.giveFocus('left', false);
if (detail.direction === 'right') {
if (onNext()) {
detail.preventNavigation();
detail.stopPropagation();
}
} else if (detail.direction === 'left') {
if (onPrevious()) {
detail.preventNavigation();
detail.stopPropagation();
}
} else {
dispatch('navigate', detail);
}
}}
bind:hasFocusWithin

View File

@@ -7,6 +7,8 @@
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '../../constants';
import HeroCarousel from '../HeroCarousel/HeroCarousel.svelte';
import SidebarMargin from '../SidebarMargin.svelte';
import { get } from 'svelte/store';
import { sidebarSelectable } from '../../selectable';
export let items: Promise<ShowcaseItemProps[]> = Promise.resolve([]);
@@ -17,6 +19,11 @@
urls={items.then((items) => items.map((i) => `${TMDB_IMAGES_ORIGINAL}${i.backdropUrl}`))}
bind:index={showcaseIndex}
on:enter
on:navigate={({ detail }) => {
if (detail.direction === 'up') {
get(sidebarSelectable)?.focus();
}
}}
>
<div class="h-full flex-1 flex overflow-hidden z-10 relative">
{#await items}

View File

@@ -63,8 +63,6 @@
// Handle focus next episode
nextJellyfinEpisode.subscribe(($jellyfinEpisode) => {
console.log('got next jellyfin episode', $jellyfinEpisode, tmdbEpisode, selectable);
const isNextEpisode =
$jellyfinEpisode?.IndexNumber === tmdbEpisode.episode_number &&
$jellyfinEpisode?.ParentIndexNumber === tmdbEpisode.season_number;

View File

@@ -49,7 +49,7 @@
$: showEpisodeInfo = scrollTop > 140;
</script>
<DetachedPage>
<DetachedPage let:handleGoBack let:registrar>
<ScrollHelper bind:scrollTop />
<div class="relative">
<Container
@@ -141,10 +141,17 @@
{/if}
{/await}
{#await Promise.all([$jellyfinItem, $sonarrItem]) then [jellyfinItem, sonarrItem]}
<Container direction="horizontal" class="flex mt-8" focusOnMount>
<Container
direction="horizontal"
class="flex mt-8"
focusOnMount
on:navigate={handleGoBack}
on:back={handleGoBack}
{registrar}
>
{#if $nextJellyfinEpisode}
<Button
class="mr-2"
class="mr-4"
on:clickOrSelect={() =>
$nextJellyfinEpisode?.Id &&
playerState.streamJellyfinId($nextJellyfinEpisode.Id)}
@@ -156,7 +163,7 @@
{/if}
{#if sonarrItem}
<Button
class="mr-2"
class="mr-4"
on:clickOrSelect={() =>
modalStack.create(SonarrMediaMangerModal, { id: sonarrItem.id || -1 })}
>
@@ -169,7 +176,7 @@
</Button>
{:else}
<Button
class="mr-2"
class="mr-4"
on:clickOrSelect={() => addSeriesToSonarr(Number(id))}
inactive={$addSeriesToSonarrFetching}
>
@@ -178,11 +185,11 @@
</Button>
{/if}
{#if PLATFORM_WEB}
<Button class="mr-2">
<Button class="mr-4">
Open In TMDB
<ExternalLink size={19} slot="icon-after" />
</Button>
<Button class="mr-2">
<Button class="mr-4">
Open In Jellyfin
<ExternalLink size={19} slot="icon-after" />
</Button>
@@ -192,7 +199,7 @@
</div>
</HeroCarousel>
</Container>
<Container on:enter={scrollIntoView({ vertical: 64 })} bind:container={episodesSelectable}>
<Container on:enter={scrollIntoView({ vertical: 64 })} bind:selectable={episodesSelectable}>
<EpisodeCarousel
id={Number(id)}
tmdbSeries={tmdbSeriesData}

View File

@@ -4,7 +4,7 @@
import { type Readable, writable, type Writable } from 'svelte/store';
import Container from '../../../Container.svelte';
import { useNavigate } from 'svelte-navigator';
import type { Selectable } from '../../selectable';
import { type Selectable, sidebarSelectable } from '../../selectable';
const navigate = useNavigate();
let selectedIndex = 0;
@@ -47,7 +47,8 @@
)}
bind:hasFocusWithin={isNavBarOpen}
bind:focusIndex
bind:container={selectable}
bind:selectable
registrar={sidebarSelectable.registrar}
>
<!-- Background -->
<div

View File

@@ -73,7 +73,6 @@
? item?.UserData?.PlaybackPositionTicks / 10_000_000
: undefined
};
console.log('startTime', playbackInfo.startTime);
if (mediaSourceId) reportPlaybackStarted(id, sessionId, mediaSourceId);

View File

@@ -82,7 +82,6 @@
on:timeupdate={() => (progressTime = !seeking && videoDidLoad ? video.currentTime : progressTime)}
on:progress={handleProgress}
on:loadeddata={() => {
console.log('loadedData');
video.currentTime = progressTime;
videoDidLoad = true;
}}

View File

@@ -82,7 +82,7 @@
class={classNames('absolute inset-x-12 bottom-8 transition-opacity flex flex-col', {
'opacity-0': !showInterface
})}
bind:container
bind:selectable={container}
>
<Container
direction="horizontal"

View File

@@ -305,7 +305,6 @@
});
onDestroy(() => {
console.log('Video destroyed');
clearInterval(progressInterval);
if (fullscreen) exitFullscreen?.();
});

View File

@@ -32,7 +32,7 @@
});
</script>
<DetachedPage>
<DetachedPage let:handleGoBack let:registrar>
<div class="min-h-screen flex flex-col py-12 px-20 relative">
<HeroCarousel
urls={$movieDataP.then(
@@ -79,10 +79,17 @@
{/if}
{/await}
{#await Promise.all([$jellyfinItemP, $radarrItemP]) then [jellyfinItem, radarrItem]}
<Container direction="horizontal" class="flex mt-8" focusOnMount>
<Container
direction="horizontal"
class="flex mt-8"
focusOnMount
on:navigate={handleGoBack}
on:back={handleGoBack}
{registrar}
>
{#if jellyfinItem}
<Button
class="mr-2"
class="mr-4"
on:clickOrSelect={() =>
jellyfinItem.Id && playerState.streamJellyfinId(jellyfinItem.Id)}
>
@@ -92,7 +99,7 @@
{/if}
{#if radarrItem}
<Button
class="mr-2"
class="mr-4"
on:clickOrSelect={() =>
modalStack.create(ManageMediaModal, { id: radarrItem.id || -1 })}
>
@@ -105,7 +112,7 @@
</Button>
{:else}
<Button
class="mr-2"
class="mr-4"
on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))}
inactive={$isFetching.handleAddToRadarr}
>
@@ -114,11 +121,11 @@
</Button>
{/if}
{#if PLATFORM_WEB}
<Button class="mr-2">
<Button class="mr-4">
Open In TMDB
<ExternalLink size={19} slot="icon-after" />
</Button>
<Button class="mr-2">
<Button class="mr-4">
Open In Jellyfin
<ExternalLink size={19} slot="icon-after" />
</Button>

View File

@@ -2,15 +2,10 @@ import { derived, get, type Readable, type Writable, writable } from 'svelte/sto
import { getScrollParent } from './utils';
export type Registerer = (htmlElement: HTMLElement) => { destroy: () => void };
export type Registrar = (selectable: Selectable) => void;
export type Direction = 'up' | 'down' | 'left' | 'right';
export type FlowDirection = 'vertical' | 'horizontal';
export type NavigationActions = {
[direction in Direction]?: (selectable: Selectable) => boolean;
} & {
back?: (selectable: Selectable) => boolean;
enter?: (selectable: Selectable) => boolean;
};
type FocusEventOptions = {
setFocusedElement: boolean | HTMLElement;
@@ -58,12 +53,30 @@ const createNavigateHandlerOptions = (
direction
});
type KeyEventOptions = {
propagate: boolean;
target: Selectable;
};
export type KeyEvent = {
selectable: Selectable;
options: KeyEventOptions;
stopPropagation: () => void;
bubble: () => void;
};
const createKeyEventOptions = (target: Selectable): KeyEventOptions => ({
propagate: true,
target
});
export type FocusHandler = (selectable: Selectable, options: FocusEventOptions) => void;
export type NavigationHandler = (
selectable: Selectable,
options: NavigateEventOptions,
willLeaveContainer: boolean
) => void;
export type KeyEventHandler = (selectable: Selectable, options: KeyEventOptions) => void;
export class Selectable {
id: symbol;
@@ -79,10 +92,12 @@ export class Selectable {
};
private canFocusEmpty: boolean = true;
private trapFocus: boolean = false;
private navigationActions: NavigationActions = {};
private onNavigate: NavigationHandler = () => {};
private isActive: boolean = true;
private onNavigate: NavigationHandler = () => {};
private onFocus: FocusHandler = () => {};
private onBack: KeyEventHandler = () => {};
private onPlayPause: KeyEventHandler = () => {};
private onSelect?: () => void;
private direction: FlowDirection = 'vertical';
@@ -581,19 +596,22 @@ export class Selectable {
this.onSelect?.();
}
back(options?: KeyEventOptions) {
const _options = options || createKeyEventOptions(this);
this.onBack(this, _options);
if (this.parent && _options.propagate) this.parent.back(_options);
}
playPause(options?: KeyEventOptions) {
const _options = options || createKeyEventOptions(this);
this.onPlayPause(this, _options);
if (this.parent && _options.propagate) this.parent.playPause(_options);
}
getFocusedChild() {
return this.children[get(this.focusIndex)];
}
setNavigationActions(actions: NavigationActions) {
this.navigationActions = actions;
return this;
}
getNavigationActions(): NavigationActions {
return this.navigationActions;
}
setIsActive(isActive: boolean) {
this.isActive = isActive;
return this;
@@ -636,6 +654,16 @@ export class Selectable {
this.onNavigate = onNavigate;
return this;
}
setOnBack(onBack: KeyEventHandler) {
this.onBack = onBack;
return this;
}
setOnPlayPause(onPlayPause: KeyEventHandler) {
this.onPlayPause = onPlayPause;
return this;
}
}
export function handleKeyboardNavigation(event: KeyboardEvent) {
@@ -652,7 +680,6 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
return;
}
const navigationActions = currentlyFocusedObject.getNavigationActions();
if (event.key === 'ArrowUp') {
if (Selectable.giveFocus('up')) event.preventDefault();
} else if (event.key === 'ArrowDown') {
@@ -662,17 +689,15 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
} else if (event.key === 'ArrowRight') {
if (Selectable.giveFocus('right')) event.preventDefault();
} else if (event.key === 'Enter') {
if (navigationActions.enter && navigationActions.enter(currentlyFocusedObject))
event.preventDefault();
else {
currentlyFocusedObject.select();
}
} else if (event.key === 'Back' || event.key === 'XF86Back') {
currentlyFocusedObject.back();
} else if (event.key === 'MediaPlayPause') {
currentlyFocusedObject.playPause();
}
}
Selectable.focusedObject.subscribe(console.log);
Selectable.focusedObject.subscribe(console.debug);
type Offsets = Partial<
Record<
@@ -786,3 +811,18 @@ export const scrollIntoView: (...args: [Offsets]) => (e: CustomEvent<EnterEvent>
scrollElementIntoView(element, ...args);
}
};
export const useRegistrar = (): { registrar: Registrar } & Readable<Selectable> => {
const selectable = writable<Selectable>();
function registrar(_selectable: Selectable) {
selectable.set(_selectable);
}
return {
registrar,
subscribe: selectable.subscribe
};
};
export const sidebarSelectable = useRegistrar();