diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index e4cceb664d..27597153c8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -29,25 +29,24 @@ 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 ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { featureFlags } from '$lib/stores/server-config.store'; import { - mdiChevronLeft, mdiHeartOutline, mdiHeart, mdiCommentOutline, + mdiChevronLeft, mdiChevronRight, - mdiClose, mdiImageBrokenVariant, - mdiPause, - mdiPlay, } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; import ActivityViewer from './activity-viewer.svelte'; + import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; + import SlideshowBar from './slideshow-bar.svelte'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; @@ -62,6 +61,14 @@ let reactions: ActivityResponseDto[] = []; + const { setAssetId } = assetViewingStore; + const { + restartProgress: restartSlideshowProgress, + stopProgress: stopSlideshowProgress, + slideshowShuffle, + slideshowState, + } = slideshowStore; + const dispatch = createEventDispatcher<{ archived: AssetResponseDto; unarchived: AssetResponseDto; @@ -82,6 +89,8 @@ let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; let shouldShowDetailButton = asset.hasMetadata; let canCopyImagesToClipboard: boolean; + let slideshowStateUnsubscribe: () => void; + let shuffleSlideshowUnsubscribe: () => void; let previewStackedAsset: AssetResponseDto | undefined; let isShowActivity = false; let isLiked: ActivityResponseDto | null = null; @@ -162,6 +171,23 @@ onMount(async () => { document.addEventListener('keydown', onKeyboardPress); + slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(asset.id); + handlePlaySlideshow(); + } else if (value === SlideshowState.StopSlideshow) { + handleStopSlideshow(); + } + }); + + shuffleSlideshowUnsubscribe = slideshowShuffle.subscribe((value) => { + if (value) { + slideshowHistory.reset(); + slideshowHistory.queue(asset.id); + } + }); + if (!sharedLink) { await getAllAlbums(); } @@ -185,6 +211,14 @@ if (browser) { document.removeEventListener('keydown', onKeyboardPress); } + + if (slideshowStateUnsubscribe) { + slideshowStateUnsubscribe(); + } + + if (shuffleSlideshowUnsubscribe) { + shuffleSlideshowUnsubscribe(); + } }); $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes @@ -263,11 +297,31 @@ const closeViewer = () => dispatch('close'); + const navigateAssetRandom = async () => { + if (!assetStore) { + return; + } + + const asset = await assetStore.getRandomAsset(); + if (!asset) { + return; + } + + slideshowHistory.queue(asset.id); + + setAssetId(asset.id); + $restartSlideshowProgress = true; + }; + const navigateAssetForward = async (e?: Event) => { - if (isSlideshowMode && assetStore && progressBar) { + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) { + return slideshowHistory.next() || navigateAssetRandom(); + } + + if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) { const hasNext = await assetStore.getNextAssetId(asset.id); if (hasNext) { - progressBar.restart(true); + $restartSlideshowProgress = true; } else { await handleStopSlideshow(); } @@ -278,8 +332,13 @@ }; const navigateAssetBackward = (e?: Event) => { - if (isSlideshowMode && progressBar) { - progressBar.restart(true); + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) { + slideshowHistory.previous(); + return; + } + + if ($slideshowState === SlideshowState.PlaySlideshow) { + $restartSlideshowProgress = true; } e?.stopPropagation(); @@ -427,19 +486,21 @@ * Slide show mode */ - let isSlideshowMode = false; let assetViewerHtmlElement: HTMLElement; - let progressBar: ProgressBar; - let progressBarStatus: ProgressBarStatus; + + const slideshowHistory = new SlideshowHistory((assetId: string) => { + setAssetId(assetId); + $restartSlideshowProgress = true; + }); const handleVideoStarted = () => { - if (isSlideshowMode) { - progressBar.restart(false); + if ($slideshowState === SlideshowState.PlaySlideshow) { + $stopSlideshowProgress = true; } }; const handleVideoEnded = async () => { - if (isSlideshowMode) { + if ($slideshowState === SlideshowState.PlaySlideshow) { await navigateAssetForward(); } }; @@ -449,19 +510,20 @@ await assetViewerHtmlElement.requestFullscreen(); } catch (error) { console.error('Error entering fullscreen', error); - } finally { - isSlideshowMode = true; + $slideshowState = SlideshowState.StopSlideshow; } }; const handleStopSlideshow = async () => { try { - await document.exitFullscreen(); + if (document.fullscreenElement) { + await document.exitFullscreen(); + } } catch (error) { console.error('Error exiting fullscreen', error); } finally { - isSlideshowMode = false; - progressBar.restart(false); + $stopSlideshowProgress = true; + $slideshowState = SlideshowState.None; } }; @@ -498,31 +560,10 @@
-
- {#if isSlideshowMode} - -
-
- - (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} - title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} - /> - - -
- -
- {:else} + + {#if $slideshowState === SlideshowState.None} +
(isShowProfileImageCrop = true)} on:runJob={({ detail: job }) => handleRunJob(job)} - on:playSlideShow={handlePlaySlideshow} + on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)} on:unstack={handleUnstack} /> - {/if} -
+
+ {/if} - {#if !isSlideshowMode && showNavigation} -
+ {#if $slideshowState === SlideshowState.None && showNavigation} +
{/if} + -
+
+ {#if $slideshowState != SlideshowState.None} +
+ ($slideshowState = SlideshowState.StopSlideshow)} + /> +
+ {/if} + {#if previewStackedAsset} {#key previewStackedAsset.id} {#if previewStackedAsset.type === AssetTypeEnum.Image} @@ -603,7 +655,7 @@ on:onVideoStarted={handleVideoStarted} /> {/if} - {#if isShared} + {#if $slideshowState === SlideshowState.None && isShared}
- - - {#if !isSlideshowMode && showNavigation} -
+ {#if $slideshowState === SlideshowState.None && showNavigation} +
{/if} - {#if !isSlideshowMode && $isShowDetail} + {#if $slideshowState === SlideshowState.None && $isShowDetail}
+ import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; + import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; + import { slideshowStore } from '$lib/stores/slideshow.store'; + import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { + mdiChevronLeft, + mdiChevronRight, + mdiClose, + mdiPause, + mdiPlay, + mdiShuffle, + mdiShuffleDisabled, + } from '@mdi/js'; + + const { slideshowShuffle } = slideshowStore; + const { restartProgress, stopProgress } = slideshowStore; + + let progressBarStatus: ProgressBarStatus; + let progressBar: ProgressBar; + + let unsubscribeRestart: () => void; + let unsubscribeStop: () => void; + + const dispatch = createEventDispatcher<{ + next: void; + prev: void; + close: void; + }>(); + + onMount(() => { + unsubscribeRestart = restartProgress.subscribe((value) => { + if (value) { + progressBar.restart(value); + } + }); + + unsubscribeStop = stopProgress.subscribe((value) => { + if (value) { + progressBar.restart(false); + } + }); + }); + + onDestroy(() => { + if (unsubscribeRestart) { + unsubscribeRestart(); + } + + if (unsubscribeStop) { + unsubscribeStop(); + } + }); + + +
+ dispatch('close')} title="Exit Slideshow" /> + {#if $slideshowShuffle} + ($slideshowShuffle = false)} title="Shuffle" /> + {:else} + ($slideshowShuffle = true)} title="No shuffle" /> + {/if} + (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} + title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} + /> + dispatch('prev')} title="Previous" /> + dispatch('next')} title="Next" /> +
+ + dispatch('next')} + duration={5000} +/> diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 431019f4a3..fa5c1ccdf7 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -304,6 +304,19 @@ export class AssetStore { return this.assetToBucket[assetId]?.bucketIndex ?? null; } + async getRandomAsset(): Promise { + const bucket = this.buckets[Math.floor(Math.random() * this.buckets.length)] || null; + if (!bucket) { + return null; + } + + if (bucket.assets.length === 0) { + await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + } + + return bucket.assets[Math.floor(Math.random() * bucket.assets.length)] || null; + } + updateAsset(_asset: AssetResponseDto) { const asset = this.assets.find((asset) => asset.id === _asset.id); if (!asset) { diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts new file mode 100644 index 0000000000..45b570c2e2 --- /dev/null +++ b/web/src/lib/stores/slideshow.store.ts @@ -0,0 +1,45 @@ +import { persisted } from 'svelte-local-storage-store'; +import { writable } from 'svelte/store'; + +export enum SlideshowState { + PlaySlideshow = 'play-slideshow', + StopSlideshow = 'stop-slideshow', + None = 'none', +} + +function createSlideshowStore() { + const restartState = writable(false); + const stopState = writable(false); + + const slideshowShuffle = persisted('slideshow-shuffle', true); + const slideshowState = writable(SlideshowState.None); + + return { + restartProgress: { + subscribe: restartState.subscribe, + set: (value: boolean) => { + // Trigger an action whenever the restartProgress is set to true. Automatically + // reset the restart state after that + if (value) { + restartState.set(true); + restartState.set(false); + } + }, + }, + stopProgress: { + subscribe: stopState.subscribe, + set: (value: boolean) => { + // Trigger an action whenever the stopProgress is set to true. Automatically + // reset the stop state after that + if (value) { + stopState.set(true); + stopState.set(false); + } + }, + }, + slideshowShuffle, + slideshowState, + }; +} + +export const slideshowStore = createSlideshowStore(); diff --git a/web/src/lib/utils/slideshow-history.ts b/web/src/lib/utils/slideshow-history.ts new file mode 100644 index 0000000000..8b34359d0b --- /dev/null +++ b/web/src/lib/utils/slideshow-history.ts @@ -0,0 +1,40 @@ +export class SlideshowHistory { + private history: string[] = []; + private index = 0; + + constructor(private onChange: (assetId: string) => void) {} + + reset() { + this.history = []; + this.index = 0; + } + + queue(assetId: string) { + this.history.push(assetId); + + // If we were at the end of the slideshow history, move the index to the new end + if (this.index === this.history.length - 2) { + this.index++; + } + } + + next(): boolean { + if (this.index === this.history.length - 1) { + return false; + } + + this.index++; + this.onChange(this.history[this.index]); + return true; + } + + previous(): boolean { + if (this.index === 0) { + return false; + } + + this.index--; + this.onChange(this.history[this.index]); + return true; + } +} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 9b81c377c4..1697af5221 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -29,6 +29,7 @@ import { AppRoute, dateFormats } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { AssetStore } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; import { downloadArchive } from '$lib/utils/asset-utils'; @@ -52,7 +53,8 @@ export let data: PageData; - let { isViewing: showAssetViewer } = assetViewingStore; + let { isViewing: showAssetViewer, setAssetId } = assetViewingStore; + let { slideshowState, slideshowShuffle } = slideshowStore; let album = data.album; $: album = data.album; @@ -108,6 +110,14 @@ } }); + const handleStartSlideshow = async () => { + const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; + if (asset) { + setAssetId(asset.id); + $slideshowState = SlideshowState.PlaySlideshow; + } + }; + const handleEscape = () => { if (viewMode === ViewMode.SELECT_USERS) { viewMode = ViewMode.VIEW; @@ -365,6 +375,9 @@ {#if viewMode === ViewMode.ALBUM_OPTIONS} + {#if album.assetCount !== 0} + + {/if} (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> {/if}