Bläddra i källkod

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 år sedan
förälder
incheckning
43ffcf7e8f

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

@@ -1,8 +1,9 @@
 <script lang="ts">
+	import { browser } from '$app/environment';
 	import { memoryStore } from '$lib/stores/memory.store';
 	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 ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 	import Play from 'svelte-material-icons/Play.svelte';
@@ -19,15 +20,31 @@
 	import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
 	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 memoryWrapper: HTMLElement;
@@ -40,123 +57,69 @@
 			});
 			$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>
 
 <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
@@ -170,19 +133,20 @@
 
 			{#if !galleryInView}
 				<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">
 						<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>
 						<p class="text-small">
-							{autoPlayIndex + 1}/{currentMemory.assets.length}
+							{assetIndex + 1}/{currentMemory.assets.length}
 						</p>
 					</div>
 				</div>
@@ -211,28 +175,28 @@
 				<!-- PREVIOUS MEMORY -->
 				<div
 					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
 						class="rounded-2xl h-full w-full relative"
-						disabled={!showPreviousMemory}
+						disabled={!previousMemory}
 						on:click={toPreviousMemory}
 					>
 						<img
 							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}
 							alt=""
 							draggable="false"
 						/>
 
-						{#if showPreviousMemory}
+						{#if previousMemory}
 							<div class="absolute right-4 bottom-4 text-white text-left">
 								<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>
 						{/if}
 					</button>
@@ -247,29 +211,33 @@
 						<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="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 class="flex h-full flex-col place-content-center place-items-center mr-4">
 								<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>
 
-						{#key currentMemory.assets[autoPlayIndex].id}
+						{#key currentAsset.id}
 							<img
 								transition:fade|local
 								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=""
 								draggable="false"
 							/>
@@ -282,8 +250,8 @@
 								)}
 							</p>
 							<p>
-								{currentMemory.assets[autoPlayIndex].exifInfo?.city || ''}
-								{currentMemory.assets[autoPlayIndex].exifInfo?.country || ''}
+								{currentAsset.exifInfo?.city || ''}
+								{currentAsset.exifInfo?.country || ''}
 							</p>
 						</div>
 					</div>
@@ -292,25 +260,25 @@
 				<!-- NEXT MEMORY -->
 				<div
 					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
 						class="rounded-2xl h-full w-full relative"
 						on:click={toNextMemory}
-						disabled={!showNextMemory}
+						disabled={!nextMemory}
 					>
 						<img
 							class="rounded-2xl h-full w-full object-cover"
-							src={showNextMemory
+							src={nextMemory
 								? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG')
 								: noThumbnailUrl}
 							alt=""
 							draggable="false"
 						/>
 
-						{#if showNextMemory}
+						{#if nextMemory}
 							<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="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)}
 				<button
 					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
 						class="rounded-xl h-full w-full object-cover"