From 2bba33f834d0583109e9fe7ccd74a2af228cce34 Mon Sep 17 00:00:00 2001 From: Thomas Way Date: Sun, 10 Aug 2025 20:59:02 +0100 Subject: [PATCH] feat(web): don't animate cached thumbnails Thumbnails for assets always are displayed with a thumbhash which fades out over 100ms, even if the thumbnail is cached and ready immediately. This can be a bit distracting and make Immich feel 'slow', or inefficient as it feels like the thumbnails are always being reloaded. Skipping the thumbhash and animation for cached thumbnails makes it feel much more responsive. --- .../thumbnail/__test__/thumbnail.spec.ts | 11 ----- .../assets/thumbnail/thumbnail.svelte | 45 ++++++++++++++----- web/src/lib/utils/cache.ts | 18 ++++++++ web/src/lib/utils/tunables.ts | 2 +- 4 files changed, 52 insertions(+), 24 deletions(-) create mode 100644 web/src/lib/utils/cache.ts diff --git a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts index f8e5fe0efa..21466780e8 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts @@ -45,15 +45,4 @@ describe('Thumbnail component', () => { const tabbables = getTabbable(container!); expect(tabbables.length).toBe(0); }); - - it('shows thumbhash while image is loading', () => { - const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' }); - const sut = render(Thumbnail, { - asset, - selected: true, - }); - - const thumbhash = sut.getByTestId('thumbhash'); - expect(thumbhash).not.toBeFalsy(); - }); }); diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index e4b590b8ea..8426b182b2 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -20,6 +20,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; + import { isCached } from '$lib/utils/cache'; import { moveFocus } from '$lib/utils/focus-util'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { TUNABLES } from '$lib/utils/tunables'; @@ -75,6 +76,12 @@ IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, } = TUNABLES; + const thumbnailURL = getAssetThumbnailUrl({ + id: asset.id, + size: AssetMediaSize.Thumbnail, + cacheKey: asset.thumbhash, + }); + let usingMobileDevice = $derived(mobileDevice.pointerCoarse); let element: HTMLElement | undefined = $state(); let mouseOver = $state(false); @@ -313,7 +320,7 @@ {/if} - {#if (!loaded || thumbError) && asset.thumbhash} - + {#if asset.thumbhash} + {#await isCached(new Request(thumbnailURL))} + + {:then cached} + {#if !cached && !loaded && !thumbError} + + {/if} + {/await} {/if} diff --git a/web/src/lib/utils/cache.ts b/web/src/lib/utils/cache.ts new file mode 100644 index 0000000000..0c2792b541 --- /dev/null +++ b/web/src/lib/utils/cache.ts @@ -0,0 +1,18 @@ +let cache: Cache | undefined; + +const getCache = async () => { + cache ||= await openCache(); + return cache; +}; + +const openCache = async () => { + const [key] = await caches.keys(); + if (key) { + return caches.open(key); + } +}; + +export const isCached = async (req: Request) => { + const cache = await getCache(); + return !!(await cache?.match(req)); +}; diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts index 6ce64ed041..8463ffa73a 100644 --- a/web/src/lib/utils/tunables.ts +++ b/web/src/lib/utils/tunables.ts @@ -29,6 +29,6 @@ export const TUNABLES = { NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(storage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), }, IMAGE_THUMBNAIL: { - THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100), + THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 1000), }, };