Started thumbnail actions
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user