mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(web): preload assets in photo-viewer (#7920)
* feat(web): preload assets in photo-viewer * PR feedback --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									582cdcab82
								
							
						
					
					
						commit
						ab4b8eca15
					
				@ -55,6 +55,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  export let assetStore: AssetStore | null = null;
 | 
					  export let assetStore: AssetStore | null = null;
 | 
				
			||||||
  export let asset: AssetResponseDto;
 | 
					  export let asset: AssetResponseDto;
 | 
				
			||||||
 | 
					  export let preloadAssets: AssetResponseDto[] = [];
 | 
				
			||||||
  export let showNavigation = true;
 | 
					  export let showNavigation = true;
 | 
				
			||||||
  export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 | 
					  export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 | 
				
			||||||
  $: isTrashEnabled = $featureFlags.trash;
 | 
					  $: isTrashEnabled = $featureFlags.trash;
 | 
				
			||||||
@ -103,6 +104,11 @@
 | 
				
			|||||||
      $stackAssetsStore = [...$stackAssetsStore, asset].sort(
 | 
					      $stackAssetsStore = [...$stackAssetsStore, asset].sort(
 | 
				
			||||||
        (a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(),
 | 
					        (a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // if its a stack, add the next stack image in addition to the next asset
 | 
				
			||||||
 | 
					      if (asset.stackCount > 1) {
 | 
				
			||||||
 | 
					        preloadAssets.push($stackAssetsStore[1]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!$stackAssetsStore.map((a) => a.id).includes(asset.id)) {
 | 
					    if (!$stackAssetsStore.map((a) => a.id).includes(asset.id)) {
 | 
				
			||||||
@ -613,7 +619,7 @@
 | 
				
			|||||||
    {#if previewStackedAsset}
 | 
					    {#if previewStackedAsset}
 | 
				
			||||||
      {#key previewStackedAsset.id}
 | 
					      {#key previewStackedAsset.id}
 | 
				
			||||||
        {#if previewStackedAsset.type === AssetTypeEnum.Image}
 | 
					        {#if previewStackedAsset.type === AssetTypeEnum.Image}
 | 
				
			||||||
          <PhotoViewer asset={previewStackedAsset} on:close={closeViewer} haveFadeTransition={false} />
 | 
					          <PhotoViewer asset={previewStackedAsset} {preloadAssets} on:close={closeViewer} haveFadeTransition={false} />
 | 
				
			||||||
        {:else}
 | 
					        {:else}
 | 
				
			||||||
          <VideoViewer
 | 
					          <VideoViewer
 | 
				
			||||||
            assetId={previewStackedAsset.id}
 | 
					            assetId={previewStackedAsset.id}
 | 
				
			||||||
@ -645,7 +651,7 @@
 | 
				
			|||||||
                .endsWith('.insp'))}
 | 
					                .endsWith('.insp'))}
 | 
				
			||||||
            <PanoramaViewer {asset} />
 | 
					            <PanoramaViewer {asset} />
 | 
				
			||||||
          {:else}
 | 
					          {:else}
 | 
				
			||||||
            <PhotoViewer {asset} on:close={closeViewer} />
 | 
					            <PhotoViewer {asset} {preloadAssets} on:close={closeViewer} />
 | 
				
			||||||
          {/if}
 | 
					          {/if}
 | 
				
			||||||
        {:else}
 | 
					        {:else}
 | 
				
			||||||
          <VideoViewer
 | 
					          <VideoViewer
 | 
				
			||||||
@ -676,7 +682,7 @@
 | 
				
			|||||||
        class="z-[1005] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 mb-1 overflow-x-auto horizontal-scrollbar"
 | 
					        class="z-[1005] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 mb-1 overflow-x-auto horizontal-scrollbar"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="relative w-full whitespace-nowrap transition-all">
 | 
					        <div class="relative w-full whitespace-nowrap transition-all">
 | 
				
			||||||
          {#each $stackAssetsStore as stackedAsset (stackedAsset.id)}
 | 
					          {#each $stackAssetsStore as stackedAsset, index (stackedAsset.id)}
 | 
				
			||||||
            <div
 | 
					            <div
 | 
				
			||||||
              class="{stackedAsset.id == asset.id
 | 
					              class="{stackedAsset.id == asset.id
 | 
				
			||||||
                ? '-translate-y-[1px]'
 | 
					                ? '-translate-y-[1px]'
 | 
				
			||||||
@ -687,7 +693,10 @@
 | 
				
			|||||||
                  ? 'bg-transparent border-2 border-white'
 | 
					                  ? 'bg-transparent border-2 border-white'
 | 
				
			||||||
                  : 'bg-gray-700/40'} inline-block hover:bg-transparent"
 | 
					                  : 'bg-gray-700/40'} inline-block hover:bg-transparent"
 | 
				
			||||||
                asset={stackedAsset}
 | 
					                asset={stackedAsset}
 | 
				
			||||||
                on:click={() => (asset = stackedAsset)}
 | 
					                on:click={() => {
 | 
				
			||||||
 | 
					                  asset = stackedAsset;
 | 
				
			||||||
 | 
					                  preloadAssets = index + 1 >= $stackAssetsStore.length ? [] : [$stackAssetsStore[index + 1]];
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
                on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
 | 
					                on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
 | 
				
			||||||
                readonly
 | 
					                readonly
 | 
				
			||||||
                thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
 | 
					                thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
 | 
				
			||||||
 | 
				
			|||||||
@ -7,7 +7,7 @@
 | 
				
			|||||||
  import { isWebCompatibleImage } from '$lib/utils/asset-utils';
 | 
					  import { isWebCompatibleImage } from '$lib/utils/asset-utils';
 | 
				
			||||||
  import { getBoundingBox } from '$lib/utils/people-utils';
 | 
					  import { getBoundingBox } from '$lib/utils/people-utils';
 | 
				
			||||||
  import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
 | 
					  import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
 | 
				
			||||||
  import { type AssetResponseDto } from '@immich/sdk';
 | 
					  import { type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
 | 
				
			||||||
  import { useZoomImageWheel } from '@zoom-image/svelte';
 | 
					  import { useZoomImageWheel } from '@zoom-image/svelte';
 | 
				
			||||||
  import { onDestroy, onMount } from 'svelte';
 | 
					  import { onDestroy, onMount } from 'svelte';
 | 
				
			||||||
  import { fade } from 'svelte/transition';
 | 
					  import { fade } from 'svelte/transition';
 | 
				
			||||||
@ -16,6 +16,7 @@
 | 
				
			|||||||
  import { getAltText } from '$lib/utils/thumbnail-util';
 | 
					  import { getAltText } from '$lib/utils/thumbnail-util';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let asset: AssetResponseDto;
 | 
					  export let asset: AssetResponseDto;
 | 
				
			||||||
 | 
					  export let preloadAssets: AssetResponseDto[] | null = null;
 | 
				
			||||||
  export let element: HTMLDivElement | undefined = undefined;
 | 
					  export let element: HTMLDivElement | undefined = undefined;
 | 
				
			||||||
  export let haveFadeTransition = true;
 | 
					  export let haveFadeTransition = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -25,6 +26,7 @@
 | 
				
			|||||||
  let hasZoomed = false;
 | 
					  let hasZoomed = false;
 | 
				
			||||||
  let copyImageToClipboard: (source: string) => Promise<Blob>;
 | 
					  let copyImageToClipboard: (source: string) => Promise<Blob>;
 | 
				
			||||||
  let canCopyImagesToClipboard: () => boolean;
 | 
					  let canCopyImagesToClipboard: () => boolean;
 | 
				
			||||||
 | 
					  let imageLoaded: boolean = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset);
 | 
					  const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -41,6 +43,9 @@
 | 
				
			|||||||
    const module = await import('copy-image-clipboard');
 | 
					    const module = await import('copy-image-clipboard');
 | 
				
			||||||
    copyImageToClipboard = module.copyImageToClipboard;
 | 
					    copyImageToClipboard = module.copyImageToClipboard;
 | 
				
			||||||
    canCopyImagesToClipboard = module.canCopyImagesToClipboard;
 | 
					    canCopyImagesToClipboard = module.canCopyImagesToClipboard;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    imageLoaded = false;
 | 
				
			||||||
 | 
					    await loadAssetData({ loadOriginal: loadOriginalByDefault });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onDestroy(() => {
 | 
					  onDestroy(() => {
 | 
				
			||||||
@ -60,8 +65,22 @@
 | 
				
			|||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      assetData = URL.createObjectURL(data);
 | 
					      assetData = URL.createObjectURL(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 {
 | 
					    } catch {
 | 
				
			||||||
      // Do nothing
 | 
					      imageLoaded = false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -128,9 +147,9 @@
 | 
				
			|||||||
  transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
 | 
					  transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
 | 
				
			||||||
  class="flex h-full select-none place-content-center place-items-center"
 | 
					  class="flex h-full select-none place-content-center place-items-center"
 | 
				
			||||||
>
 | 
					>
 | 
				
			||||||
  {#await loadAssetData({ loadOriginal: loadOriginalByDefault })}
 | 
					  {#if !imageLoaded}
 | 
				
			||||||
    <LoadingSpinner />
 | 
					    <LoadingSpinner />
 | 
				
			||||||
  {:then}
 | 
					  {:else}
 | 
				
			||||||
    <div bind:this={imgElement} class="h-full w-full">
 | 
					    <div bind:this={imgElement} class="h-full w-full">
 | 
				
			||||||
      <img
 | 
					      <img
 | 
				
			||||||
        bind:this={$photoViewer}
 | 
					        bind:this={$photoViewer}
 | 
				
			||||||
@ -147,5 +166,5 @@
 | 
				
			|||||||
        />
 | 
					        />
 | 
				
			||||||
      {/each}
 | 
					      {/each}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  {/await}
 | 
					  {/if}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -38,7 +38,7 @@
 | 
				
			|||||||
  const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
 | 
					  const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
 | 
				
			||||||
    assetInteractionStore;
 | 
					    assetInteractionStore;
 | 
				
			||||||
  const viewport: Viewport = { width: 0, height: 0 };
 | 
					  const viewport: Viewport = { width: 0, height: 0 };
 | 
				
			||||||
  let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
 | 
					  let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets } = assetViewingStore;
 | 
				
			||||||
  let element: HTMLElement;
 | 
					  let element: HTMLElement;
 | 
				
			||||||
  let showShortcuts = false;
 | 
					  let showShortcuts = false;
 | 
				
			||||||
  let showSkeleton = true;
 | 
					  let showSkeleton = true;
 | 
				
			||||||
@ -141,8 +141,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const handlePrevious = async () => {
 | 
					  const handlePrevious = async () => {
 | 
				
			||||||
    const previousAsset = await assetStore.getPreviousAssetId($viewingAsset.id);
 | 
					    const previousAsset = await assetStore.getPreviousAssetId($viewingAsset.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (previousAsset) {
 | 
					    if (previousAsset) {
 | 
				
			||||||
      await assetViewingStore.setAssetId(previousAsset);
 | 
					      const preloadId = await assetStore.getPreviousAssetId(previousAsset);
 | 
				
			||||||
 | 
					      preloadId
 | 
				
			||||||
 | 
					        ? await assetViewingStore.setAssetId(previousAsset, [preloadId])
 | 
				
			||||||
 | 
					        : await assetViewingStore.setAssetId(previousAsset);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return !!previousAsset;
 | 
					    return !!previousAsset;
 | 
				
			||||||
@ -150,8 +154,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const handleNext = async () => {
 | 
					  const handleNext = async () => {
 | 
				
			||||||
    const nextAsset = await assetStore.getNextAssetId($viewingAsset.id);
 | 
					    const nextAsset = await assetStore.getNextAssetId($viewingAsset.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (nextAsset) {
 | 
					    if (nextAsset) {
 | 
				
			||||||
      await assetViewingStore.setAssetId(nextAsset);
 | 
					      const preloadId = await assetStore.getNextAssetId(nextAsset);
 | 
				
			||||||
 | 
					      preloadId
 | 
				
			||||||
 | 
					        ? await assetViewingStore.setAssetId(nextAsset, [preloadId])
 | 
				
			||||||
 | 
					        : await assetViewingStore.setAssetId(nextAsset);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return !!nextAsset;
 | 
					    return !!nextAsset;
 | 
				
			||||||
@ -455,6 +463,7 @@
 | 
				
			|||||||
        {withStacked}
 | 
					        {withStacked}
 | 
				
			||||||
        {assetStore}
 | 
					        {assetStore}
 | 
				
			||||||
        asset={$viewingAsset}
 | 
					        asset={$viewingAsset}
 | 
				
			||||||
 | 
					        preloadAssets={$preloadAssets}
 | 
				
			||||||
        {isShared}
 | 
					        {isShared}
 | 
				
			||||||
        {album}
 | 
					        {album}
 | 
				
			||||||
        on:previous={handlePrevious}
 | 
					        on:previous={handlePrevious}
 | 
				
			||||||
 | 
				
			|||||||
@ -4,10 +4,23 @@ import { writable } from 'svelte/store';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
function createAssetViewingStore() {
 | 
					function createAssetViewingStore() {
 | 
				
			||||||
  const viewingAssetStoreState = writable<AssetResponseDto>();
 | 
					  const viewingAssetStoreState = writable<AssetResponseDto>();
 | 
				
			||||||
 | 
					  const preloadAssets = writable<AssetResponseDto[]>([]);
 | 
				
			||||||
  const viewState = writable<boolean>(false);
 | 
					  const viewState = writable<boolean>(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const setAssetId = async (id: string) => {
 | 
					  const setAssetId = async (id: string, preloadIds?: string[]) => {
 | 
				
			||||||
    const data = await getAssetInfo({ id, key: getKey() });
 | 
					    const data = await getAssetInfo({ id, key: getKey() });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (preloadIds) {
 | 
				
			||||||
 | 
					      const preloadList = [];
 | 
				
			||||||
 | 
					      for (const preloadId of preloadIds) {
 | 
				
			||||||
 | 
					        if (preloadId) {
 | 
				
			||||||
 | 
					          const preloadAsset = await getAssetInfo({ id: preloadId, key: getKey() });
 | 
				
			||||||
 | 
					          preloadList.push(preloadAsset);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      preloadAssets.set(preloadList);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    viewingAssetStoreState.set(data);
 | 
					    viewingAssetStoreState.set(data);
 | 
				
			||||||
    viewState.set(true);
 | 
					    viewState.set(true);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
@ -20,6 +33,9 @@ function createAssetViewingStore() {
 | 
				
			|||||||
    asset: {
 | 
					    asset: {
 | 
				
			||||||
      subscribe: viewingAssetStoreState.subscribe,
 | 
					      subscribe: viewingAssetStoreState.subscribe,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    preloadAssets: {
 | 
				
			||||||
 | 
					      subscribe: preloadAssets.subscribe,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    isViewing: {
 | 
					    isViewing: {
 | 
				
			||||||
      subscribe: viewState.subscribe,
 | 
					      subscribe: viewState.subscribe,
 | 
				
			||||||
      set: viewState.set,
 | 
					      set: viewState.set,
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user