Prechádzať zdrojové kódy

use animation frames for memory autoplay (#2771)

The current implementation mixes intervals and animation frames, which is a
little convoluted. The use of intervals means that the animation is not going
to be smooth and may have strange behaviour when the window is moved to the
background. It's possible that the current animation frames could pile up and
run all at once which would be undesirable.

Moving everything into animation frames means the code is simpler and easier to
reason about. It should also be more performant and less buggy.

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Thomas 2 rokov pred
rodič
commit
43ffcf7e8f

+ 104 - 136
web/src/lib/components/memory-page/memory-viewer.svelte

@@ -1,8 +1,9 @@
 <script lang="ts">
 <script lang="ts">
+	import { browser } from '$app/environment';
 	import { memoryStore } from '$lib/stores/memory.store';
 	import { memoryStore } from '$lib/stores/memory.store';
 	import { DateTime } from 'luxon';
 	import { DateTime } from 'luxon';
-	import { onMount } from 'svelte';
-	import { MemoryLaneResponseDto, api } from '@api';
+	import { onDestroy, onMount } from 'svelte';
+	import { api } from '@api';
 	import { goto } from '$app/navigation';
 	import { goto } from '$app/navigation';
 	import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 	import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 	import Play from 'svelte-material-icons/Play.svelte';
 	import Play from 'svelte-material-icons/Play.svelte';
@@ -19,15 +20,31 @@
 	import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
 	import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
 	import { fade } from 'svelte/transition';
 	import { fade } from 'svelte/transition';
 
 
-	let currentIndex = 0;
-	let currentMemory: MemoryLaneResponseDto;
-	let nextMemory: MemoryLaneResponseDto;
-	let lastMemory: MemoryLaneResponseDto;
+	let memoryIndex: number;
+	$: {
+		const index = parseInt($page.url.searchParams.get('memory') ?? '') || 0;
+		memoryIndex = index < $memoryStore?.length ? index : 0;
+	}
+
+	$: previousMemory = $memoryStore?.[memoryIndex - 1] || null;
+	$: currentMemory = $memoryStore?.[memoryIndex] || null;
+	$: nextMemory = $memoryStore?.[memoryIndex + 1] || null;
+
+	let assetIndex: number;
+	$: {
+		const index = parseInt($page.url.searchParams.get('asset') ?? '') || 0;
+		assetIndex = index < currentMemory?.assets.length ? index : 0;
+	}
+
+	$: previousAsset = currentMemory?.assets[assetIndex - 1] || null;
+	$: currentAsset = currentMemory?.assets[assetIndex] || null;
+	$: nextAsset = currentMemory?.assets[assetIndex + 1] || null;
 
 
-	let lastIndex = 0;
-	let nextIndex = 0;
-	$: showNextMemory = nextIndex <= $memoryStore?.length - 1;
-	$: showPreviousMemory = currentIndex != 0;
+	$: canAdvance = !!(nextMemory || nextAsset);
+
+	$: if (!canAdvance && browser) {
+		pause();
+	}
 
 
 	let memoryGallery: HTMLElement;
 	let memoryGallery: HTMLElement;
 	let memoryWrapper: HTMLElement;
 	let memoryWrapper: HTMLElement;
@@ -40,123 +57,69 @@
 			});
 			});
 			$memoryStore = data;
 			$memoryStore = data;
 		}
 		}
