浏览代码

feat(web): slideshow mode (#3813)

* slideshow

slideshow for main screen

Added control buttons

update

close detail panel window sif opened

format

5 seconds

remove unused files

handle video player

format

* fix: restrict slideshow to timeline views

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Alex 1 年之前
父节点
当前提交
e18a9f84a4

+ 8 - 3
web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

@@ -1,5 +1,6 @@
 <script lang="ts">
   import { page } from '$app/stores';
+  import { photoZoomState } from '$lib/stores/zoom-image.store';
   import { clickOutside } from '$lib/utils/click-outside';
   import { AssetJobName, AssetResponseDto, AssetTypeEnum, api } from '@api';
   import { createEventDispatcher } from 'svelte';
@@ -11,14 +12,13 @@
   import Heart from 'svelte-material-icons/Heart.svelte';
   import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
   import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
-  import MagnifyPlusOutline from 'svelte-material-icons/MagnifyPlusOutline.svelte';
   import MagnifyMinusOutline from 'svelte-material-icons/MagnifyMinusOutline.svelte';
+  import MagnifyPlusOutline from 'svelte-material-icons/MagnifyPlusOutline.svelte';
   import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
   import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
   import MenuOption from '../shared-components/context-menu/menu-option.svelte';
-  import { photoZoomState } from '$lib/stores/zoom-image.store';
 
   export let asset: AssetResponseDto;
   export let showCopyButton: boolean;
@@ -26,10 +26,11 @@
   export let showMotionPlayButton: boolean;
   export let isMotionPhotoPlaying = false;
   export let showDownloadButton: boolean;
+  export let showSlideshow = false;
 
   const isOwner = asset.ownerId === $page.data.user?.id;
 
-  type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob';
+  type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob' | 'playSlideShow';
 
   const dispatch = createEventDispatcher<{
     goBack: void;
@@ -44,6 +45,7 @@
     addToSharedAlbum: void;
     asProfileImage: void;
     runJob: AssetJobName;
+    playSlideShow: void;
   }>();
 
   let contextMenuPosition = { x: 0, y: 0 };
@@ -137,6 +139,9 @@
         <CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More" />
         {#if isShowAssetOptions}
           <ContextMenu {...contextMenuPosition} direction="left">
+            {#if showSlideshow}
+              <MenuOption on:click={() => onMenuClick('playSlideShow')} text="Slideshow" />
+            {/if}
             <MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
             <MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
 

+ 117 - 26
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -16,13 +16,17 @@
   import { ProjectionType } from '$lib/constants';
   import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
   import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
-
+  import Pause from 'svelte-material-icons/Pause.svelte';
+  import Play from 'svelte-material-icons/Play.svelte';
   import { isShowDetail } from '$lib/stores/preferences.store';
   import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
   import NavigationArea from './navigation-area.svelte';
   import { browser } from '$app/environment';
   import { handleError } from '$lib/utils/handle-error';
   import type { AssetStore } from '$lib/stores/assets.store';
+  import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
+  import Close from 'svelte-material-icons/Close.svelte';
+  import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
 
   export let assetStore: AssetStore | null = null;
   export let asset: AssetResponseDto;
@@ -47,6 +51,7 @@
   let isShowProfileImageCrop = false;
   let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
   let canCopyImagesToClipboard: boolean;
+
   const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key, keyInfo.shiftKey);
 
   onMount(async () => {
@@ -125,12 +130,25 @@
 
   const closeViewer = () => dispatch('close');
 
-  const navigateAssetForward = (e?: Event) => {
+  const navigateAssetForward = async (e?: Event) => {
+    if (isSlideshowMode && assetStore && progressBar) {
+      const hasNext = await assetStore.getNextAssetId(asset.id);
+      if (hasNext) {
+        progressBar.restart(true);
+      } else {
+        handleStopSlideshow();
+      }
+    }
+
     e?.stopPropagation();
     dispatch('next');
   };
 
   const navigateAssetBackward = (e?: Event) => {
+    if (isSlideshowMode && progressBar) {
+      progressBar.restart(true);
+    }
+
     e?.stopPropagation();
     dispatch('previous');
   };
@@ -263,36 +281,104 @@
       handleError(error, `Unable to submit job`);
     }
   };
+
+  /**
+   * Slide show mode
+   */
+
+  let isSlideshowMode = false;
+  let assetViewerHtmlElement: HTMLElement;
+  let progressBar: ProgressBar;
+  let progressBarStatus: ProgressBarStatus;
+
+  const handleVideoStarted = () => {
+    if (isSlideshowMode) {
+      progressBar.restart(false);
+    }
+  };
+
+  const handleVideoEnded = async () => {
+    if (isSlideshowMode) {
+      await navigateAssetForward();
+    }
+  };
+
+  const handlePlaySlideshow = async () => {
+    try {
+      await assetViewerHtmlElement.requestFullscreen();
+    } catch (error) {
+      console.error('Error entering fullscreen', error);
+    } finally {
+      isSlideshowMode = true;
+    }
+  };
+
+  const handleStopSlideshow = async () => {
+    try {
+      await document.exitFullscreen();
+    } catch (error) {
+      console.error('Error exiting fullscreen', error);
+    } finally {
+      isSlideshowMode = false;
+      progressBar.restart(false);
+    }
+  };
 </script>
 
 <section
   id="immich-asset-viewer"
   class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
+  bind:this={assetViewerHtmlElement}
 >
   <div class="z-[1000] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
-    <AssetViewerNavBar
-      {asset}
-      isMotionPhotoPlaying={shouldPlayMotionPhoto}
-      showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
-      showZoomButton={asset.type === AssetTypeEnum.Image}
-      showMotionPlayButton={!!asset.livePhotoVideoId}
-      showDownloadButton={shouldShowDownloadButton}
-      on:goBack={closeViewer}
-      on:showDetail={showDetailInfoHandler}
-      on:download={() => downloadFile(asset)}
-      on:delete={() => (isShowDeleteConfirmation = true)}
-      on:favorite={toggleFavorite}
-      on:addToAlbum={() => openAlbumPicker(false)}
-      on:addToSharedAlbum={() => openAlbumPicker(true)}
-      on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
-      on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
-      on:toggleArchive={toggleArchive}
-      on:asProfileImage={() => (isShowProfileImageCrop = true)}
-      on:runJob={({ detail: job }) => handleRunJob(job)}
-    />
+    {#if isSlideshowMode}
+      <!-- SlideShowController -->
+      <div class="flex">
+        <div class="m-4 flex gap-2">
+          <CircleIconButton logo={Close} on:click={handleStopSlideshow} title="Exit Slideshow" />
+          <CircleIconButton
+            logo={progressBarStatus === ProgressBarStatus.Paused ? Play : Pause}
+            on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
+            title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
+          />
+          <CircleIconButton logo={ChevronLeft} on:click={navigateAssetBackward} title="Previous" />
+          <CircleIconButton logo={ChevronRight} on:click={navigateAssetForward} title="Next" />
+        </div>
+        <ProgressBar
+          autoplay
+          bind:this={progressBar}
+          bind:status={progressBarStatus}
+          on:done={navigateAssetForward}
+          duration={5000}
+        />
+      </div>
+    {:else}
+      <AssetViewerNavBar
+        {asset}
+        isMotionPhotoPlaying={shouldPlayMotionPhoto}
+        showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
+        showZoomButton={asset.type === AssetTypeEnum.Image}
+        showMotionPlayButton={!!asset.livePhotoVideoId}
+        showDownloadButton={shouldShowDownloadButton}
+        showSlideshow={!!assetStore}
+        on:goBack={closeViewer}
+        on:showDetail={showDetailInfoHandler}
+        on:download={() => downloadFile(asset)}
+        on:delete={() => (isShowDeleteConfirmation = true)}
+        on:favorite={toggleFavorite}
+        on:addToAlbum={() => openAlbumPicker(false)}
+        on:addToSharedAlbum={() => openAlbumPicker(true)}
+        on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
+        on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
+        on:toggleArchive={toggleArchive}
+        on:asProfileImage={() => (isShowProfileImageCrop = true)}
+        on:runJob={({ detail: job }) => handleRunJob(job)}
+        on:playSlideShow={handlePlaySlideshow}
+      />
+    {/if}
   </div>
 
-  {#if showNavigation}
+  {#if !isSlideshowMode && showNavigation}
     <div class="column-span-1 z-[999] col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
       <NavigationArea on:click={navigateAssetBackward}><ChevronLeft size="36" /></NavigationArea>
     </div>
@@ -323,18 +409,23 @@
           <PhotoViewer {asset} on:close={closeViewer} />
         {/if}
       {:else}
-        <VideoViewer assetId={asset.id} on:close={closeViewer} />
+        <VideoViewer
+          assetId={asset.id}
+          on:close={closeViewer}
+          on:onVideoEnded={handleVideoEnded}
+          on:onVideoStarted={handleVideoStarted}
+        />
       {/if}
     {/key}
   </div>
 
-  {#if showNavigation}
+  {#if !isSlideshowMode && showNavigation}
     <div class="z-[999] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
       <NavigationArea on:click={navigateAssetForward}><ChevronRight size="36" /></NavigationArea>
     </div>
   {/if}
 
-  {#if $isShowDetail}
+  {#if !isSlideshowMode && $isShowDetail}
     <div
       transition:fly={{ duration: 150 }}
       id="detail-panel"

+ 2 - 1
web/src/lib/components/asset-viewer/video-viewer.svelte

@@ -9,7 +9,7 @@
   export let assetId: string;
 
   let isVideoLoading = true;
-  const dispatch = createEventDispatcher<{ onVideoEnded: void }>();
+  const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
 
   const handleCanPlay = async (event: Event) => {
     try {
@@ -17,6 +17,7 @@
       video.muted = true;
       await video.play();
       video.muted = false;
+      dispatch('onVideoStarted');
     } catch (error) {
       handleError(error, 'Unable to play video');
     } finally {

+ 83 - 0
web/src/lib/components/shared-components/progress-bar/progress-bar.svelte

@@ -0,0 +1,83 @@
+<script context="module" lang="ts">
+  export enum ProgressBarStatus {
+    Playing = 'playing',
+    Paused = 'paused',
+  }
+</script>
+
+<script lang="ts">
+  import { createEventDispatcher, onMount } from 'svelte';
+  import { tweened } from 'svelte/motion';
+
+  /**
+   * Autoplay on mount
+   * @default false
+   */
+  export let autoplay = false;
+
+  /**
+   * Duration in milliseconds
+   * @default 5000
+   */
+  export let duration = 5000;
+
+  /**
+   * Progress bar status
+   */
+  export let status: ProgressBarStatus = ProgressBarStatus.Paused;
+
+  let progress = tweened<number>(0, {
+    duration: (from: number, to: number) => (to ? duration * (to - from) : 0),
+  });
+
+  const dispatch = createEventDispatcher<{
+    done: void;
+    playing: void;
+    paused: void;
+  }>();
+
+  onMount(() => {
+    if (autoplay) {
+      play();
+    }
+  });
+
+  export const play = () => {
+    status = ProgressBarStatus.Playing;
+    dispatch('playing');
+    progress.set(1);
+  };
+
+  export const pause = () => {
+    status = ProgressBarStatus.Paused;
+    dispatch('paused');
+    progress.set($progress);
+  };
+
+  export const restart = (autoplay: boolean) => {
+    progress.set(0);
+
+    if (autoplay) {
+      play();
+    }
+  };
+
+  export const reset = () => {
+    status = ProgressBarStatus.Paused;
+    progress.set(0);
+  };
+
+  export const setDuration = (newDuration: number) => {
+    progress = tweened<number>(0, {
+      duration: (from: number, to: number) => (to ? newDuration * (to - from) : 0),
+    });
+  };
+
+  progress.subscribe((value) => {
+    if (value === 1) {
+      dispatch('done');
+    }
+  });
+</script>
+
+<span class="absolute left-0 h-[3px] bg-immich-primary shadow-2xl" style:width={`${$progress * 100}%`} />