diff --git a/src/App.svelte b/src/App.svelte index e0ee380..10ef247 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -15,6 +15,7 @@ import MoviePage from './lib/pages/MoviePage.svelte'; import ModalStack from './lib/components/Modal/ModalStack.svelte'; import PageNotFound from './lib/pages/PageNotFound.svelte'; + import NavigationDebugger from './lib/components/NavigationDebugger.svelte'; appState.subscribe((s) => console.log('appState', s)); @@ -68,4 +69,6 @@ {/if} + + diff --git a/src/Container.svelte b/src/Container.svelte index 5f49a96..e79f935 100644 --- a/src/Container.svelte +++ b/src/Container.svelte @@ -45,15 +45,21 @@ dispatch('enter', { selectable, options, stopPropagation }); }) - .setOnNavigate((selectable, options) => { + .setOnNavigate((selectable, options, willLeaveContainer) => { function preventNavigation() { options.preventNavigation = true; } + function stopPropagation() { + options.propagate = false; + } + dispatch('navigate', { selectable, options, + willLeaveContainer, preventNavigation, + stopPropagation, direction: options.direction }); }) diff --git a/src/lib/components/DetachedPage/DetachedPage.svelte b/src/lib/components/DetachedPage/DetachedPage.svelte index a8ff4c4..f83aadb 100644 --- a/src/lib/components/DetachedPage/DetachedPage.svelte +++ b/src/lib/components/DetachedPage/DetachedPage.svelte @@ -4,7 +4,11 @@ { - if (detail.direction === 'left' && detail.options.willLeaveContainer) { + if ( + detail.direction === 'left' && + detail.options.willLeaveContainer && + detail.selectable === detail.options.target + ) { history.back(); detail.preventNavigation(); } diff --git a/src/lib/components/HeroCarousel/HeroCarousel.svelte b/src/lib/components/HeroCarousel/HeroCarousel.svelte index 3c2c6fe..ab5f0ec 100644 --- a/src/lib/components/HeroCarousel/HeroCarousel.svelte +++ b/src/lib/components/HeroCarousel/HeroCarousel.svelte @@ -48,7 +48,7 @@ } else if (detail.options.direction === 'left') { if (onPrevious()) detail.preventNavigation(); } else if (detail.options.direction === 'up') { - Selectable.giveFocus('left', true); + Selectable.giveFocus('left', false); detail.preventNavigation(); } }} diff --git a/src/lib/components/Modal/FullScreenModal.svelte b/src/lib/components/Modal/FullScreenModal.svelte index 2c4e6c1..9d33f53 100644 --- a/src/lib/components/Modal/FullScreenModal.svelte +++ b/src/lib/components/Modal/FullScreenModal.svelte @@ -9,7 +9,7 @@ { - if (detail.direction === 'left' && detail.options.willLeaveContainer) { + if (detail.direction === 'left' && detail.willLeaveContainer) { modalStack.close(modalId); detail.preventNavigation(); } diff --git a/src/lib/components/NavigationDebugger.svelte b/src/lib/components/NavigationDebugger.svelte new file mode 100644 index 0000000..e114530 --- /dev/null +++ b/src/lib/components/NavigationDebugger.svelte @@ -0,0 +1,55 @@ + + + + +{#if showOverlay} +
+{/if} diff --git a/src/lib/components/SeriesPage/EpisodeCarousel.svelte b/src/lib/components/SeriesPage/EpisodeCarousel.svelte index 756f8ef..cd9dfd6 100644 --- a/src/lib/components/SeriesPage/EpisodeCarousel.svelte +++ b/src/lib/components/SeriesPage/EpisodeCarousel.svelte @@ -21,6 +21,8 @@ export let jellyfinEpisodes: Readable; export let nextJellyfinEpisode: Readable; + export let selectedTmdbEpisode: TmdbEpisode | undefined; + const containers = new Map(); let scrollTop: number; @@ -131,6 +133,7 @@ on:enter={(event) => { scrollIntoView({ left: 64 + 16 })(event); focusSeason(season); + selectedTmdbEpisode = episode; }} on:mount={(e) => handleEpisodeMount(e, episode)} jellyfinEpisode={$jellyfinEpisodes?.find( diff --git a/src/lib/components/SeriesPage/SeriesPage.svelte b/src/lib/components/SeriesPage/SeriesPage.svelte index 43a2096..f1cc151 100644 --- a/src/lib/components/SeriesPage/SeriesPage.svelte +++ b/src/lib/components/SeriesPage/SeriesPage.svelte @@ -55,7 +55,7 @@ class="h-screen flex flex-col py-12 px-20 relative" on:enter={scrollIntoView({ top: 0 })} on:navigate={({ detail }) => { - if (detail.direction === 'down') { + if (detail.direction === 'down' && detail.willLeaveContainer) { if (episodesSelectable?.focusChild(1)) detail.preventNavigation(); } }} diff --git a/src/lib/components/VideoPlayer/ProgressBar.svelte b/src/lib/components/VideoPlayer/ProgressBar.svelte index af54678..31e7a97 100644 --- a/src/lib/components/VideoPlayer/ProgressBar.svelte +++ b/src/lib/components/VideoPlayer/ProgressBar.svelte @@ -33,7 +33,7 @@ } - +
diff --git a/src/lib/components/VideoPlayer/VideoPlayer.svelte b/src/lib/components/VideoPlayer/VideoPlayer.svelte index 30c3bc0..c3eebf1 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.svelte +++ b/src/lib/components/VideoPlayer/VideoPlayer.svelte @@ -5,7 +5,6 @@ import classNames from 'classnames'; import ProgressBar from './ProgressBar.svelte'; import { onDestroy } from 'svelte'; - import type { Readable } from 'svelte/store'; import type { Selectable } from '../../selectable'; export let playbackInfo: PlaybackInfo | undefined; @@ -22,25 +21,44 @@ export let video: HTMLVideoElement; let showInterface = true; + let showInterfaceTimeout: ReturnType; let hideInterfaceTimeout: ReturnType; let container: Selectable; - function handleMouseMove() { + function handleShowInterface() { showInterface = true; - clearTimeout(hideInterfaceTimeout); - hideInterfaceTimeout = setTimeout(() => { - if (seeking) handleMouseMove(); - else { - showInterface = false; - container?.focusChild(1); - } - }, 2000); + clearTimeout(showInterfaceTimeout); + showInterfaceTimeout = setTimeout(() => { + if (seeking) handleShowInterface(); + else handleHideInterface(); + }, 5000); } - onDestroy(() => clearTimeout(hideInterfaceTimeout)); + function handleHideInterface() { + showInterface = false; + clearTimeout(hideInterfaceTimeout); + hideInterfaceTimeout = setTimeout(() => { + container?.focusChild(1); + }, 200); + } + + onDestroy(() => { + clearTimeout(showInterfaceTimeout); + clearTimeout(hideInterfaceTimeout); + }); - + { + if (!showInterface) { + detail.stopPropagation(); + detail.preventNavigation(); + } + handleShowInterface(); + }} +> - Buttons + { + if (detail.direction === 'up') { + detail.stopPropagation(); + detail.preventNavigation(); + handleHideInterface(); + } + }} + > + Buttons + { - console.log(e.detail); video.currentTime = e.detail; }} bind:totalTime diff --git a/src/lib/selectable.ts b/src/lib/selectable.ts index 22ee8b5..fd4ac04 100644 --- a/src/lib/selectable.ts +++ b/src/lib/selectable.ts @@ -33,26 +33,37 @@ const createFocusHandlerOptions = (): FocusEventOptions => ({ }); type NavigateEventOptions = { - willLeaveContainer: boolean; + target?: Selectable; preventNavigation: boolean; + propagate: boolean; direction: Direction; }; export type NavigateEvent = { selectable: Selectable; direction: Direction; + willLeaveContainer: boolean; options: NavigateEventOptions; preventNavigation: () => void; + stopPropagation: () => void; }; -const createNavigateHandlerOptions = (direction: Direction): NavigateEventOptions => ({ - willLeaveContainer: false, +const createNavigateHandlerOptions = ( + target: Selectable | undefined, + direction: Direction +): NavigateEventOptions => ({ + target, preventNavigation: false, + propagate: true, direction }); export type FocusHandler = (selectable: Selectable, options: FocusEventOptions) => void; -export type NavigationHandler = (selectable: Selectable, options: NavigateEventOptions) => void; +export type NavigationHandler = ( + selectable: Selectable, + options: NavigateEventOptions, + willLeaveContainer: boolean +) => void; export class Selectable { id: symbol; @@ -207,61 +218,135 @@ export class Selectable { return false; } - private giveFocus(direction: Direction, bypassActions: boolean = false): boolean { - const focusIndex = get(this.focusIndex); - const navigationEventOptions = createNavigateHandlerOptions(direction); + private giveFocus(direction: Direction, fireActions: boolean = true): boolean { + function getSelectable(selectable: Selectable): { + target?: Selectable; + cycledParent?: Selectable; + } { + const focusIndex = get(selectable.focusIndex); - const indexAddition = { - up: this.direction === 'vertical' ? -1 : -this.gridColumns, - down: this.direction === 'vertical' ? 1 : this.gridColumns, - left: - this.direction === 'horizontal' - ? (focusIndex % this.gridColumns) - 1 < 0 - ? 0 - : -1 - : -this.gridColumns, - right: - this.direction === 'horizontal' - ? (focusIndex % this.gridColumns) + 1 >= this.gridColumns - ? 0 - : 1 - : this.gridColumns - }[direction]; + const indexAddition = { + up: selectable.direction === 'vertical' ? -1 : -selectable.gridColumns, + down: selectable.direction === 'vertical' ? 1 : selectable.gridColumns, + left: + selectable.direction === 'horizontal' + ? (focusIndex % selectable.gridColumns) - 1 < 0 + ? 0 + : -1 + : -selectable.gridColumns, + right: + selectable.direction === 'horizontal' + ? (focusIndex % selectable.gridColumns) + 1 >= selectable.gridColumns + ? 0 + : 1 + : selectable.gridColumns + }[direction]; - // Cycle siblings - if (indexAddition !== 0) { - let index = focusIndex + indexAddition; - while (index >= 0 && index < this.children.length) { - const children = this.children[index]; - if (children && children.isFocusable()) { - this.onNavigate(this, navigationEventOptions); - if (navigationEventOptions.preventNavigation) return true; - children.focus(); - return true; + // Cycle siblings + if (indexAddition !== 0) { + let index = focusIndex + indexAddition; + while (index >= 0 && index < selectable.children.length) { + const child = selectable.children[index]; + if (child && child.isFocusable()) { + return { target: child, cycledParent: selectable }; + } + index += indexAddition; } - index += indexAddition; + } + + if (selectable.neighbors[direction]?.isFocusable()) { + return { target: selectable.neighbors[direction] }; + // return selectable.neighbors[direction]; + } else if (!selectable.trapFocus) { + const parent = selectable.parent; + if (parent) return getSelectable(parent); + } + + return {}; + } + + function propagateNavigationEvent( + selectable: Selectable, + options: NavigateEventOptions, + cycledParent?: Selectable + ) { + const willLeaveContainer = cycledParent ? cycledParent !== selectable.parent : false; + + selectable.onNavigate(selectable, options, willLeaveContainer); + + if (options.propagate && selectable.parent) { + propagateNavigationEvent( + selectable.parent, + options, + willLeaveContainer ? cycledParent : undefined + ); } } - // About to leave this container (=coulnd't cycle siblings) - navigationEventOptions.willLeaveContainer = true; - if (!bypassActions) { - this.onNavigate(this, navigationEventOptions); - if (navigationEventOptions.preventNavigation) return true; - } - if (this.neighbors[direction]?.isFocusable()) { - this.neighbors[direction]?.focus(); + const { target, cycledParent } = getSelectable(this); + const navigationEventOptions = createNavigateHandlerOptions(target, direction); + if (fireActions) propagateNavigationEvent(this, navigationEventOptions, cycledParent); + + if (target && !navigationEventOptions.preventNavigation) { + target.focus(); return true; - } else if (!this.trapFocus) { - return this.parent?.giveFocus(direction, bypassActions) || false; } return false; + + // const focusIndex = get(this.focusIndex); + // + // const indexAddition = { + // up: this.direction === 'vertical' ? -1 : -this.gridColumns, + // down: this.direction === 'vertical' ? 1 : this.gridColumns, + // left: + // this.direction === 'horizontal' + // ? (focusIndex % this.gridColumns) - 1 < 0 + // ? 0 + // : -1 + // : -this.gridColumns, + // right: + // this.direction === 'horizontal' + // ? (focusIndex % this.gridColumns) + 1 >= this.gridColumns + // ? 0 + // : 1 + // : this.gridColumns + // }[direction]; + // + // // Cycle siblings + // if (indexAddition !== 0) { + // let index = focusIndex + indexAddition; + // while (index >= 0 && index < this.children.length) { + // const children = this.children[index]; + // if (children && children.isFocusable()) { + // propagateNavigationEvent(this, navigationEventOptions); + // if (navigationEventOptions.preventNavigation) return true; + // children.focus(); + // return true; + // } + // index += indexAddition; + // } + // } + // + // // About to leave this container (=coulnd't cycle siblings) + // navigationEventOptions.willLeaveContainer = true; + // if (!bypassActions) { + // propagateNavigationEvent(this, navigationEventOptions); + // if (navigationEventOptions.preventNavigation) return true; + // } + // if (this.neighbors[direction]?.isFocusable()) { + // this.neighbors[direction]?.focus(); + // return true; + // } else if (!this.trapFocus) { + // return this.parent?.giveFocus(direction, bypassActions) || false; + // } + // + // return false; } - static giveFocus(direction: Direction, bypassActions: boolean = false) { + static giveFocus(direction: Direction, fireActions?: boolean) { const currentlyFocusedObject = get(Selectable.focusedObject); - return currentlyFocusedObject?.giveFocus(direction, bypassActions); + return currentlyFocusedObject?.giveFocus(direction, fireActions); } private static initializeTreeStructure() {