mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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>
This commit is contained in:
		| @@ -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" /> | ||||
|  | ||||
|   | ||||
| @@ -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,13 +281,78 @@ | ||||
|       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"> | ||||
|     {#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} | ||||
| @@ -277,6 +360,7 @@ | ||||
|         showZoomButton={asset.type === AssetTypeEnum.Image} | ||||
|         showMotionPlayButton={!!asset.livePhotoVideoId} | ||||
|         showDownloadButton={shouldShowDownloadButton} | ||||
|         showSlideshow={!!assetStore} | ||||
|         on:goBack={closeViewer} | ||||
|         on:showDetail={showDetailInfoHandler} | ||||
|         on:download={() => downloadFile(asset)} | ||||
| @@ -289,10 +373,12 @@ | ||||
|         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" | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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}%`} /> | ||||
		Reference in New Issue
	
	Block a user