Implemented Melt's spatial menu
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '$lib/i18n';
|
||||
import { removePlaylistVideo } from '$lib/api';
|
||||
import { invidiousAuthStore, feedLastItemId, isAndroidTvStore } from '$lib/store';
|
||||
import { invidiousAuthStore, feedLastItemId } from '$lib/store';
|
||||
import ContentColumn from '$lib/components/layout/ContentColumn.svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { onMount } from 'svelte';
|
||||
import Thumbnail from '$lib/components/thumbnail/VideoThumbnail.svelte';
|
||||
import { extractUniqueId, timeout, type feedItems } from '$lib/misc';
|
||||
import ChannelThumbnail from '$lib/components/thumbnail/ChannelThumbnail.svelte';
|
||||
import PlaylistThumbnail from '$lib/components/thumbnail/PlaylistThumbnail.svelte';
|
||||
import HashtagThumbnail from '$lib/components/thumbnail/HashtagThumbnail.svelte';
|
||||
import NoResults from '$lib/components/NoResults.svelte';
|
||||
import { SpatialMenu } from 'melt/builders';
|
||||
import { mergeAttrs } from 'melt';
|
||||
|
||||
interface Props {
|
||||
items?: feedItems;
|
||||
@@ -26,228 +27,66 @@
|
||||
classes = 'page right active'
|
||||
}: Props = $props();
|
||||
|
||||
let gridElement: HTMLElement;
|
||||
let focusableItems: HTMLElement[] = [];
|
||||
let currentFocusIndex = $state(0);
|
||||
let lastFocusIndex = $state(0); // Remember position when leaving grid
|
||||
let columns = $state(4); // Default columns for Android TV
|
||||
let isComponentActive = $state(false); // Track if focus is within this component
|
||||
let gridElement: HTMLElement | undefined = $state();
|
||||
|
||||
async function removePlaylistItem(indexId: string) {
|
||||
if (!playlistId) return;
|
||||
await removePlaylistVideo(playlistId, indexId);
|
||||
}
|
||||
|
||||
function calculateColumns() {
|
||||
if (!gridElement || !$isAndroidTvStore) return;
|
||||
|
||||
const gridItems = gridElement.querySelectorAll('article[role="presentation"]');
|
||||
if (gridItems.length === 0) return;
|
||||
|
||||
// Count items in first row by checking top position
|
||||
const firstItemTop = gridItems[0].getBoundingClientRect().top;
|
||||
let cols = 1;
|
||||
|
||||
for (let i = 1; i < gridItems.length; i++) {
|
||||
const itemTop = gridItems[i].getBoundingClientRect().top;
|
||||
if (Math.abs(itemTop - firstItemTop) < 10) {
|
||||
cols++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
columns = Math.max(1, cols);
|
||||
}
|
||||
|
||||
function setupAndroidTVNavigation() {
|
||||
if (!$isAndroidTvStore || !gridElement) return;
|
||||
|
||||
focusableItems = Array.from(
|
||||
gridElement.querySelectorAll('article[role="presentation"]')
|
||||
) as HTMLElement[];
|
||||
|
||||
calculateColumns();
|
||||
|
||||
// Make items focusable and set current focus to stored position
|
||||
const initialFocusIndex = Math.min(lastFocusIndex, focusableItems.length - 1);
|
||||
currentFocusIndex = initialFocusIndex;
|
||||
updateTabIndex(initialFocusIndex);
|
||||
}
|
||||
|
||||
// Update tabindex when focus changes to maintain proper focus order
|
||||
function updateTabIndex(focusIndex: number) {
|
||||
if (!$isAndroidTvStore || focusableItems.length === 0) return;
|
||||
|
||||
focusableItems.forEach((focusableItem, index) => {
|
||||
focusableItem.tabIndex = index === focusIndex ? 0 : -1;
|
||||
});
|
||||
}
|
||||
|
||||
// Check if focus is within the ItemsList component
|
||||
function checkComponentFocus() {
|
||||
if (!gridElement) return;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const isWithinComponent = gridElement.contains(activeElement);
|
||||
isComponentActive = isWithinComponent;
|
||||
|
||||
// If focus left the component, store the position
|
||||
if (!isWithinComponent && focusableItems.length > 0) {
|
||||
lastFocusIndex = currentFocusIndex;
|
||||
updateTabIndex(currentFocusIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNavigation(direction: 'up' | 'down' | 'left' | 'right', event: Event) {
|
||||
if (!$isAndroidTvStore || focusableItems.length === 0 || !isComponentActive) return true;
|
||||
|
||||
let newIndex = currentFocusIndex;
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
if (currentFocusIndex % columns === 0) {
|
||||
lastFocusIndex = currentFocusIndex;
|
||||
updateTabIndex(currentFocusIndex);
|
||||
return true;
|
||||
}
|
||||
newIndex = Math.max(0, currentFocusIndex - 1);
|
||||
break;
|
||||
case 'right':
|
||||
newIndex = Math.min(focusableItems.length - 1, currentFocusIndex + 1);
|
||||
break;
|
||||
case 'up':
|
||||
// Check if we're in the first row and trying to go up
|
||||
if (currentFocusIndex < columns) {
|
||||
// Store current position before leaving and ensure it stays focusable
|
||||
lastFocusIndex = currentFocusIndex;
|
||||
updateTabIndex(currentFocusIndex);
|
||||
return true;
|
||||
}
|
||||
newIndex = Math.max(0, currentFocusIndex - columns);
|
||||
break;
|
||||
case 'down':
|
||||
newIndex = Math.min(focusableItems.length - 1, currentFocusIndex + columns);
|
||||
break;
|
||||
}
|
||||
|
||||
if (newIndex !== currentFocusIndex) {
|
||||
event.preventDefault();
|
||||
// Update focus
|
||||
updateTabIndex(newIndex);
|
||||
focusableItems[newIndex].focus();
|
||||
|
||||
// Snap to view with instant behavior
|
||||
focusableItems[newIndex].scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
});
|
||||
|
||||
currentFocusIndex = newIndex;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if ($isAndroidTvStore) {
|
||||
await tick();
|
||||
// Setup Android TV navigation
|
||||
setupAndroidTVNavigation();
|
||||
const first = gridElement?.querySelector('[tabindex="0"]') as HTMLElement;
|
||||
first?.focus();
|
||||
|
||||
// Focus the correct item initially (first time or restored position)
|
||||
if (focusableItems.length > 0) {
|
||||
if ($feedLastItemId) {
|
||||
const focusedItemIndex = focusableItems.findIndex((item) => item.id === $feedLastItemId);
|
||||
feedLastItemId.set(undefined);
|
||||
if (!$feedLastItemId) return;
|
||||
|
||||
if (focusedItemIndex !== -1) {
|
||||
focusableItems[focusedItemIndex]?.focus();
|
||||
currentFocusIndex = focusedItemIndex;
|
||||
}
|
||||
}
|
||||
const element = document.getElementById($feedLastItemId);
|
||||
|
||||
if (element) {
|
||||
await timeout(100);
|
||||
element.focus();
|
||||
element.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
|
||||
feedLastItemId.set(undefined);
|
||||
});
|
||||
|
||||
function goToItem(uniqueItemId: string) {
|
||||
feedLastItemId.set(uniqueItemId);
|
||||
|
||||
const articleElement = document.getElementById(uniqueItemId);
|
||||
if (articleElement) {
|
||||
const clickable = articleElement.querySelector('a, button');
|
||||
if (clickable instanceof HTMLElement) {
|
||||
clickable.click();
|
||||
}
|
||||
|
||||
// Bind navigation keys
|
||||
Mousetrap.bind('up', (e) => handleNavigation('up', e), 'keydown');
|
||||
Mousetrap.bind('down', (e) => handleNavigation('down', e), 'keydown');
|
||||
Mousetrap.bind('left', (e) => handleNavigation('left', e), 'keydown');
|
||||
Mousetrap.bind('right', (e) => handleNavigation('right', e), 'keydown');
|
||||
|
||||
// Watch for window resize to recalculate columns
|
||||
window.addEventListener('resize', calculateColumns);
|
||||
|
||||
// Watch for focus changes to detect when we're active
|
||||
document.addEventListener('focusin', checkComponentFocus);
|
||||
document.addEventListener('focusout', checkComponentFocus);
|
||||
} else if ($feedLastItemId) {
|
||||
const element = document.getElementById($feedLastItemId);
|
||||
|
||||
if (element) {
|
||||
await timeout(100);
|
||||
element.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
|
||||
feedLastItemId.set(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($isAndroidTvStore) {
|
||||
Mousetrap.unbind(['up', 'down', 'left', 'right']);
|
||||
window.removeEventListener('resize', calculateColumns);
|
||||
document.removeEventListener('focusin', checkComponentFocus);
|
||||
document.removeEventListener('focusout', checkComponentFocus);
|
||||
}
|
||||
});
|
||||
|
||||
// Update navigation when items change
|
||||
$effect(() => {
|
||||
if ($isAndroidTvStore && items.length > 0 && gridElement) {
|
||||
tick().then(() => setupAndroidTVNavigation());
|
||||
}
|
||||
});
|
||||
const spatialMenu = new SpatialMenu({ wrap: false, crossAxis: true, scrollBehavior: 'smooth' });
|
||||
</script>
|
||||
|
||||
<div class={classes} class:android-container={$isAndroidTvStore}>
|
||||
<div class={classes + ' item-container'}>
|
||||
{#if items.length === 0}
|
||||
<NoResults />
|
||||
{/if}
|
||||
<div class="grid" bind:this={gridElement}>
|
||||
<div class="grid" bind:this={gridElement} {...spatialMenu.root}>
|
||||
{#each items as item, index (index)}
|
||||
{@const uniqueItemId = extractUniqueId(item)}
|
||||
{@const spatialItem = spatialMenu.getItem(item, { onSelect: () => goToItem(uniqueItemId) })}
|
||||
<ContentColumn>
|
||||
<article
|
||||
class="no-padding android-tv-item border"
|
||||
class:android-tv-focused={$isAndroidTvStore}
|
||||
{...mergeAttrs(spatialItem.attrs, {
|
||||
onclick: () => goToItem(uniqueItemId),
|
||||
id: `item-${index}`
|
||||
})}
|
||||
class="no-padding item-select border"
|
||||
class:item-select-focused={spatialItem.highlighted}
|
||||
style="height: 100%;"
|
||||
id={extractUniqueId(item)}
|
||||
role="presentation"
|
||||
onclick={() => {
|
||||
const uniqueItemId = extractUniqueId(item);
|
||||
feedLastItemId.set(uniqueItemId);
|
||||
|
||||
// Required to pass click through to thumbnail component on Android TV
|
||||
if ($isAndroidTvStore) {
|
||||
const articleElement = document.getElementById(uniqueItemId);
|
||||
if (articleElement) {
|
||||
const clickable = articleElement.querySelector('a, button');
|
||||
if (clickable instanceof HTMLElement) {
|
||||
clickable.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onfocus={() => {
|
||||
if ($isAndroidTvStore) {
|
||||
currentFocusIndex = index;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream' || item.type === 'historyVideo'}
|
||||
{#key item.videoId}
|
||||
@@ -278,7 +117,7 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.android-container {
|
||||
.item-container {
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
@@ -288,11 +127,11 @@
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.android-tv-item:focus {
|
||||
.item-select-focused {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
outline: 4px solid var(--primary);
|
||||
outline: 1px solid var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { afterNavigate, disableScrollHandling, goto } from '$app/navigation';
|
||||
|
||||
import { navigating, page } from '$app/stores';
|
||||
import { getFeed, notificationsMarkAsRead } from '$lib/api/index';
|
||||
@@ -171,8 +171,8 @@
|
||||
class:tv-nav={$isAndroidTvStore}
|
||||
class:hide={$playertheatreModeIsActive}
|
||||
>
|
||||
<header role="presentation" style="cursor: pointer;" tabindex="-1" class="small-padding">
|
||||
<a href={resolve($interfaceDefaultPage, {})} data-sveltekit-preload-data="off">
|
||||
<header class="small-padding">
|
||||
<a href={resolve($interfaceDefaultPage, {})} tabindex="-1" data-sveltekit-preload-data="off">
|
||||
<Logo />
|
||||
</a>
|
||||
</header>
|
||||
@@ -329,7 +329,7 @@
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
<main id="main-content" class="responsive max root" tabindex="0" role="region">
|
||||
<main id="main-content" class="responsive max root">
|
||||
{#if $playerState}
|
||||
<div class="grid">
|
||||
<div
|
||||
@@ -385,7 +385,9 @@
|
||||
{#if $navigating}
|
||||
<PageLoading />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
<div tabindex="0">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SyncParty />
|
||||
|
||||
Reference in New Issue
Block a user