diff --git a/src/Container.svelte b/src/Container.svelte index e79f935..da85f72 100644 --- a/src/Container.svelte +++ b/src/Container.svelte @@ -3,10 +3,11 @@ + import { type KeyEvent, type NavigateEvent, useRegistrar } from '../../selectable.js'; + import { get } from 'svelte/store'; - { - if ( - detail.direction === 'left' && - detail.willLeaveContainer && - detail.selectable === detail.options.target - ) { - history.back(); + const selectable = useRegistrar(); + + function handleGoBack({ detail }: CustomEvent | CustomEvent) { + 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" -> - + + history.back(); + } + + function handleGoToTop({ detail }: CustomEvent | CustomEvent) { + if ('willLeaveContainer' in detail) { + // Navigate event + if (detail.direction === 'left' && detail.willLeaveContainer) { + detail.preventNavigation(); + get(selectable)?.focus(); + } + } else { + // Back event + get(selectable)?.focus(); + } + } + + + + + + + diff --git a/src/lib/components/HeroCarousel/HeroCarousel.svelte b/src/lib/components/HeroCarousel/HeroCarousel.svelte index bb220c5..05ac3d3 100644 --- a/src/lib/components/HeroCarousel/HeroCarousel.svelte +++ b/src/lib/components/HeroCarousel/HeroCarousel.svelte @@ -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; @@ -47,15 +50,21 @@ { + 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); - detail.preventNavigation(); + 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 diff --git a/src/lib/components/HeroShowcase/HeroShowcase.svelte b/src/lib/components/HeroShowcase/HeroShowcase.svelte index 63de093..1847516 100644 --- a/src/lib/components/HeroShowcase/HeroShowcase.svelte +++ b/src/lib/components/HeroShowcase/HeroShowcase.svelte @@ -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 = 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(); + } + }} > {#await items} diff --git a/src/lib/components/SeriesPage/EpisodeCarousel.svelte b/src/lib/components/SeriesPage/EpisodeCarousel.svelte index 847d298..49dde6a 100644 --- a/src/lib/components/SeriesPage/EpisodeCarousel.svelte +++ b/src/lib/components/SeriesPage/EpisodeCarousel.svelte @@ -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; diff --git a/src/lib/components/SeriesPage/SeriesPage.svelte b/src/lib/components/SeriesPage/SeriesPage.svelte index 6d92f4f..ef1fa5e 100644 --- a/src/lib/components/SeriesPage/SeriesPage.svelte +++ b/src/lib/components/SeriesPage/SeriesPage.svelte @@ -49,7 +49,7 @@ $: showEpisodeInfo = scrollTop > 140; - + + {#if $nextJellyfinEpisode} $nextJellyfinEpisode?.Id && playerState.streamJellyfinId($nextJellyfinEpisode.Id)} @@ -156,7 +163,7 @@ {/if} {#if sonarrItem} modalStack.create(SonarrMediaMangerModal, { id: sonarrItem.id || -1 })} > @@ -169,7 +176,7 @@ {:else} addSeriesToSonarr(Number(id))} inactive={$addSeriesToSonarrFetching} > @@ -178,11 +185,11 @@ {/if} {#if PLATFORM_WEB} - + Open In TMDB - + Open In Jellyfin @@ -192,7 +199,7 @@ - + (progressTime = !seeking && videoDidLoad ? video.currentTime : progressTime)} on:progress={handleProgress} on:loadeddata={() => { - console.log('loadedData'); video.currentTime = progressTime; videoDidLoad = true; }} diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index c3eebf1..48ca682 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -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} > { - console.log('Video destroyed'); clearInterval(progressInterval); if (fullscreen) exitFullscreen?.(); }); diff --git a/src/lib/pages/MoviePage.svelte b/src/lib/pages/MoviePage.svelte index 43e8f01..53d111d 100644 --- a/src/lib/pages/MoviePage.svelte +++ b/src/lib/pages/MoviePage.svelte @@ -32,7 +32,7 @@ }); - + + {#if jellyfinItem} jellyfinItem.Id && playerState.streamJellyfinId(jellyfinItem.Id)} > @@ -92,7 +99,7 @@ {/if} {#if radarrItem} modalStack.create(ManageMediaModal, { id: radarrItem.id || -1 })} > @@ -105,7 +112,7 @@ {:else} requests.handleAddToRadarr(Number(id))} inactive={$isFetching.handleAddToRadarr} > @@ -114,11 +121,11 @@ {/if} {#if PLATFORM_WEB} - + Open In TMDB - + Open In Jellyfin diff --git a/src/lib/selectable.ts b/src/lib/selectable.ts index 39854c4..fb8aa49 100644 --- a/src/lib/selectable.ts +++ b/src/lib/selectable.ts @@ -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(); - } + 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 scrollElementIntoView(element, ...args); } }; + +export const useRegistrar = (): { registrar: Registrar } & Readable => { + const selectable = writable(); + + function registrar(_selectable: Selectable) { + selectable.set(_selectable); + } + + return { + registrar, + subscribe: selectable.subscribe + }; +}; + +export const sidebarSelectable = useRegistrar();