mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01: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">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
import { clickOutside } from '$lib/utils/click-outside';
|
import { clickOutside } from '$lib/utils/click-outside';
|
||||||
import { AssetJobName, AssetResponseDto, AssetTypeEnum, api } from '@api';
|
import { AssetJobName, AssetResponseDto, AssetTypeEnum, api } from '@api';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
@ -11,14 +12,13 @@
|
||||||
import Heart from 'svelte-material-icons/Heart.svelte';
|
import Heart from 'svelte-material-icons/Heart.svelte';
|
||||||
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
|
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
|
||||||
import InformationOutline from 'svelte-material-icons/InformationOutline.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 MagnifyMinusOutline from 'svelte-material-icons/MagnifyMinusOutline.svelte';
|
||||||
|
import MagnifyPlusOutline from 'svelte-material-icons/MagnifyPlusOutline.svelte';
|
||||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
||||||
import MenuOption from '../shared-components/context-menu/menu-option.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 asset: AssetResponseDto;
|
||||||
export let showCopyButton: boolean;
|
export let showCopyButton: boolean;
|
||||||
|
@ -26,10 +26,11 @@
|
||||||
export let showMotionPlayButton: boolean;
|
export let showMotionPlayButton: boolean;
|
||||||
export let isMotionPhotoPlaying = false;
|
export let isMotionPhotoPlaying = false;
|
||||||
export let showDownloadButton: boolean;
|
export let showDownloadButton: boolean;
|
||||||
|
export let showSlideshow = false;
|
||||||
|
|
||||||
const isOwner = asset.ownerId === $page.data.user?.id;
|
const isOwner = asset.ownerId === $page.data.user?.id;
|
||||||
|
|
||||||
type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob';
|
type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob' | 'playSlideShow';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
goBack: void;
|
goBack: void;
|
||||||
|
@ -44,6 +45,7 @@
|
||||||
addToSharedAlbum: void;
|
addToSharedAlbum: void;
|
||||||
asProfileImage: void;
|
asProfileImage: void;
|
||||||
runJob: AssetJobName;
|
runJob: AssetJobName;
|
||||||
|
playSlideShow: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let contextMenuPosition = { x: 0, y: 0 };
|
let contextMenuPosition = { x: 0, y: 0 };
|
||||||
|
@ -137,6 +139,9 @@
|
||||||
<CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More" />
|
<CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More" />
|
||||||
{#if isShowAssetOptions}
|
{#if isShowAssetOptions}
|
||||||
<ContextMenu {...contextMenuPosition} direction="left">
|
<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('addToAlbum')} text="Add to Album" />
|
||||||
<MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
|
<MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
|
||||||
|
|
||||||
|
|
|
@ -16,13 +16,17 @@
|
||||||
import { ProjectionType } from '$lib/constants';
|
import { ProjectionType } from '$lib/constants';
|
||||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||||
import ProfileImageCropper from '../shared-components/profile-image-cropper.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 { isShowDetail } from '$lib/stores/preferences.store';
|
||||||
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
|
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
|
||||||
import NavigationArea from './navigation-area.svelte';
|
import NavigationArea from './navigation-area.svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import type { AssetStore } from '$lib/stores/assets.store';
|
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 assetStore: AssetStore | null = null;
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
|
@ -47,6 +51,7 @@
|
||||||
let isShowProfileImageCrop = false;
|
let isShowProfileImageCrop = false;
|
||||||
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
|
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
|
||||||
let canCopyImagesToClipboard: boolean;
|
let canCopyImagesToClipboard: boolean;
|
||||||
|
|
||||||
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key, keyInfo.shiftKey);
|
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key, keyInfo.shiftKey);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
@ -125,12 +130,25 @@
|
||||||
|
|
||||||
const closeViewer = () => dispatch('close');
|
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();
|
e?.stopPropagation();
|
||||||
dispatch('next');
|
dispatch('next');
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateAssetBackward = (e?: Event) => {
|
const navigateAssetBackward = (e?: Event) => {
|
||||||
|
if (isSlideshowMode && progressBar) {
|
||||||
|
progressBar.restart(true);
|
||||||
|
}
|
||||||
|
|
||||||
e?.stopPropagation();
|
e?.stopPropagation();
|
||||||
dispatch('previous');
|
dispatch('previous');
|
||||||
};
|
};
|
||||||
|
@ -263,13 +281,78 @@
|
||||||
handleError(error, `Unable to submit job`);
|
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>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
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"
|
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">
|
<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
|
<AssetViewerNavBar
|
||||||
{asset}
|
{asset}
|
||||||
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
||||||
|
@ -277,6 +360,7 @@
|
||||||
showZoomButton={asset.type === AssetTypeEnum.Image}
|
showZoomButton={asset.type === AssetTypeEnum.Image}
|
||||||
showMotionPlayButton={!!asset.livePhotoVideoId}
|
showMotionPlayButton={!!asset.livePhotoVideoId}
|
||||||
showDownloadButton={shouldShowDownloadButton}
|
showDownloadButton={shouldShowDownloadButton}
|
||||||
|
showSlideshow={!!assetStore}
|
||||||
on:goBack={closeViewer}
|
on:goBack={closeViewer}
|
||||||
on:showDetail={showDetailInfoHandler}
|
on:showDetail={showDetailInfoHandler}
|
||||||
on:download={() => downloadFile(asset)}
|
on:download={() => downloadFile(asset)}
|
||||||
|
@ -289,10 +373,12 @@
|
||||||
on:toggleArchive={toggleArchive}
|
on:toggleArchive={toggleArchive}
|
||||||
on:asProfileImage={() => (isShowProfileImageCrop = true)}
|
on:asProfileImage={() => (isShowProfileImageCrop = true)}
|
||||||
on:runJob={({ detail: job }) => handleRunJob(job)}
|
on:runJob={({ detail: job }) => handleRunJob(job)}
|
||||||
|
on:playSlideShow={handlePlaySlideshow}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</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">
|
<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>
|
<NavigationArea on:click={navigateAssetBackward}><ChevronLeft size="36" /></NavigationArea>
|
||||||
</div>
|
</div>
|
||||||
|
@ -323,18 +409,23 @@
|
||||||
<PhotoViewer {asset} on:close={closeViewer} />
|
<PhotoViewer {asset} on:close={closeViewer} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<VideoViewer assetId={asset.id} on:close={closeViewer} />
|
<VideoViewer
|
||||||
|
assetId={asset.id}
|
||||||
|
on:close={closeViewer}
|
||||||
|
on:onVideoEnded={handleVideoEnded}
|
||||||
|
on:onVideoStarted={handleVideoStarted}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</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">
|
<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>
|
<NavigationArea on:click={navigateAssetForward}><ChevronRight size="36" /></NavigationArea>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $isShowDetail}
|
{#if !isSlideshowMode && $isShowDetail}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
export let assetId: string;
|
export let assetId: string;
|
||||||
|
|
||||||
let isVideoLoading = true;
|
let isVideoLoading = true;
|
||||||
const dispatch = createEventDispatcher<{ onVideoEnded: void }>();
|
const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
|
||||||
|
|
||||||
const handleCanPlay = async (event: Event) => {
|
const handleCanPlay = async (event: Event) => {
|
||||||
try {
|
try {
|
||||||
|
@ -17,6 +17,7 @@
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
await video.play();
|
await video.play();
|
||||||
video.muted = false;
|
video.muted = false;
|
||||||
|
dispatch('onVideoStarted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Unable to play video');
|
handleError(error, 'Unable to play video');
|
||||||
} finally {
|
} 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…
Add table
Reference in a new issue