diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts new file mode 100644 index 0000000000..9625fba40f --- /dev/null +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -0,0 +1,83 @@ +import * as utils from '$lib/utils'; +import type { AssetResponseDto } from '@immich/sdk'; +import { assetFactory } from '@test-data/factories/asset-factory'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import type { Mock, MockInstance } from 'vitest'; +import PhotoViewer from './photo-viewer.svelte'; + +vi.mock('$lib/utils', async (originalImport) => { + const meta = await originalImport(); + return { + ...meta, + downloadRequest: vi.fn(), + }; +}); + +describe('PhotoViewer component', () => { + let downloadRequestMock: MockInstance; + let createObjectURLMock: Mock<[obj: Blob], string>; + let asset: AssetResponseDto; + + beforeAll(() => { + downloadRequestMock = vi.spyOn(utils, 'downloadRequest').mockResolvedValue({ + data: new Blob(), + status: 200, + }); + createObjectURLMock = vi.fn(); + window.URL.createObjectURL = createObjectURLMock; + asset = assetFactory.build({ originalPath: 'image.png' }); + }); + + afterAll(() => { + vi.resetAllMocks(); + }); + + it('initially shows a loading spinner', () => { + render(PhotoViewer, { asset }); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('loads and shows a photo', async () => { + createObjectURLMock.mockReturnValueOnce('url-one'); + render(PhotoViewer, { asset }); + + expect(downloadRequestMock).toBeCalledWith( + expect.objectContaining({ + url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=${asset.checksum}`, + }), + ); + await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument()); + expect(screen.getByRole('img')).toHaveAttribute('src', 'url-one'); + }); + + it('loads high resolution photo when zoomed', async () => { + createObjectURLMock.mockReturnValueOnce('url-one'); + render(PhotoViewer, { asset }); + createObjectURLMock.mockReturnValueOnce('url-two'); + + await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument()); + await fireEvent(window, new CustomEvent('zoomImage')); + await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two')); + expect(downloadRequestMock).toBeCalledWith( + expect.objectContaining({ + url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=false&c=${asset.checksum}`, + }), + ); + }); + + it('reloads photo when checksum changes', async () => { + const { component } = render(PhotoViewer, { asset }); + createObjectURLMock.mockReturnValueOnce('url-two'); + + await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument()); + component.$set({ asset: { ...asset, checksum: 'new-checksum' } }); + + await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two')); + expect(downloadRequestMock).toBeCalledWith( + expect.objectContaining({ + url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=new-checksum`, + }), + ); + }); +}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 7ad0cd51fe..03377e0d59 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -3,7 +3,7 @@ import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetFileUrl, handlePromiseError } from '$lib/utils'; + import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { shortcuts } from '$lib/actions/shortcut'; @@ -24,14 +24,12 @@ export let haveFadeTransition = true; let imgElement: HTMLDivElement; - let assetFileUrl: string = ''; + let assetData: string; + let abortController: AbortController; + let hasZoomed = false; let copyImageToClipboard: (source: string) => Promise; let canCopyImagesToClipboard: () => boolean; let imageLoaded: boolean = false; - let imageError: boolean = false; - // set to true when am image has been zoomed, to force loading of the original image regardless - // of app settings - let forceLoadOriginal: boolean = false; const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset); @@ -42,12 +40,6 @@ }); } - $: { - preload({ preloadAssets, loadOriginal: loadOriginalByDefault }); - } - - $: assetFileUrl = load(asset.id, !loadOriginalByDefault || forceLoadOriginal, false, asset.checksum); - onMount(async () => { // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 // TODO: Move to regular import once the package correctly supports ESM. @@ -56,30 +48,42 @@ canCopyImagesToClipboard = module.canCopyImagesToClipboard; }); + $: void loadAssetData({ loadOriginal: loadOriginalByDefault, checksum: asset.checksum }); + onDestroy(() => { $boundingBoxesArray = []; + abortController?.abort(); }); - const preload = ({ - preloadAssets, - loadOriginal, - }: { - preloadAssets: AssetResponseDto[] | null; - loadOriginal: boolean; - }) => { - for (const preloadAsset of preloadAssets || []) { - if (preloadAsset.type === AssetTypeEnum.Image) { - let img = new Image(); - img.src = getAssetFileUrl(preloadAsset.id, !loadOriginal, false, preloadAsset.checksum); - } - } - }; + const loadAssetData = async ({ loadOriginal, checksum }: { loadOriginal: boolean; checksum: string }) => { + try { + abortController?.abort(); + abortController = new AbortController(); - const load = (assetId: string, isWeb: boolean, isThumb: boolean, checksum: string) => { - const assetUrl = getAssetFileUrl(assetId, isWeb, isThumb, checksum); - // side effect, only flag imageLoaded when url is different - imageLoaded = assetFileUrl === assetUrl; - return assetUrl; + // TODO: Use sdk once it supports signals + const res = await downloadRequest({ + url: getAssetFileUrl(asset.id, !loadOriginal, false, checksum), + signal: abortController.signal, + }); + + assetData = window.URL.createObjectURL(res.data); + imageLoaded = true; + + if (!preloadAssets) { + return; + } + + for (const preloadAsset of preloadAssets) { + if (preloadAsset.type === AssetTypeEnum.Image) { + await downloadRequest({ + url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false), + signal: abortController.signal, + }); + } + } + } catch { + imageLoaded = false; + } }; const doCopy = async () => { @@ -88,7 +92,7 @@ } try { - await copyImageToClipboard(assetFileUrl); + await copyImageToClipboard(assetData); notificationController.show({ type: NotificationType.Info, message: 'Copied image to clipboard.', @@ -117,7 +121,12 @@ zoomImageWheelState.subscribe((state) => { photoZoomState.set(state); - forceLoadOriginal = state.currentZoom > 1 && isWebCompatibleImage(asset) ? true : false; + + if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) { + hasZoomed = true; + + handlePromiseError(loadAssetData({ loadOriginal: true, checksum: asset.checksum })); + } }); const onCopyShortcut = (event: KeyboardEvent) => { @@ -137,53 +146,41 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> -{#if imageError} -
Error loading image
-{/if} -
- {getAltText(asset)} (imageLoaded = true)} - on:error={() => (imageError = imageLoaded = true)} - /> + +
{#if !imageLoaded} -
+
- {:else if !imageError} - {#key assetFileUrl} -
- {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} - {getAltText(asset)} - {/if} + {:else} +
+ {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {getAltText(asset)} - {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} -
- {/each} -
- {/key} + {/if} + {getAltText(asset)} + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} +
+ {/each} +
{/if}