Moved to Melt's avatar builder for image loading

This commit is contained in:
WardPearce
2026-03-02 18:50:55 +13:00
parent f947566e37
commit 972a9ba7f8
10 changed files with 159 additions and 276 deletions
+13 -9
View File
@@ -4,10 +4,12 @@
import type { Image } from '$lib/api/model';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { isYTBackend, truncate } from '$lib/misc';
import { invidiousAuthStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
import { invidiousAuthStore, isAndroidTvStore } from '$lib/store';
import { _ } from '$lib/i18n';
import { localDb } from '$lib/dexie';
import { onMount } from 'svelte';
import { Avatar } from 'melt/builders';
import { mergeAttrs } from 'melt';
let {
channel,
@@ -49,19 +51,21 @@
favoritedChannel = true;
}
}
const avatar = new Avatar({ src: proxyGoogleImage(getBestThumbnail(channel.authorThumbnails)) });
console.log(avatar.src);
</script>
<nav>
<a href={resolve(`/channel/[authorId]`, { authorId: channel.authorId })}>
<nav style="gap: 0.5em;">
{#if !$interfaceLowBandwidthMode}
<img
loading="lazy"
class="circle large"
src={proxyGoogleImage(getBestThumbnail(channel.authorThumbnails))}
alt="Channel profile"
/>
{/if}
<img class="circle large" {...avatar.image} alt="Channel profile" />
<button
class="large secondary-container"
{...mergeAttrs(avatar.fallback, {
style: 'text-transform: uppercase;border-radius: 2.5rem !important;'
})}>{channel.author[0]}</button
>
<div>
<p style="margin: 0;" class="bold">
{$isAndroidTvStore ? channel.author : truncate(channel.author, 16)}
@@ -33,7 +33,6 @@
interfaceDefaultPage,
interfaceDisableAutoUpdate,
interfaceForceCase,
interfaceLowBandwidthMode,
interfaceRegionStore,
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
@@ -280,23 +279,6 @@
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.lowBandwidthMode')}</div>
</div>
<label class="switch" tabindex="0">
<input
type="checkbox"
bind:checked={$interfaceLowBandwidthMode}
onclick={() => interfaceLowBandwidthMode.set(!$interfaceLowBandwidthMode)}
role="switch"
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
@@ -1,13 +1,12 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { getBestThumbnail, insecureRequestImageHandler, proxyGoogleImage } from '$lib/images';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { cleanNumber } from '$lib/numbers';
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import type { Channel } from '$lib/api/model';
import { truncate } from '$lib/misc';
import { interfaceLowBandwidthMode } from '$lib/store';
import { Avatar } from 'melt/builders';
import { mergeAttrs } from 'melt';
interface Props {
channel: Channel;
@@ -15,18 +14,7 @@
let { channel }: Props = $props();
let channelPfp: HTMLImageElement | undefined = $state();
onMount(async () => {
if (get(interfaceLowBandwidthMode)) return;
const img = await insecureRequestImageHandler(
proxyGoogleImage(getBestThumbnail(channel.authorThumbnails))
);
img.onload = () => {
channelPfp = img;
};
});
const avatar = new Avatar({ src: proxyGoogleImage(getBestThumbnail(channel.authorThumbnails)) });
</script>
<a
@@ -35,21 +23,20 @@
style="min-width: 100%;min-height: 100%;"
>
<div class="padding">
{#if !$interfaceLowBandwidthMode}
<div class="center-align">
{#if !channelPfp}
<progress class="circle"></progress>
{:else}
<img
loading="lazy"
class="circle"
style="width: 90px;height: 90px;"
src={channelPfp.src}
alt={channel.author}
/>
{/if}
</div>
{/if}
<div class="center-align">
<img
{...avatar.image}
class="circle"
style="width: 90px;height: 90px;"
alt={channel.author}
/>
<button
class="secondary-container large"
{...mergeAttrs(avatar.fallback, {
style: 'text-transform: uppercase;border-radius: 2.5rem !important;'
})}>{channel.author[0]}</button
>
</div>
<h5 class="center-align">{truncate(channel.author, 14)}</h5>
<h6 style="margin-top: 0;" class="center-align grey-text medium-text">
{cleanNumber(channel.subCount)}
@@ -1,13 +1,12 @@
<script lang="ts">
import { getBestThumbnail, insecureRequestImageHandler } from '$lib/images';
import { getBestThumbnail } from '$lib/images';
import { resolve } from '$app/paths';
import { letterCase } from '$lib/letterCasing';
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import type { Playlist, PlaylistPage } from '$lib/api/model';
import { truncate } from '$lib/misc';
import { interfaceLowBandwidthMode } from '$lib/store';
import { Avatar } from 'melt/builders';
import { mergeAttrs } from 'melt';
interface Props {
playlist: Playlist | PlaylistPage;
@@ -20,21 +19,14 @@
const playlistLink = resolve('/playlist/[playlistId]', { playlistId: playlist.playlistId });
onMount(async () => {
if (get(interfaceLowBandwidthMode)) return;
let thumbnailSrc = '';
if (playlist.videos && playlist.videos.length > 0) {
thumbnailSrc = getBestThumbnail(playlist.videos[0].videoThumbnails) || '';
} else if (playlist.playlistThumbnail) {
thumbnailSrc = playlist.playlistThumbnail;
}
let imgSrc;
if (playlist.videos && playlist.videos.length > 0) {
imgSrc = getBestThumbnail(playlist.videos[0].videoThumbnails) || '';
} else if (playlist.playlistThumbnail) {
imgSrc = playlist.playlistThumbnail;
} else {
imgSrc = '';
return;
}
img = await insecureRequestImageHandler(imgSrc);
});
const thumbnail = new Avatar({ src: thumbnailSrc });
</script>
<a
@@ -43,17 +35,15 @@
style="width: 100%; overflow: hidden;min-height:100px;"
class="wave"
>
{#if !$interfaceLowBandwidthMode && img}
<img
loading="lazy"
class="responsive"
style="max-width: 100%;height: 100%;"
src={img.src}
alt="Thumbnail for playlist"
/>
{:else}
<h6 style="margin: 3em 0;">No image</h6>
{/if}
<img
class="responsive"
{...mergeAttrs(thumbnail.image, {
style: 'max-width: 100%;height: 100%;'
})}
alt="Thumbnail for playlist"
/>
<div {...thumbnail.fallback} class="secondary-container responsive" style="height: 200px;"></div>
<div class="absolute right bottom small-margin black white-text small-text thumbnail-corner">
{playlist.videoCount}
{$_('videos')}
@@ -1,12 +1,13 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { getBestThumbnail, insecureRequestImageHandler } from '$lib/images';
import { getBestThumbnail } from '$lib/images';
import { letterCase } from '$lib/letterCasing';
import { cleanNumber, videoLength } from '$lib/numbers';
import { onDestroy, onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { getDeArrow, getThumbnail } from '$lib/api';
import { Avatar } from 'melt/builders';
import type {
Notification,
PlaylistPageVideo,
@@ -18,7 +19,6 @@
import type { PlayerEvents } from '$lib/player';
import {
deArrowEnabledStore,
interfaceLowBandwidthMode,
isAndroidTvStore,
playerSavePlaybackPositionStore,
playerState,
@@ -39,8 +39,6 @@
let { video = $bindable(), playlistId = '', sideways = $bindable(false) }: Props = $props();
let placeholderHeight: number = $state(0);
let watchUrl = createVideoUrl(video.videoId, playlistId);
let beenWatched: boolean = $state(false);
@@ -51,8 +49,6 @@
}
});
let thumbnail: HTMLImageElement | undefined = $state();
let progress: string | undefined = $state();
if (get(playerSavePlaybackPositionStore)) {
try {
@@ -64,6 +60,39 @@
progress = undefined;
}
let thumbnailSrc = $state(
'thumbnail' in video ? video.thumbnail : (getBestThumbnail(video.videoThumbnails) as string)
);
if (get(deArrowEnabledStore)) {
try {
getDeArrow(video.videoId, { priority: 'low' }).then(async (deArrow) => {
for (const title of deArrow.titles) {
if (title.locked || title.votes > 0) {
video.title = title.title.replace('>', '');
break;
}
}
for (const thumbnail of deArrow.thumbnails) {
if (thumbnail.locked || thumbnail.original || thumbnail.votes > 0) {
if (thumbnail.timestamp !== null) {
thumbnailSrc = await getThumbnail(video.videoId, thumbnail.timestamp, {
priority: 'low'
});
}
break;
}
}
});
} catch {
// Continue regardless of error
}
}
const thumbnail = new Avatar({ src: () => thumbnailSrc });
let thumbnailHTMLElement: HTMLImageElement | undefined = $state();
let startedSideways = sideways === true;
function disableSideways() {
if (!startedSideways) return;
@@ -78,16 +107,8 @@
}
onMount(async () => {
calcThumbnailPlaceholderHeight();
// Check if sideways should be enabled or disabled.
disableSideways();
addEventListener('resize', () => {
disableSideways();
calcThumbnailPlaceholderHeight();
});
checkIfWatched();
if (
@@ -101,39 +122,6 @@
checkIfWatched();
}
});
if (get(interfaceLowBandwidthMode)) return;
let imageSrc =
'thumbnail' in video ? video.thumbnail : (getBestThumbnail(video.videoThumbnails) as string);
if (get(deArrowEnabledStore)) {
try {
const deArrow = await getDeArrow(video.videoId);
for (const title of deArrow.titles) {
if (title.locked || title.votes > 0) {
video.title = title.title.replace('>', '');
break;
}
}
for (const thumbnail of deArrow.thumbnails) {
if (thumbnail.locked || thumbnail.original || thumbnail.votes > 0) {
if (thumbnail.timestamp !== null) {
imageSrc = await getThumbnail(video.videoId, thumbnail.timestamp);
}
break;
}
}
} catch {
// Continue regardless of error
}
}
const img = await insecureRequestImageHandler(imageSrc);
img.onload = () => {
thumbnail = img;
};
});
onDestroy(() => {
@@ -159,26 +147,6 @@
});
}
}
function calcThumbnailPlaceholderHeight() {
if ($isAndroidTvStore) {
placeholderHeight = 100;
return;
}
if (!sideways) {
if (innerWidth < 1000) {
if (innerWidth < 600) {
placeholderHeight = innerWidth / 2;
} else {
placeholderHeight = innerWidth / 4;
}
} else {
placeholderHeight = innerWidth / 12;
}
} else {
placeholderHeight = 100;
}
}
</script>
<div class:sideways-root={sideways} tabindex="0" role="button">
@@ -191,32 +159,28 @@
data-sveltekit-preload-data="off"
onclick={onVideoSelected}
>
{#if !$interfaceLowBandwidthMode}
<div class="thumbnail-image">
{#if !thumbnail}
<div
class="secondary-container"
style="width: 100%;height: {placeholderHeight}px;"
></div>
{:else}
<div class:crop={thumbnail.height > 300}>
<img
class="responsive"
class:watched={beenWatched}
loading="lazy"
src={thumbnail.src}
alt="Thumbnail for video"
/>
</div>
{/if}
{#if beenWatched}
<div class="chip surface-container-highest">
<i>check</i>
</div>
{/if}
<div class="thumbnail-image">
<div class:crop={thumbnailHTMLElement ? thumbnailHTMLElement.height > 300 : false}>
<img
class="responsive"
class:watched={beenWatched}
{...thumbnail.image}
bind:this={thumbnailHTMLElement}
alt="Thumbnail for video"
/>
</div>
{/if}
<div
{...thumbnail.fallback}
class="secondary-container responsive"
style="height: 200px;"
></div>
{#if beenWatched}
<div class="chip surface-container-highest">
<i>check</i>
</div>
{/if}
</div>
{#if progress}
<progress
class="absolute right bottom"
@@ -227,15 +191,11 @@
{/if}
{#if !('liveVideo' in video) || !video.liveVideo}
{#if video.lengthSeconds !== 0}
{#if !$interfaceLowBandwidthMode}
<div
class="absolute right bottom small-margin black white-text small-text thumbnail-corner"
>
&nbsp;{videoLength(video.lengthSeconds)}&nbsp;
</div>
{:else}
<h3>{videoLength(video.lengthSeconds)}</h3>
{/if}
<div
class="absolute right bottom small-margin black white-text small-text thumbnail-corner"
>
&nbsp;{videoLength(video.lengthSeconds)}&nbsp;
</div>
{/if}
{:else if video.lengthSeconds !== 0}
<div class="absolute right bottom small-margin red white-text small-text thumbnail-corner">
@@ -2,14 +2,14 @@
import { resolve } from '$app/paths';
import { getComments } from '$lib/api';
import { type Comment, type Comments } from '$lib/api/model';
import { getBestThumbnail, insecureRequestImageHandler, proxyGoogleImage } from '$lib/images';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { numberWithCommas } from '$lib/numbers';
import { interfaceLowBandwidthMode } from '$lib/store';
import { onMount } from 'svelte';
import CommentSelf from './Comment.svelte';
import { truncate } from '$lib/misc';
import { _ } from '$lib/i18n';
import { extractActualLink } from '$lib/description';
import { Avatar } from 'melt/builders';
import { mergeAttrs } from 'melt';
interface Props {
comment: Comment;
@@ -71,59 +71,49 @@
return doc.documentElement.outerHTML;
}
let userPfp = $state('');
onMount(async () => {
if ($interfaceLowBandwidthMode) return;
const img = await insecureRequestImageHandler(
proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))
);
img.onload = () => {
userPfp = img.src;
};
});
const avatar = new Avatar({ src: proxyGoogleImage(getBestThumbnail(comment.authorThumbnails)) });
</script>
<article class="comment" class:border={!isSubComp}>
{#if !$interfaceLowBandwidthMode}
<div class="comment-header">
{#if userPfp}
<img loading="lazy" class="circle small" src={userPfp} alt="comment profile" />
{:else}
<progress class="circle"></progress>
{/if}
<div class="comment-info">
<a href={resolve(`/channel/[authorId]`, { authorId: comment.authorId })} class="author">
<span class="bold" class:channel-owner={comment.authorIsChannelOwner}>
{truncate(comment.author, 12)}
</span>
<span class="secondary-text">{comment.publishedText}</span>
</a>
<p class="no-margin">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html parseComment(comment.contentHtml)}
<!-- Comment comes directly from YT so is already sanitized -->
</p>
<div class="comment-actions">
<p class="no-margin no-padding"><i>thumb_up</i> {numberWithCommas(comment.likeCount)}</p>
{#if comment.replies && !replies}
<button
onclick={async () => loadReplies(comment.replies.continuation)}
class="transparent replies"
>
<i>expand_more</i>
<span>{comment.replies.replyCount} {replyText}</span>
</button>
{:else if replies}
<button onclick={() => (replies = undefined)} class="transparent replies">
<i>expand_less</i>
<span>Hide {replyText}</span>
</button>
{/if}
</div>
<div class="comment-header">
<img {...avatar.image} loading="lazy" class="circle small" alt="comment profile" />
<button
class="secondary-container"
{...mergeAttrs(avatar.fallback, {
style: 'text-transform: uppercase;border-radius: 2.5rem !important;'
})}>{comment.author[1]}</button
>
<div class="comment-info">
<a href={resolve(`/channel/[authorId]`, { authorId: comment.authorId })} class="author">
<span class="bold" class:channel-owner={comment.authorIsChannelOwner}>
{truncate(comment.author, 12)}
</span>
<span class="secondary-text">{comment.publishedText}</span>
</a>
<p class="no-margin">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html parseComment(comment.contentHtml)}
<!-- Comment comes directly from YT so is already sanitized -->
</p>
<div class="comment-actions">
<p class="no-margin no-padding"><i>thumb_up</i> {numberWithCommas(comment.likeCount)}</p>
{#if comment.replies && !replies}
<button
onclick={async () => loadReplies(comment.replies.continuation)}
class="transparent replies"
>
<i>expand_more</i>
<span>{comment.replies.replyCount} {replyText}</span>
</button>
{:else if replies}
<button onclick={() => (replies = undefined)} class="transparent replies">
<i>expand_less</i>
<span>Hide {replyText}</span>
</button>
{/if}
</div>
</div>
{/if}
</div>
{#if replies}
{#each replies.comments as reply (reply)}
@@ -13,7 +13,6 @@ import {
interfaceDefaultPage,
interfaceDisplayThumbnailAvatars,
interfaceForceCase,
interfaceLowBandwidthMode,
interfaceRegionStore,
interfaceSearchSuggestionsStore,
playerAlwaysLoopStore,
@@ -203,11 +202,6 @@ export const persistedStores: PersistedStore<any>[] = [
store: showWarningStore,
schema: zBoolean
},
{
name: 'lowBandwidthMode',
store: interfaceLowBandwidthMode,
schema: zBoolean
},
{
name: 'displayThumbnailAvatars',
store: interfaceDisplayThumbnailAvatars,
-17
View File
@@ -27,23 +27,6 @@ export class ImageCache {
}
}
export async function insecureRequestImageHandler(source: string): Promise<HTMLImageElement> {
const img = new Image();
if (get(interfaceAllowInsecureRequests)) {
const imgResp = await fetch(source);
if (!imgResp.ok) {
img.src = '';
return img;
}
img.src = URL.createObjectURL(await imgResp.blob());
} else {
img.src = source;
}
return img;
}
export function getBestThumbnail(
images: Image[] | null,
maxWidthDimension: number = 480,
-5
View File
@@ -234,11 +234,6 @@ export const interfaceAutoExpandChapters: Writable<boolean> = persist(
'autoExpandChapters'
);
export const interfaceAmoledTheme = persist(writable(false), createStorage(), 'amoledTheme');
export const interfaceLowBandwidthMode = persist(
writable(false),
createStorage(),
'lowBandwidthMode'
);
export const interfaceDisplayThumbnailAvatars = persist(
writable(false),
createStorage(),
@@ -4,7 +4,7 @@
import PageLoading from '$lib/components/PageLoading.svelte';
import { proxyGoogleImage } from '$lib/images';
import { cleanNumber } from '$lib/numbers';
import { channelCacheStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
import { channelCacheStore, isAndroidTvStore } from '$lib/store';
import { _ } from '$lib/i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import ItemsList from '$lib/components/layout/ItemsList.svelte';
@@ -14,6 +14,7 @@
import { isYTBackend } from '$lib/misc';
import Share from '$lib/components/Share.svelte';
import { resolve } from '$app/paths';
import { Avatar } from 'melt/builders';
let tab: ChannelContentTypes = $state('videos');
@@ -83,17 +84,14 @@
displayContent = undefined;
displayContent = await getChannelContent(page.params.slug, { type: tab });
}
const banner = new Avatar({
src: proxyGoogleImage($channelCacheStore[page.params.slug].channel.authorBanners[0].url)
});
</script>
<div class="padding">
{#if $channelCacheStore[page.params.slug].channel.authorBanners.length > 0 && !$interfaceLowBandwidthMode}
<img
loading="lazy"
src={proxyGoogleImage($channelCacheStore[page.params.slug].channel.authorBanners[0].url)}
width="100%"
alt="Channel banner"
/>
{/if}
<img {...banner.image} width="100%" alt="Channel banner" />
<div class="description">
<div>
<Author