+	});
 
 
-		const queryIndex = $page.url.searchParams.get('index');
-		if (queryIndex != null) {
-			currentIndex = parseInt(queryIndex);
-			if (isNaN(currentIndex) || currentIndex > $memoryStore.length - 1) {
-				currentIndex = 0;
-			}
-		}
+	onDestroy(() => browser && pause());
 
 
-		currentMemory = $memoryStore[currentIndex];
+	const toPreviousMemory = () => previousMemory && goto(`?memory=${memoryIndex - 1}`);
 
 
-		nextIndex = currentIndex + 1;
-		nextMemory = $memoryStore[nextIndex];
+	const toNextMemory = () => nextMemory && goto(`?memory=${memoryIndex + 1}`);
 
 
-		if (currentIndex > 0) {
-			lastMemory = $memoryStore[lastIndex];
-		}
-	});
+	const toPreviousAsset = () =>
+		previousAsset ? goto(`?memory=${memoryIndex}&asset=${assetIndex - 1}`) : toPreviousMemory();
 
 
-	const toNextMemory = (): boolean => {
-		if (showNextMemory) {
-			resetAutoPlay();
+	const toNextAsset = () =>
+		nextAsset ? goto(`?memory=${memoryIndex}&asset=${assetIndex + 1}`) : toNextMemory();
 
 
-			currentIndex++;
-			nextIndex = currentIndex + 1;
-			lastIndex = currentIndex - 1;
+	const duration = 5000; // 5 seconds
 
 
-			currentMemory = $memoryStore[currentIndex];
-			nextMemory = $memoryStore[nextIndex];
-			lastMemory = $memoryStore[lastIndex];
+	let paused = true;
+	let progress = 0;
+	let animationFrameRequest: number;
+	let start: number | null = null;
 
 
-			return true;
-		}
+	const requestDraw = () => (animationFrameRequest = requestAnimationFrame(draw));
 
 
-		return false;
-	};
+	const draw = (now: number) => {
+		requestDraw();
 
 
-	const toPreviousMemory = () => {
-		if (showPreviousMemory) {
-			resetAutoPlay();
+		start ??= now - progress * duration;
 
 
-			currentIndex--;
-			nextIndex = currentIndex + 1;
-			lastIndex = currentIndex - 1;
+		const elapsed = now - start;
+		progress = Math.min(1, elapsed / duration);
 
 
-			currentMemory = $memoryStore[currentIndex];
-			nextMemory = $memoryStore[nextIndex];
-			lastMemory = $memoryStore[lastIndex];
+		if (progress !== 1) {
+			return;
 		}
 		}
-	};
 
 
-	let autoPlayInterval: NodeJS.Timeout;
-	let autoPlay = false;
-	let autoPlaySpeed = 5000;
-	let autoPlayProgress = 0;
-	let autoPlayIndex = 0;
-	let canPlayNext = true;
-
-	const toggleAutoPlay = () => {
-		autoPlay = !autoPlay;
-		if (autoPlay) {
-			autoPlayInterval = setInterval(() => {
-				if (!canPlayNext) return;
-
-				window.requestAnimationFrame(() => {
-					autoPlayProgress++;
-				});
-
-				if (autoPlayProgress > 100) {
-					autoPlayProgress = 0;
-					canPlayNext = false;
-					autoPlayTransition();
-				}
-			}, autoPlaySpeed / 100);
-		} else {
-			clearInterval(autoPlayInterval);
-		}
+		toNextAsset();
+		start = now;
 	};
 	};
 
 
-	const autoPlayTransition = () => {
-		if (autoPlayIndex < currentMemory.assets.length - 1) {
-			autoPlayIndex++;
-		} else {
-			const canAdvance = toNextMemory();
-			if (!canAdvance) {
-				autoPlay = false;
-				clearInterval(autoPlayInterval);
-				return;
-			}
+	const play = () => {
+		if (!canAdvance) {
+			return;
 		}
 		}
 
 
-		// Delay for nicer animation of the progress bar
-		setTimeout(() => {
-			canPlayNext = true;
-		}, 250);
+		paused = false;
+		requestDraw();
 	};
 	};
 
 
-	const resetAutoPlay = () => {
-		autoPlayIndex = 0;
-		autoPlayProgress = 0;
+	const pause = () => {
+		paused = true;
+		cancelAnimationFrame(animationFrameRequest);
+		resetStart();
 	};
 	};
 
 
-	const toNextCurrentAsset = () => {
-		autoPlayIndex++;
-
-		if (autoPlayIndex > currentMemory.assets.length - 1) {
-			toNextMemory();
-		}
+	const resetProgress = () => {
+		progress = 0;
+		resetStart();
 	};
 	};
 
 
-	const toPreviousCurrentAsset = () => {
-		autoPlayIndex--;
+	const resetStart = () => (start = null);
 
 
-		if (autoPlayIndex < 0) {
-			toPreviousMemory();
-		}
-	};
+	// Progress should be reset when the current memory or asset changes.
+	$: memoryIndex, assetIndex, resetProgress();
 </script>
 </script>
 
 
 <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
 <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
