Unify our item listing, fix bypassing click to child component

This commit is contained in:
WardPearce
2025-10-08 03:16:33 +13:00
parent b8ff983114
commit da6665fbb0
17 changed files with 120 additions and 109 deletions
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-11T07:20:32.362551415Z">
<DropdownSelection timestamp="2025-10-07T13:21:06.133130689Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/ward/.android/avd/Pixel_7_Pro.avd" />
<DeviceId pluginId="LocalEmulator" identifier="path=/home/ward/.android/avd/Television_4K.avd" />
</handle>
</Target>
</DropdownSelection>
+1 -1
View File
@@ -44,4 +44,4 @@
"capacitor",
"electron"
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.10.11",
"version": "1.10.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.10.11",
"version": "1.10.12",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
@@ -1,6 +1,8 @@
import { goto } from '$app/navigation';
import { isAndroidTvStore } from '$lib/store';
import { Capacitor } from '@capacitor/core';
import { NodeJS } from 'capacitor-nodejs';
import { get } from 'svelte/store';
const originalFetch = window.fetch;
const corsProxyUrl: string = 'http://localhost:3000/';
@@ -56,4 +58,13 @@ if (Capacitor.getPlatform() === 'android') {
NodeJS.whenReady().then(() => {
goto('/', { replaceState: true });
});
// Required for Android TV to load correctly.
let hasReloaded = false;
isAndroidTvStore.subscribe((isAndroidTv) => {
if (hasReloaded || !isAndroidTv) return;
hasReloaded = true;
setTimeout(() => goto('/', { replaceState: true }), 1000);
});
}
+5 -5
View File
@@ -14,6 +14,7 @@ export interface Thumbnail {
}
export interface VideoBase {
type: 'video' | 'shortVideo' | 'stream';
videoId: string;
title: string;
videoThumbnails: Thumbnail[];
@@ -30,7 +31,6 @@ export interface ResolvedUrl {
}
export interface Video extends VideoBase {
type: 'video';
title: string;
authorUrl: string;
authorVerified: boolean;
@@ -178,6 +178,7 @@ export interface Channel {
}
export interface PlaylistVideo {
type: 'playlist';
title: string;
videoId: string;
lengthSeconds: number;
@@ -196,7 +197,8 @@ export interface Playlist {
videos: PlaylistVideo[];
}
export interface PlaylistPageVideo extends PlaylistVideo {
export interface PlaylistPageVideo extends Omit<PlaylistVideo, 'type'> {
type: 'video';
author: string;
index: number;
indexId: string;
@@ -245,9 +247,7 @@ export interface HashTag {
videoCount: number;
}
export interface Notification extends VideoBase {
type: 'video' | 'shortVideo' | 'stream';
}
export interface Notification extends VideoBase {}
export interface Feed {
notifications: Notification[];
@@ -2,21 +2,32 @@
import Thumbnail from '$lib/components/Thumbnail.svelte';
import { _ } from '$lib/i18n';
import { removePlaylistVideo } from '../api';
import type { PlaylistPageVideo, Video, VideoBase } from '../api/model';
import type {
Channel,
HashTag,
Playlist,
PlaylistPage,
PlaylistPageVideo,
Video,
VideoBase
} from '../api/model';
import { authStore, feedLastItemId, isAndroidTvStore } from '../store';
import ContentColumn from './ContentColumn.svelte';
import { onMount, onDestroy } from 'svelte';
import Mousetrap from 'mousetrap';
import { goto } from '$app/navigation';
import { createVideoUrl } from '$lib/misc';
import { extractUniqueId } from '$lib/misc';
import ChannelThumbnail from './ChannelThumbnail.svelte';
import PlaylistThumbnail from './PlaylistThumbnail.svelte';
import HashtagThumbnail from './HashtagThumbnail.svelte';
interface Props {
videos?: (VideoBase | Video | PlaylistPageVideo)[];
items?:
| (VideoBase | Video | PlaylistPageVideo | Channel | Playlist | HashTag)[]
| PlaylistPage[];
playlistId?: string;
playlistAuthor?: string;
}
let { videos = [], playlistId = '', playlistAuthor = '' }: Props = $props();
let { items = [], playlistId = '', playlistAuthor = '' }: Props = $props();
let gridElement = $state<HTMLElement>();
let focusableItems = $state<HTMLElement[]>([]);
@@ -33,15 +44,15 @@
function calculateColumns() {
if (!gridElement || !$isAndroidTvStore) return;
const items = gridElement.querySelectorAll('article[role="presentation"]');
if (items.length === 0) return;
const gridItems = gridElement.querySelectorAll('article[role="presentation"]');
if (gridItems.length === 0) return;
// Count items in first row by checking top position
const firstItemTop = items[0].getBoundingClientRect().top;
const firstItemTop = gridItems[0].getBoundingClientRect().top;
let cols = 1;
for (let i = 1; i < items.length; i++) {
const itemTop = items[i].getBoundingClientRect().top;
for (let i = 1; i < gridItems.length; i++) {
const itemTop = gridItems[i].getBoundingClientRect().top;
if (Math.abs(itemTop - firstItemTop) < 10) {
cols++;
} else {
@@ -71,12 +82,12 @@
function updateTabIndex(focusIndex: number) {
if (!$isAndroidTvStore || focusableItems.length === 0) return;
focusableItems.forEach((item, index) => {
item.tabIndex = index === focusIndex ? 0 : -1;
focusableItems.forEach((focusableItem, index) => {
focusableItem.tabIndex = index === focusIndex ? 0 : -1;
});
}
// Check if focus is within the VideoList component
// Check if focus is within the ItemsList component
function checkComponentFocus() {
if (!gridElement) return;
@@ -157,6 +168,7 @@
let focusedItemIndex = -1;
if ($feedLastItemId) {
focusedItemIndex = focusableItems.findIndex((item) => item.id === $feedLastItemId);
feedLastItemId.set(undefined);
}
const focusIndex =
focusedItemIndex === -1
@@ -191,9 +203,9 @@
}
});
// Update navigation when videos change
// Update navigation when items change
$effect(() => {
if ($isAndroidTvStore && videos.length > 0 && gridElement) {
if ($isAndroidTvStore && items.length > 0 && gridElement) {
setTimeout(() => {
setupAndroidTVNavigation();
}, 100);
@@ -204,18 +216,26 @@
<div class="page right active" class:android-container={$isAndroidTvStore}>
<div class="space"></div>
<div class="grid" bind:this={gridElement}>
{#each videos as video, index}
{#each items as item, index}
<ContentColumn>
<article
class="no-padding android-tv-item"
class:android-tv-focused={$isAndroidTvStore}
style="height: 100%;"
id={video.videoId}
id={extractUniqueId(item)}
role="presentation"
onclick={() => {
feedLastItemId.set(video.videoId);
feedLastItemId.set(extractUniqueId(item));
// Required to pass click through to thumbnail component on Android TV
if ($isAndroidTvStore) {
goto(createVideoUrl(video.videoId, playlistId));
const articleElement = document.getElementById(extractUniqueId(item));
if (articleElement) {
const clickable = articleElement.querySelector('a, button');
if (clickable instanceof HTMLElement) {
clickable.click();
}
}
}
}}
onfocus={() => {
@@ -224,17 +244,25 @@
}
}}
>
<Thumbnail {video} {playlistId} />
{#if $authStore && decodeURIComponent($authStore.username) === playlistAuthor && 'indexId' in video}
<div class="right-align" style="margin: 1em .5em;">
<button
onclick={async () => removePlaylistItem(video.indexId)}
class="tertiary circle small"
>
<i>delete</i>
<div class="tooltip">{$_('delete')}</div>
</button>
</div>
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream'}
<Thumbnail video={item} {playlistId} />
{#if $authStore && decodeURIComponent($authStore.username) === playlistAuthor && 'indexId' in item}
<div class="right-align" style="margin: 1em .5em;">
<button
onclick={async () => removePlaylistItem(item.indexId)}
class="tertiary circle small"
>
<i>delete</i>
<div class="tooltip">{$_('delete')}</div>
</button>
</div>
{/if}
{:else if item.type === 'channel'}
<ChannelThumbnail channel={item} />
{:else if item.type === 'playlist'}
<PlaylistThumbnail playlist={item} />
{:else if item.type === 'hashtag'}
<HashtagThumbnail hashtag={item} />
{/if}
</article>
</ContentColumn>
@@ -4,12 +4,12 @@
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import type { Playlist } from '../api/model';
import type { Playlist, PlaylistPage } from '../api/model';
import { insecureRequestImageHandler, truncate } from '../misc';
import { interfaceLowBandwidthMode } from '../store';
interface Props {
playlist: Playlist;
playlist: Playlist | PlaylistPage;
disabled?: boolean;
}
@@ -155,14 +155,7 @@
}
</script>
<div
class:sideways-root={sideways}
tabindex="0"
role="button"
onclick={async () => {
goto(watchUrl, { replaceState: $isAndroidTvStore && page.url.pathname.startsWith('/tv') });
}}
>
<div class:sideways-root={sideways} tabindex="0" role="button">
<div id="thumbnail-container">
<a
tabindex="-1"
+18 -2
View File
@@ -4,7 +4,15 @@ import he from 'he';
import type Peer from 'peerjs';
import { get } from 'svelte/store';
import { instanceStore, interfaceAllowInsecureRequests, isAndroidTvStore } from './store';
import type { Channel, HashTag, Playlist, PlaylistPageVideo, Video, VideoBase } from './api/model';
import type {
Channel,
HashTag,
Playlist,
PlaylistPage,
PlaylistPageVideo,
Video,
VideoBase
} from './api/model';
export function truncate(value: string, maxLength: number = 50): string {
return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value;
@@ -74,7 +82,15 @@ export async function insecureRequestImageHandler(source: string): Promise<HTMLI
return img;
}
export type feedItem = VideoBase | Video | PlaylistPageVideo | Channel | Video | Playlist | HashTag;
export type feedItem =
| VideoBase
| Video
| PlaylistPageVideo
| Channel
| Video
| Playlist
| HashTag
| PlaylistPage;
export type feedItems = feedItem[];
export function extractUniqueId(item: feedItem): string {
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import VideoList from '$lib/components/VideoList.svelte';
import { feedCacheStore } from '$lib/store.js';
import ItemsList from '$lib/components/ItemsList.svelte';
import { feedCacheStore } from '$lib/store';
let { data } = $props();
</script>
@@ -12,5 +12,5 @@
<p>Popular page has been disabled</p>
</nav>
{:else}
<VideoList videos={$feedCacheStore.popular ?? []} />
<ItemsList items={$feedCacheStore.popular ?? []} />
{/if}
@@ -4,7 +4,6 @@
import ContentColumn from '$lib/components/ContentColumn.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
import VideoList from '$lib/components/VideoList.svelte';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { cleanNumber } from '$lib/numbers';
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
@@ -14,6 +13,7 @@
import { _ } from '$lib/i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import { get } from 'svelte/store';
import ItemsList from '$lib/components/ItemsList.svelte';
let { data } = $props();
@@ -203,20 +203,9 @@
{#if displayContent}
{#if 'videos' in displayContent}
<VideoList videos={displayContent.videos} />
<ItemsList items={displayContent.videos} />
{:else}
<div class="page right active">
<div class="space"></div>
<div class="grid">
{#each displayContent.playlists as playlist}
<ContentColumn>
<article class="no-padding" style="height: 100%;">
<PlaylistThumbnail {playlist} />
</article>
</ContentColumn>
{/each}
</div>
</div>
<ItemsList items={displayContent.playlists} />
{/if}
<InfiniteLoading on:infinite={loadMore} />
@@ -1,7 +1,7 @@
<script lang="ts">
import { getHashtag } from '$lib/api';
import type { Video } from '$lib/api/model.js';
import VideoList from '$lib/components/VideoList.svelte';
import ItemsList from '$lib/components/ItemsList.svelte';
import { onMount } from 'svelte';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
@@ -26,6 +26,6 @@
}
</script>
<VideoList {videos} />
<ItemsList items={videos} />
<InfiniteLoading on:infinite={loadMore} />
@@ -2,13 +2,13 @@
import { deleteAllVideoProgress, deleteHistory, getHistory, getVideo } from '$lib/api';
import type { VideoPlay } from '$lib/api/model';
import PageLoading from '$lib/components/PageLoading.svelte';
import VideoList from '$lib/components/VideoList.svelte';
import { synciousStore } from '$lib/store';
import { error } from '@sveltejs/kit';
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import { get } from 'svelte/store';
import ItemsList from '$lib/components/ItemsList.svelte';
let history: VideoPlay[] = $state([]);
let loaded = $state(false);
@@ -82,7 +82,7 @@
</nav>
{#if loaded}
<VideoList videos={history} />
<ItemsList items={history} />
<InfiniteLoading on:infinite={loadMore} />
{:else}
@@ -1,11 +1,11 @@
<script lang="ts">
import VideoList from '$lib/components/VideoList.svelte';
import { unsafeRandomItem } from '$lib/misc';
import { cleanNumber } from '$lib/numbers';
import { isAndroidTvStore, playlistSettingsStore } from '$lib/store';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import { _ } from '$lib/i18n';
import ItemsList from '$lib/components/ItemsList.svelte';
let { data } = $props();
</script>
@@ -93,8 +93,8 @@
</article>
{#if data.playlist.videos}
<VideoList
videos={data.playlist.videos}
<ItemsList
items={data.playlist.videos}
playlistAuthor={data.playlist.info.author}
playlistId={data.playlist.info.playlistId}
/>
@@ -11,6 +11,7 @@
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import ItemsList from '$lib/components/ItemsList.svelte';
let { data } = $props();
@@ -86,34 +87,7 @@
</div>
{#if $searchCacheStore[data.searchStoreId]}
<div class="page right active">
<div class="space"></div>
<div class="grid">
{#each $searchCacheStore[data.searchStoreId] as item}
<ContentColumn>
{#key item}
<article
class="no-padding"
style="height: 100%;"
role="presentation"
onclick={async () => feedLastItemId.set(await extractUniqueId(item))}
id={extractUniqueId(item)}
>
{#if item.type === 'video'}
<Thumbnail video={item} />
{:else if item.type === 'channel'}
<ChannelThumbnail channel={item} />
{:else if item.type === 'playlist'}
<PlaylistThumbnail playlist={item} />
{:else if item.type === 'hashtag'}
<HashtagThumbnail hashtag={item} />
{/if}
</article>
{/key}
</ContentColumn>
{/each}
</div>
</div>
<ItemsList items={$searchCacheStore[data.searchStoreId]} />
{:else}
<PageLoading />
{/if}
@@ -1,10 +1,10 @@
<script lang="ts">
import { getFeed } from '$lib/api/index';
import type { PlaylistPageVideo, Video, VideoBase } from '$lib/api/model.js';
import VideoList from '$lib/components/VideoList.svelte';
import { feedCacheStore } from '$lib/store';
import { _ } from '$lib/i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import ItemsList from '$lib/components/ItemsList.svelte';
let currentPage = 1;
let videos: (VideoBase | Video | PlaylistPageVideo)[] = $state($feedCacheStore.subscription);
@@ -30,6 +30,6 @@
</a>
</nav>
<VideoList videos={videos ?? []} />
<ItemsList items={videos ?? []} />
<InfiniteLoading on:infinite={loadMore} />
@@ -1,6 +1,6 @@
<script lang="ts">
import VideoList from '$lib/components/VideoList.svelte';
import ItemsList from '$lib/components/ItemsList.svelte';
import { feedCacheStore } from '$lib/store';
</script>
<VideoList videos={$feedCacheStore.trending ?? []} />
<ItemsList items={$feedCacheStore.trending ?? []} />