Unify our item listing, fix bypassing click to child component
This commit is contained in:
+2
-2
@@ -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>
|
||||
|
||||
@@ -44,4 +44,4 @@
|
||||
"capacitor",
|
||||
"electron"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
+59
-31
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? []} />
|
||||
|
||||
Reference in New Issue
Block a user