Started thumbnail actions

This commit is contained in:
WardPearce
2026-03-13 12:50:35 +13:00
parent 8140970edb
commit f9730dc95c
8 changed files with 182 additions and 63 deletions
+6 -3
View File
@@ -1,7 +1,8 @@
import sodium from 'libsodium-wrappers-sumo';
import { decryptWithMasterKey, encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
import type { VideoPlay, VideoWatchHistory } from '../model';
import type { VideoWatchHistory } from '../model';
import { getBestThumbnail } from '$lib/images';
import type { ThumbnailVideo } from '$lib/thumbnail';
export async function updateWatchHistoryBackend(videoId: string, progress: number) {
await sodium.ready;
@@ -128,7 +129,7 @@ export async function deleteWatchHistoryItemBackend(videoId: string) {
await fetch(`/api/user/history/${videoHash}`, { method: 'DELETE' });
}
export async function saveWatchHistoryBackend(video: VideoPlay, progress: number = 0) {
export async function saveWatchHistoryBackend(video: ThumbnailVideo, progress: number = 0) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
@@ -137,7 +138,9 @@ export async function saveWatchHistoryBackend(video: VideoPlay, progress: number
const title = await encryptWithMasterKey(video.title);
const author = await encryptWithMasterKey(video.author);
const thumbnail = await encryptWithMasterKey(getBestThumbnail(video.videoThumbnails));
const thumbnail = await encryptWithMasterKey(
'videoThumbnails' in video ? getBestThumbnail(video.videoThumbnails) : video.thumbnail
);
const videoId = await encryptWithMasterKey(video.videoId);
await fetch('/api/user/history', {
+1 -1
View File
@@ -5,7 +5,7 @@ const videoIds: string[] = [];
const pendingResolves = new Map<string, (result: VideoWatchHistory | undefined) => void>();
let timeout: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 2000;
const DEBOUNCE_MS = 1000;
const BATCH_SIZE = 100;
async function processBatches(): Promise<void> {
+6 -3
View File
@@ -21,7 +21,8 @@ import type {
CommentsOptions,
ChannelOptions,
ChannelContent,
VideoWatchHistory
VideoWatchHistory,
VideoBase
} from './model';
import { commentsSetDefaults, searchSetDefaults, useEngineFallback } from './misc';
import { getSearchYTjs } from './youtubejs/search';
@@ -82,6 +83,7 @@ import {
} from './backend/history';
import { localDb } from '$lib/dexie';
import { getBestThumbnail } from '$lib/images';
import type { ThumbnailVideo } from '$lib/thumbnail';
export async function getPopular(fetchOptions?: RequestInit): Promise<Video[]> {
// Doesn't exist in YTjs.
@@ -368,7 +370,7 @@ export async function updateWatchHistory(
await localDb.watchHistory.update({ videoId }, { progress, watched: new Date() });
}
export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
export async function saveWatchHistory(video: ThumbnailVideo, progress: number = 0) {
if (!get(watchHistoryEnabledStore)) return;
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
@@ -384,7 +386,8 @@ export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
progress,
id: video.videoId,
title: video.title,
thumbnail: getBestThumbnail(video.videoThumbnails),
thumbnail:
'videoThumbnails' in video ? getBestThumbnail(video.videoThumbnails) : video.thumbnail,
videoId: video.videoId,
type: 'historyVideo'
});
+13 -5
View File
@@ -17,11 +17,17 @@
let {
shares,
includePromptText = undefined,
iconOnly = true
iconOnly = true,
classes = 'surface-container-highest',
menuClasses = 'mobile',
onShare = undefined
}: {
shares: ShareLink[];
includePromptText?: string;
iconOnly: boolean;
classes?: string;
menuClasses?: string;
onShare?: (value: string) => void;
} = $props();
const shareBase = {
@@ -34,19 +40,21 @@
let shareButtonElement: HTMLElement | undefined = $state();
let includePrompt = $state(false);
async function onShare(share: ShareLink) {
async function onShareValue(share: ShareLink) {
const url = new URL(`${shareBase[share.type]}${share.path}`);
if (share.param && includePrompt)
url.searchParams.append(share.param.key, share.param.value().toString());
onShare?.(url.toString());
await shareURL(url.toString());
shareButtonElement?.click();
}
</script>
<button bind:this={shareButtonElement} class="surface-container-highest">
<button bind:this={shareButtonElement} class={classes}>
<i>share</i>
{#if !iconOnly}
{$_('player.share.title')}
@@ -54,7 +62,7 @@
<div class="tooltip">
{$_('player.share.title')}
</div>
<menu class="no-wrap mobile" data-ui="#share-menu" id="share-menu">
<menu class={menuClasses + ' no-wrap'} data-ui="#share-menu" id="share-menu">
{#if includePromptText}
<li class="row">
<label class="switch">
@@ -73,7 +81,7 @@
class="row"
role="presentation"
onclick={() => {
onShare(share);
onShareValue(share);
}}
>
<div class="min">
@@ -71,11 +71,17 @@
{#each items as item, index (index)}
{#if !isItemFiltered(item)}
{@const uniqueItemId = extractUniqueId(item)}
{@const spatialItem = spatialMenu.getItem(item, { onSelect: () => goToItem(uniqueItemId) })}
{@const spatialItem = spatialMenu.getItem(item, {
onSelect: () => {
if ($isAndroidTvStore) goToItem(uniqueItemId);
}
})}
<ContentColumn>
<article
{...mergeAttrs(spatialItem.attrs, {
onclick: () => goToItem(uniqueItemId),
onclick: () => {
if ($isAndroidTvStore) goToItem(uniqueItemId);
},
id: uniqueItemId
})}
class="no-padding item-select border"
@@ -7,22 +7,19 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { Avatar } from 'melt/builders';
import type {
Notification,
PlaylistPageVideo,
Video,
VideoBase,
VideoWatchHistory
} from '$lib/api/model';
import { deArrowEnabledStore, isAndroidTvStore, playerState } from '$lib/store';
import { relativeTimestamp } from '$lib/time';
import { queueGetWatchHistory } from '$lib/api/historyPool';
import { page } from '$app/state';
import { getDeArrow, getThumbnailDeArrow } from '$lib/api/dearrow';
import AuthorAvatar from '../AuthorAvatar.svelte';
import Share from '../Share.svelte';
import { fade } from 'svelte/transition';
import { deleteWatchHistoryItem, saveWatchHistory } from '$lib/api';
import type { ThumbnailVideo } from '$lib/thumbnail';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo | VideoWatchHistory;
video: ThumbnailVideo;
playlistId?: string;
sideways?: boolean;
}
@@ -71,12 +68,13 @@
}
let thumbnailHeight = $state(0);
let thumbnailHTMLElement: HTMLImageElement | undefined = $state();
let thumbnailImageElement: HTMLImageElement | undefined = $state();
let thumbnailElement: HTMLElement | undefined = $state();
const thumbnail = new Avatar({
src: () => thumbnailSrc,
onLoadingStatusChange: () => {
if (thumbnailHTMLElement) thumbnailHeight = thumbnailHTMLElement.naturalHeight;
if (thumbnailImageElement) thumbnailHeight = thumbnailImageElement.naturalHeight;
}
});
@@ -89,22 +87,30 @@
} else sideways = true;
}
let thumbnailActionsVisible = $state(false);
const observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) thumbnailActionsVisible = false;
});
onMount(async () => {
// Check if sideways should be enabled or disabled.
disableSideways();
addEventListener('resize', disableSideways);
if (!page.url.pathname.endsWith('/history'))
queueGetWatchHistory(video.videoId).then((watchHistory) => {
if (watchHistory) {
progress = watchHistory.progress.toString();
}
});
queueGetWatchHistory(video.videoId).then((watchHistory) => {
if (watchHistory) {
progress = watchHistory.progress.toString();
}
});
if (thumbnailElement) observer.observe(thumbnailElement);
});
onDestroy(() => {
removeEventListener('resize', disableSideways);
observer.disconnect();
});
function onVideoSelected() {
@@ -112,7 +118,14 @@
}
</script>
<div class:sideways-root={sideways} class:use-flex-column={!sideways} tabindex="0" role="button">
<div
class:sideways-root={sideways}
class:use-flex-column={!sideways}
bind:this={thumbnailElement}
onmouseleave={() => (thumbnailActionsVisible = false)}
tabindex="0"
role="button"
>
<div id="thumbnail-container">
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
@@ -128,7 +141,7 @@
class="responsive"
class:watched={progress !== undefined}
{...thumbnail.image}
bind:this={thumbnailHTMLElement}
bind:this={thumbnailImageElement}
alt="Thumbnail for video"
/>
</div>
@@ -188,37 +201,100 @@
<AuthorAvatar author={video.author} authorId={video.authorId} />
{/if}
<div>
{#if 'authorId' in video && video.authorId}
<a
tabindex="-1"
class:author={!sideways}
href={resolve(`/channel/[authorId]`, { authorId: video.authorId })}
data-sveltekit-preload-data="off"
>{video.author}
</a>
{:else}
<p>{video.author}</p>
{/if}
{#if 'promotedBy' in video && video.promotedBy === 'favourited'}
<i>star</i>
{/if}
{#if !('publishedText' in video) && 'viewCountText' in video}
•
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{$_('views')}
{/if}
{#if 'published' in video}
<nav>
<div>
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
•
{video.published && video.published !== 0
? relativeTimestamp(video.published * 1000, false)
: video.publishedText}
{#if 'authorId' in video && video.authorId}
<a
tabindex="-1"
class:author={!sideways}
href={resolve(`/channel/[authorId]`, { authorId: video.authorId })}
data-sveltekit-preload-data="off"
>{video.author}
</a>
{:else}
<p>{video.author}</p>
{/if}
{#if 'promotedBy' in video && video.promotedBy === 'favourited'}
<i>star</i>
{/if}
{#if !('publishedText' in video) && 'viewCountText' in video}
•
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{$_('views')}
{/if}
{#if 'published' in video}
<div>
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
•
{video.published && video.published !== 0
? relativeTimestamp(video.published * 1000, false)
: video.publishedText}
</div>
{/if}
</div>
{/if}
{#if !sideways}
<button onclick={() => (thumbnailActionsVisible = true)} class="transparent circle">
<i>more_vert</i>
</button>
{#if thumbnailActionsVisible}
<menu class="max active" transition:fade>
<li>
<Share
shares={[
{
type: 'materialious',
path: resolve('/watch/[videoId]', { videoId: video.videoId })
},
{
type: 'invidious',
path: `/watch?v=${video.videoId}`
},
{
type: 'invidious redirect',
path: `/watch?v=${video.videoId}`
},
{
type: 'youtube',
path: `/watch?v=${video.videoId}`
}
]}
classes="transparent max"
menuClasses=""
iconOnly={false}
onShare={() => (thumbnailActionsVisible = false)}
/>
</li>
<li>
<button
onclick={async () => {
if (progress) {
await deleteWatchHistoryItem(video.videoId);
progress = undefined;
} else {
await saveWatchHistory(video, 1);
progress = '0';
}
thumbnailActionsVisible = false;
}}
class="transparent max"
>
{#if progress}
<i>visibility_off</i>
<span>{$_('markAs.unwatched')}</span>
{:else}
<i>visibility</i>
<span>{$_('markAs.watched')}</span>
{/if}
</button>
</li>
</menu>
{/if}
{/if}
</nav>
</div>
</nav>
</div>
@@ -291,6 +367,7 @@
word-wrap: break-word;
overflow: hidden;
border-radius: 0px;
width: 100%;
}
.thumbnail-details {
@@ -320,6 +397,10 @@
align-items: end;
}
menu li:hover {
background-color: transparent;
}
@media screen and (max-width: 1800px) {
.sideways-root .thumbnail {
width: 100%;
@@ -38,6 +38,10 @@
"sortBy": "Sort By",
"features": "Features"
},
"markAs": {
"watched": "Mark as watched",
"unwatched": "Mark as unwatched"
},
"initalSetup": {
"useInvidious": "I want to use Invidious",
"useLocalFallback": "I want to use local video fallback",
+15 -1
View File
@@ -1,9 +1,23 @@
import type { Image } from './api/model';
import type {
Image,
Notification,
PlaylistPageVideo,
Video,
VideoBase,
VideoWatchHistory
} from './api/model';
import { localDb } from './dexie';
import { getBestThumbnail } from './images';
let isInitial = false;
export type ThumbnailVideo =
| VideoBase
| Video
| Notification
| PlaylistPageVideo
| VideoWatchHistory;
export async function associateAvatar(channelId: string, avatars: Image[]) {
if (avatars.length === 0) return;