Implemented Melt's spatial menu

This commit is contained in:
WardPearce
2026-03-02 20:05:03 +13:00
parent ad416541ce
commit 7b54ddf678
2 changed files with 53 additions and 212 deletions
@@ -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>
+7 -5
View File
@@ -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 />