@@ -170,19 +133,20 @@
 
 
 			{#if !galleryInView}
 			{#if !galleryInView}
 				<div class="flex place-items-center place-content-center overflow-hidden gap-2">
 				<div class="flex place-items-center place-content-center overflow-hidden gap-2">
-					<CircleIconButton logo={autoPlay ? Pause : Play} forceDark on:click={toggleAutoPlay} />
+					<CircleIconButton
+						logo={paused ? Play : Pause}
+						forceDark
+						on:click={paused ? play : pause}
+					/>
 
 
 					<div class="relative w-full">
 					<div class="relative w-full">
 						<span class="absolute left-0 w-full h-[2px] bg-gray-500" />
 						<span class="absolute left-0 w-full h-[2px] bg-gray-500" />
-						<span
-							class="absolute left-0 h-[2px] bg-white transition-all"
-							style:width={`${autoPlayProgress}%`}
-						/>
+						<span class="absolute left-0 h-[2px] bg-white" style:width={`${progress * 100}%`} />
 					</div>
 					</div>
 
 
 					<div>
 					<div>
 						<p class="text-small">
 						<p class="text-small">
-							{autoPlayIndex + 1}/{currentMemory.assets.length}
+							{assetIndex + 1}/{currentMemory.assets.length}
 						</p>
 						</p>
 					</div>
 					</div>
 				</div>
 				</div>
@@ -211,28 +175,28 @@
 				<!-- PREVIOUS MEMORY -->
 				<!-- PREVIOUS MEMORY -->
 				<div
 				<div
 					class="rounded-2xl w-[20vw] h-1/2"
 					class="rounded-2xl w-[20vw] h-1/2"
-					class:opacity-25={showPreviousMemory}
-					class:opacity-0={!showPreviousMemory}
-					class:hover:opacity-70={showPreviousMemory}
+					class:opacity-25={previousMemory}
+					class:opacity-0={!previousMemory}
+					class:hover:opacity-70={previousMemory}
 				>
 				>
 					<button
 					<button
 						class="rounded-2xl h-full w-full relative"
 						class="rounded-2xl h-full w-full relative"
-						disabled={!showPreviousMemory}
+						disabled={!previousMemory}
 						on:click={toPreviousMemory}
 						on:click={toPreviousMemory}
 					>
 					>
 						<img
 						<img
 							class="rounded-2xl h-full w-full object-cover"
 							class="rounded-2xl h-full w-full object-cover"
-							src={showPreviousMemory && lastMemory
-								? api.getAssetThumbnailUrl(lastMemory.assets[0].id, 'JPEG')
+							src={previousMemory
+								? api.getAssetThumbnailUrl(previousMemory.assets[0].id, 'JPEG')
 								: noThumbnailUrl}
 								: noThumbnailUrl}
 							alt=""
 							alt=""
 							draggable="false"
 							draggable="false"
 						/>
 						/>
 
 
-						{#if showPreviousMemory}
+						{#if previousMemory}
 							<div class="absolute right-4 bottom-4 text-white text-left">
 							<div class="absolute right-4 bottom-4 text-white text-left">
 								<p class="font-semibold text-xs text-gray-200">PREVIOUS</p>
 								<p class="font-semibold text-xs text-gray-200">PREVIOUS</p>
-								<p class="text-xl">{lastMemory.title}</p>
+								<p class="text-xl">{previousMemory.title}</p>
 							</div>
 							</div>
 						{/if}
 						{/if}
 					</button>
 					</button>
@@ -247,29 +211,33 @@
 						<div class="absolute h-full flex justify-between w-full">
 						<div class="absolute h-full flex justify-between w-full">
 							<div class="flex h-full flex-col place-content-center place-items-center ml-4">
 							<div class="flex h-full flex-col place-content-center place-items-center ml-4">
 								<div class="inline-block">
 								<div class="inline-block">
-									<CircleIconButton
-										logo={ChevronLeft}
-										backgroundColor="#202123"
-										on:click={toPreviousCurrentAsset}
-									/>
+									{#if previousMemory || previousAsset}
+										<CircleIconButton
+											logo={ChevronLeft}
+											backgroundColor="#202123"
+											on:click={toPreviousAsset}
+										/>
+									{/if}
 								</div>
 								</div>
 							</div>
 							</div>
 							<div class="flex h-full flex-col place-content-center place-items-center mr-4">
 							<div class="flex h-full flex-col place-content-center place-items-center mr-4">
 								<div class="inline-block">
 								<div class="inline-block">
-									<CircleIconButton
-										logo={ChevronRight}
-										backgroundColor="#202123"
-										on:click={toNextCurrentAsset}
-									/>
+									{#if canAdvance}
+										<CircleIconButton
+											logo={ChevronRight}
+											backgroundColor="#202123"
+											on:click={toNextAsset}
+										/>
+									{/if}
 								</div>
 								</div>
 							</div>
 							</div>
 						</div>
 						</div>
 
 
-						{#key currentMemory.assets[autoPlayIndex].id}
+						{#key currentAsset.id}
 							<img
 							<img
 								transition:fade|local
 								transition:fade|local
 								class="rounded-2xl w-full h-full object-contain transition-all"
 								class="rounded-2xl w-full h-full object-contain transition-all"
-								src={api.getAssetThumbnailUrl(currentMemory.assets[autoPlayIndex].id, 'JPEG')}
+								src={api.getAssetThumbnailUrl(currentAsset.id, 'JPEG')}
 								alt=""
 								alt=""
 								draggable="false"
 								draggable="false"
 							/>
 							/>
@@ -282,8 +250,8 @@
 								)}
 								)}
 							</p>
 							</p>
 							<p>
 							<p>
-								{currentMemory.assets[autoPlayIndex].exifInfo?.city || ''}
-								{currentMemory.assets[autoPlayIndex].exifInfo?.country || ''}
+								{currentAsset.exifInfo?.city || ''}
+								{currentAsset.exifInfo?.country || ''}
 							</p>
 							</p>
 						</div>
 						</div>
 					</div>
 					</div>
@@ -292,25 +260,25 @@
 				<!-- NEXT MEMORY -->
 				<!-- NEXT MEMORY -->
 				<div
 				<div
 					class="rounded-xl w-[20vw] h-1/2"
 					class="rounded-xl w-[20vw] h-1/2"
-					class:opacity-25={showNextMemory}
-					class:opacity-0={!showNextMemory}
-					class:hover:opacity-70={showNextMemory}
+					class:opacity-25={nextMemory}
+					class:opacity-0={!nextMemory}
+					class:hover:opacity-70={nextMemory}
 				>
 				>
 					<button
 					<button
 						class="rounded-2xl h-full w-full relative"
 						class="rounded-2xl h-full w-full relative"
 						on:click={toNextMemory}
 						on:click={toNextMemory}
-						disabled={!showNextMemory}
+						disabled={!nextMemory}
 					>
 					>
 						<img
 						<img
 							class="rounded-2xl h-full w-full object-cover"
 							class="rounded-2xl h-full w-full object-cover"
-							src={showNextMemory
+							src={nextMemory
 								? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG')
 								? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG')
 								: noThumbnailUrl}
 								: noThumbnailUrl}
 							alt=""
 							alt=""
 							draggable="false"
 							draggable="false"
 						/>
 						/>
 
 
-						{#if showNextMemory}
+						{#if nextMemory}
 							<div class="absolute left-4 bottom-4 text-white text-left">
 							<div class="absolute left-4 bottom-4 text-white text-left">
 								<p class="font-semibold text-xs text-gray-200">UP NEXT</p>
 								<p class="font-semibold text-xs text-gray-200">UP NEXT</p>
 								<p class="text-xl">{nextMemory.title}</p>
 								<p class="text-xl">{nextMemory.title}</p>

+ 1 - 1
web/src/lib/components/photos-page/memory-lane.svelte

@@ -70,7 +70,7 @@
 			{#each memoryLane as memory, i (memory.title)}
 			{#each memoryLane as memory, i (memory.title)}
 				<button
 				<button
 					class="memory-card relative inline-block mr-8 rounded-xl aspect-video h-[215px]"
 					class="memory-card relative inline-block mr-8 rounded-xl aspect-video h-[215px]"
-					on:click={() => goto(`/memory?index=${i}`)}
+					on:click={() => goto(`/memory?memory=${i}`)}
 				>
 				>
 					<img
 					<img
 						class="rounded-xl h-full w-full object-cover"
 						class="rounded-xl h-full w-full object-cover"