diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 584e8391b9..3af48f824a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -295,13 +295,13 @@ }), ); let thumbnailPreloaded = $state(false); - // $effect(() => { - // // eslint-disable-next-line @typescript-eslint/no-unused-expressions - // asset; - // untrack(() => { - // void preloadManager.isUrlPreloaded(thumbnailUrl).then((preloaded) => (thumbnailPreloaded = preloaded)); - // }); - // }); + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + asset; + untrack(() => { + void preloadManager.isUrlPreloaded(thumbnailUrl).then((preloaded) => (thumbnailPreloaded = preloaded)); + }); + }); const exifDimensions = $derived( asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageHeight diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 740cf784b7..9d5fc1a43a 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -3,6 +3,7 @@ import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { Icon } from '@immich/ui'; import { mdiEyeOffOutline } from '@mdi/js'; + import { untrack } from 'svelte'; import type { ActionReturn } from 'svelte/action'; import type { ClassValue } from 'svelte/elements'; @@ -64,6 +65,14 @@ }; } + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + url; + untrack(() => { + preloadManager.loading(url); + }); + }); + let optionalClasses = $derived( [ curve && 'rounded-xl', diff --git a/web/src/lib/managers/PreloadManager.svelte.ts b/web/src/lib/managers/PreloadManager.svelte.ts index 6d28f9cfb9..dd6749669e 100644 --- a/web/src/lib/managers/PreloadManager.svelte.ts +++ b/web/src/lib/managers/PreloadManager.svelte.ts @@ -1,8 +1,15 @@ import { getAssetUrl } from '$lib/utils'; -import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; +import { cancelImageUrl, isImageUrlCached, preloadImageUrl } from '$lib/utils/sw-messaging'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; class PreloadManager { + #cachedImages = new Set(); + loading(url: string) { + if (!globalThis.isSecureContext) { + this.#cachedImages.add(url); + } + } + preload(asset: AssetResponseDto | undefined | null) { if (globalThis.isSecureContext) { preloadImageUrl(getAssetUrl({ asset })); @@ -33,6 +40,24 @@ class PreloadManager { } cancelImageUrl(url); } + + isPreloaded(asset: AssetResponseDto | undefined) { + if (!asset) { + return Promise.resolve(false); + } + const url = getAssetUrl({ asset }); + return this.isUrlPreloaded(url); + } + + isUrlPreloaded(url: string | undefined | null) { + if (!url) { + return Promise.resolve(false); + } + if (globalThis.isSecureContext) { + return isImageUrlCached(url); + } + return Promise.resolve(this.#cachedImages.has(url)); + } } export const preloadManager = new PreloadManager(); diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts index 61cd1b8df0..bc46c0ce23 100644 --- a/web/src/lib/utils/sw-messaging.ts +++ b/web/src/lib/utils/sw-messaging.ts @@ -1,14 +1,48 @@ const broadcast = new BroadcastChannel('immich'); +let isLoadedReplyListeners: ((url: string, isUrlCached: boolean) => void)[] = []; +broadcast.addEventListener('message', (event) => { + if (event.data.type == 'isImageUrlCachedReply') { + for (const listener of isLoadedReplyListeners) { + listener(event.data.url, event.data.isImageUrlCached); + } + } +}); + export function cancelImageUrl(url: string | undefined | null) { if (!url) { return; } broadcast.postMessage({ type: 'cancel', url }); } + export function preloadImageUrl(url: string | undefined | null) { if (!url) { return; } broadcast.postMessage({ type: 'preload', url }); } + +export function isImageUrlCached(url: string | undefined | null): Promise { + if (!url) { + return Promise.resolve(false); + } + if (!globalThis.isSecureContext) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + const listener = (urlReply: string, isUrlCached: boolean) => { + if (urlReply === url) { + cleanup(isUrlCached); + } + }; + const cleanup = (isUrlCached: boolean) => { + isLoadedReplyListeners = isLoadedReplyListeners.filter((element) => element !== listener); + resolve(isUrlCached); + }; + isLoadedReplyListeners.push(listener); + broadcast.postMessage({ type: 'isImageUrlCached', url }); + + setTimeout(() => cleanup(false), 5000); + }); +} diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts index ae6f1e1be6..62f90bbcc5 100644 --- a/web/src/service-worker/broadcast-channel.ts +++ b/web/src/service-worker/broadcast-channel.ts @@ -1,7 +1,8 @@ -import { handleCancel, handlePreload } from './request'; +import { handleCancel, handleIsUrlCached, handlePreload } from './request'; + +export const broadcast = new BroadcastChannel('immich'); export const installBroadcastChannelListener = () => { - const broadcast = new BroadcastChannel('immich'); // eslint-disable-next-line unicorn/prefer-add-event-listener broadcast.onmessage = (event) => { if (!event.data) { @@ -20,6 +21,15 @@ export const installBroadcastChannelListener = () => { handleCancel(url); break; } + + case 'isImageUrlCached': { + void handleIsUrlCached(url); + break; + } } }; }; + +export const replyIsImageUrlCached = (url: string, isImageUrlCached: boolean) => { + broadcast.postMessage({ type: 'isImageUrlCachedReply', url, isImageUrlCached }); +}; diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts index 9f13512047..ffc9e76d56 100644 --- a/web/src/service-worker/request.ts +++ b/web/src/service-worker/request.ts @@ -1,3 +1,4 @@ +import { replyIsImageUrlCached } from './broadcast-channel'; import { get, put } from './cache'; const pendingRequests = new Map void)[] }>(); @@ -93,3 +94,11 @@ export const handleCancel = (url: URL) => { requestSignals.abort.abort(); }; + +export const handleIsUrlCached = async (url: URL) => { + const cacheKey = getCacheKey(url); + + const isImageUrlCached = !!(await get(cacheKey)); + console.log('cacheKey', cacheKey, isImageUrlCached); + replyIsImageUrlCached(url.pathname + url.search + url.hash, isImageUrlCached); +};