diff --git a/materialious/src/lib/components/Thumbnail.svelte b/materialious/src/lib/components/Thumbnail.svelte index 7d4394c8..d35f3040 100644 --- a/materialious/src/lib/components/Thumbnail.svelte +++ b/materialious/src/lib/components/Thumbnail.svelte @@ -7,7 +7,7 @@ import { get } from 'svelte/store'; import { getDeArrow, getThumbnail } from '../api'; import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model'; - import { insecureRequestImageHandler } from '../misc'; + import { createVideoUrl, insecureRequestImageHandler } from '../misc'; import type { PlayerEvents } from '../player'; import { authStore, @@ -34,13 +34,7 @@ let placeholderHeight: number = $state(0); - let watchUrl = new URL( - `${location.origin}/${$isAndroidTvStore ? 'tv' : 'watch'}/${video.videoId}` - ); - - if (playlistId !== '') { - watchUrl.searchParams.set('playlist', playlistId); - } + let watchUrl = createVideoUrl(video.videoId, playlistId); syncPartyPeerStore.subscribe((peer) => { if (peer) { diff --git a/materialious/src/lib/components/VideoList.svelte b/materialious/src/lib/components/VideoList.svelte index 656847eb..b8477bf6 100644 --- a/materialious/src/lib/components/VideoList.svelte +++ b/materialious/src/lib/components/VideoList.svelte @@ -3,9 +3,12 @@ import { _ } from '$lib/i18n'; import { removePlaylistVideo } from '../api'; import type { PlaylistPageVideo, Video, VideoBase } from '../api/model'; - import { authStore, feedLastItemId } from '../store'; + import { authStore, feedLastItemId, isAndroidTvStore } from '../store'; import ContentColumn from './ContentColumn.svelte'; - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; + import Mousetrap from 'mousetrap'; + import { goto } from '$app/navigation'; + import { createVideoUrl } from '$lib/misc'; interface Props { videos?: (VideoBase | Video | PlaylistPageVideo)[]; @@ -15,33 +18,211 @@ let { videos = [], playlistId = '', playlistAuthor = '' }: Props = $props(); + let gridElement = $state(); + let focusableItems = $state([]); + 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 + async function removePlaylistItem(indexId: string) { if (!playlistId) return; await removePlaylistVideo(playlistId, indexId); } + function calculateColumns() { + if (!gridElement || !$isAndroidTvStore) return; + + const items = gridElement.querySelectorAll('article[role="presentation"]'); + if (items.length === 0) return; + + // Count items in first row by checking top position + const firstItemTop = items[0].getBoundingClientRect().top; + let cols = 1; + + for (let i = 1; i < items.length; i++) { + const itemTop = items[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((item, index) => { + item.tabIndex = index === focusIndex ? 0 : -1; + }); + } + + // Check if focus is within the VideoList 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': + 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); + // Allow the event to bubble up to parent navigation + 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(() => { - if ($feedLastItemId) { + if ($feedLastItemId && !$isAndroidTvStore) { document .getElementById($feedLastItemId) ?.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' }); feedLastItemId.set(undefined); } + + if ($isAndroidTvStore) { + // Setup Android TV navigation + setTimeout(() => { + setupAndroidTVNavigation(); + // Focus the correct item initially (first time or restored position) + if (focusableItems.length > 0) { + let focusedItemIndex = -1; + if ($feedLastItemId) { + focusedItemIndex = focusableItems.findIndex((item) => item.id === $feedLastItemId); + } + const focusIndex = + focusedItemIndex === -1 + ? Math.min(lastFocusIndex, focusableItems.length - 1) + : focusedItemIndex; + focusableItems[focusIndex]?.focus(); + currentFocusIndex = focusIndex; + } + }, 200); + + // Bind navigation keys + Mousetrap.bind('up', (e) => handleNavigation('up', e)); + Mousetrap.bind('down', (e) => handleNavigation('down', e), 'keydown'); + Mousetrap.bind('left', (e) => handleNavigation('left', e)); + Mousetrap.bind('right', (e) => handleNavigation('right', e)); + + // 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); + } + }); + + onDestroy(() => { + if ($isAndroidTvStore) { + Mousetrap.unbind(['up', 'down', 'left', 'right']); + window.removeEventListener('resize', calculateColumns); + document.removeEventListener('focusin', checkComponentFocus); + document.removeEventListener('focusout', checkComponentFocus); + } + }); + + // Update navigation when videos change + $effect(() => { + if ($isAndroidTvStore && videos.length > 0 && gridElement) { + setTimeout(() => { + setupAndroidTVNavigation(); + }, 100); + } }); -
+
-
- {#each videos as video} +
+ {#each videos as video, index}
+ + diff --git a/materialious/src/lib/misc.ts b/materialious/src/lib/misc.ts index 586cee97..40e56358 100644 --- a/materialious/src/lib/misc.ts +++ b/materialious/src/lib/misc.ts @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import he from 'he'; import type Peer from 'peerjs'; import { get } from 'svelte/store'; -import { instanceStore, interfaceAllowInsecureRequests } from './store'; +import { instanceStore, interfaceAllowInsecureRequests, isAndroidTvStore } from './store'; import type { Channel, HashTag, Playlist, PlaylistPageVideo, Video, VideoBase } from './api/model'; export function truncate(value: string, maxLength: number = 50): string { @@ -112,3 +112,15 @@ export function expandSummery(id: string) { element.click(); } } + +export function createVideoUrl(videoId: string, playlistId: string): URL { + const watchUrl = new URL( + `${location.origin}/${get(isAndroidTvStore) ? 'tv' : 'watch'}/${videoId}` + ); + + if (playlistId !== '') { + watchUrl.searchParams.set('playlist', playlistId); + } + + return watchUrl; +} diff --git a/materialious/src/routes/(app)/+layout.svelte b/materialious/src/routes/(app)/+layout.svelte index eeed58a0..28bacdeb 100644 --- a/materialious/src/routes/(app)/+layout.svelte +++ b/materialious/src/routes/(app)/+layout.svelte @@ -202,22 +202,26 @@ if ($isAndroidTvStore) { const topContent = document.getElementById('top-content') as HTMLElement; - Mousetrap.bind('down', () => { - if (topContent.contains(document.activeElement)) { - document.getElementById('main-content')?.focus(); - return false; - } - }); + Mousetrap.bind( + 'down', + () => { + if (topContent.contains(document.activeElement)) { + document.getElementById('main-content')?.focus(); + return true; + } + }, + 'keyup' + ); } if (Capacitor.getPlatform() === 'android') { - document.addEventListener('click', (event: MouseEvent) => { + document.addEventListener('click', async (event: MouseEvent) => { // Handles opening links in browser for android. const link = (event.target as HTMLElement).closest('a'); if (link && link.href && link.href.startsWith('http') && link.target === '_blank') { event.preventDefault(); - Browser.open({ url: link.href }); + await Browser.open({ url: link.href }); } }); }