1
0
Fork 0
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:
Alex 2023-08-25 18:20:45 -05:00 committed by GitHub
parent 59bb727636
commit e18a9f84a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 210 additions and 30 deletions

View file

@ -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" />

View file

@ -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"

View file

@ -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 {

View file

@ -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}%`} />