mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +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:
parent
59bb727636
commit
e18a9f84a4
4 changed files with 210 additions and 30 deletions
|
@ -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,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"
|
||||
|
|
|
@ -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}%`} />
|
Loading…
Reference in a new issue