memory-viewer.svelte 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. <script lang="ts">
  2. import { memoryStore } from '$lib/stores/memory.store';
  3. import { DateTime } from 'luxon';
  4. import { onMount } from 'svelte';
  5. import { api } from '@api';
  6. import { goto } from '$app/navigation';
  7. import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
  8. import Play from 'svelte-material-icons/Play.svelte';
  9. import Pause from 'svelte-material-icons/Pause.svelte';
  10. import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
  11. import ChevronUp from 'svelte-material-icons/ChevronUp.svelte';
  12. import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
  13. import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
  14. import { AppRoute } from '$lib/constants';
  15. import { page } from '$app/stores';
  16. import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
  17. import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
  18. import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
  19. import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
  20. import { fade } from 'svelte/transition';
  21. import { tweened } from 'svelte/motion';
  22. const parseIndex = (s: string | null, max: number | null) => Math.max(Math.min(parseInt(s ?? '') || 0, max ?? 0), 0);
  23. $: memoryIndex = parseIndex($page.url.searchParams.get('memory'), $memoryStore?.length - 1);
  24. $: assetIndex = parseIndex($page.url.searchParams.get('asset'), currentMemory?.assets.length - 1);
  25. $: previousMemory = $memoryStore?.[memoryIndex - 1];
  26. $: currentMemory = $memoryStore?.[memoryIndex];
  27. $: nextMemory = $memoryStore?.[memoryIndex + 1];
  28. $: previousAsset = currentMemory?.assets[assetIndex - 1];
  29. $: currentAsset = currentMemory?.assets[assetIndex];
  30. $: nextAsset = currentMemory?.assets[assetIndex + 1];
  31. $: canGoForward = !!(nextMemory || nextAsset);
  32. $: canGoBack = !!(previousMemory || previousAsset);
  33. const toNextMemory = () => goto(`?memory=${memoryIndex + 1}`);
  34. const toPreviousMemory = () => goto(`?memory=${memoryIndex - 1}`);
  35. const toNextAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex + 1}`);
  36. const toPreviousAsset = () => goto(`?memory=${memoryIndex}&asset=${assetIndex - 1}`);
  37. const toNext = () => (nextAsset ? toNextAsset() : toNextMemory());
  38. const toPrevious = () => (previousAsset ? toPreviousAsset() : toPreviousMemory());
  39. const progress = tweened<number>(0, {
  40. duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0),
  41. });
  42. const play = () => progress.set(1);
  43. const pause = () => progress.set($progress);
  44. let resetPromise = Promise.resolve();
  45. const reset = () => (resetPromise = progress.set(0));
  46. let paused = false;
  47. // Play or pause progress when the paused state changes.
  48. $: paused ? pause() : play();
  49. // Progress should be paused when it's no longer possible to advance.
  50. $: paused ||= !canGoForward || galleryInView;
  51. // Advance to the next asset or memory when progress is complete.
  52. $: $progress === 1 && toNext();
  53. // Progress should be resumed when reset and not paused.
  54. $: !$progress && !paused && play();
  55. // Progress should be reset when the current memory or asset changes.
  56. $: memoryIndex, assetIndex, reset();
  57. const handleKeyDown = (e: KeyboardEvent) => {
  58. if (e.key === 'ArrowRight' && canGoForward) {
  59. e.preventDefault();
  60. toNext();
  61. } else if (e.key === 'ArrowLeft' && canGoBack) {
  62. e.preventDefault();
  63. toPrevious();
  64. } else if (e.key === 'Escape') {
  65. e.preventDefault();
  66. goto(AppRoute.PHOTOS);
  67. }
  68. };
  69. onMount(async () => {
  70. if (!$memoryStore) {
  71. const { data } = await api.assetApi.getMemoryLane({
  72. timestamp: DateTime.local().startOf('day').toISO() || '',
  73. });
  74. $memoryStore = data;
  75. }
  76. });
  77. let memoryGallery: HTMLElement;
  78. let memoryWrapper: HTMLElement;
  79. let galleryInView = false;
  80. </script>
  81. <svelte:window on:keydown={handleKeyDown} />
  82. <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
  83. {#if currentMemory}
  84. <ControlAppBar on:close-button-click={() => goto(AppRoute.PHOTOS)} forceDark>
  85. <svelte:fragment slot="leading">
  86. <p class="text-lg">
  87. {currentMemory.title}
  88. </p>
  89. </svelte:fragment>
  90. {#if !galleryInView}
  91. <div class="flex place-items-center place-content-center overflow-hidden gap-2">
  92. <CircleIconButton logo={paused ? Play : Pause} forceDark on:click={() => (paused = !paused)} />
  93. {#each currentMemory.assets as _, i}
  94. <button class="relative w-full py-2" on:click={() => goto(`?memory=${memoryIndex}&asset=${i}`)}>
  95. <span class="absolute left-0 w-full h-[2px] bg-gray-500" />
  96. {#await resetPromise}
  97. <span class="absolute left-0 h-[2px] bg-white" style:width={`${i < assetIndex ? 100 : 0}%`} />
  98. {:then}
  99. <span
  100. class="absolute left-0 h-[2px] bg-white"
  101. style:width={`${i < assetIndex ? 100 : i > assetIndex ? 0 : $progress * 100}%`}
  102. />
  103. {/await}
  104. </button>
  105. {/each}
  106. <div>
  107. <p class="text-small">
  108. {assetIndex + 1}/{currentMemory.assets.length}
  109. </p>
  110. </div>
  111. </div>
  112. {/if}
  113. </ControlAppBar>
  114. {#if galleryInView}
  115. <div
  116. class="sticky top-20 flex place-content-center place-items-center z-30 transition-opacity"
  117. class:opacity-0={!galleryInView}
  118. class:opacity-100={galleryInView}
  119. >
  120. <button on:click={() => memoryWrapper.scrollIntoView({ behavior: 'smooth' })} disabled={!galleryInView}>
  121. <CircleIconButton logo={ChevronUp} backgroundColor="white" forceDark />
  122. </button>
  123. </div>
  124. {/if}
  125. <!-- Viewer -->
  126. <section class="pt-20 overflow-hidden">
  127. <div
  128. class="flex w-[300%] h-[calc(100vh_-_180px)] items-center justify-center box-border ml-[-100%] gap-10 overflow-hidden"
  129. >
  130. <!-- PREVIOUS MEMORY -->
  131. <div
  132. class="rounded-2xl w-[20vw] h-1/2"
  133. class:opacity-25={previousMemory}
  134. class:opacity-0={!previousMemory}
  135. class:hover:opacity-70={previousMemory}
  136. >
  137. <button class="rounded-2xl h-full w-full relative" disabled={!previousMemory} on:click={toPreviousMemory}>
  138. <img
  139. class="rounded-2xl h-full w-full object-cover"
  140. src={previousMemory ? api.getAssetThumbnailUrl(previousMemory.assets[0].id, 'JPEG') : noThumbnailUrl}
  141. alt=""
  142. draggable="false"
  143. />
  144. {#if previousMemory}
  145. <div class="absolute right-4 bottom-4 text-white text-left">
  146. <p class="font-semibold text-xs text-gray-200">PREVIOUS</p>
  147. <p class="text-xl">{previousMemory.title}</p>
  148. </div>
  149. {/if}
  150. </button>
  151. </div>
  152. <!-- CURRENT MEMORY -->
  153. <div
  154. class="main-view rounded-2xl h-full relative w-[70vw] bg-black flex place-items-center place-content-center"
  155. >
  156. <div class="bg-black w-full h-full rounded-2xl">
  157. <!-- CONTROL BUTTONS -->
  158. <div class="absolute h-full flex justify-between w-full">
  159. <div class="flex h-full flex-col place-content-center place-items-center ml-4">
  160. <div class="inline-block">
  161. {#if canGoBack}
  162. <CircleIconButton logo={ChevronLeft} backgroundColor="#202123" on:click={toPrevious} />
  163. {/if}
  164. </div>
  165. </div>
  166. <div class="flex h-full flex-col place-content-center place-items-center mr-4">
  167. <div class="inline-block">
  168. {#if canGoForward}
  169. <CircleIconButton logo={ChevronRight} backgroundColor="#202123" on:click={toNext} />
  170. {/if}
  171. </div>
  172. </div>
  173. </div>
  174. {#key currentAsset.id}
  175. <img
  176. transition:fade|local
  177. class="rounded-2xl w-full h-full object-contain transition-all"
  178. src={api.getAssetThumbnailUrl(currentAsset.id, 'JPEG')}
  179. alt=""
  180. draggable="false"
  181. />
  182. {/key}
  183. <div class="absolute top-4 left-8 text-white text-sm font-medium">
  184. <p>
  185. {DateTime.fromISO(currentMemory.assets[0].fileCreatedAt).toLocaleString(DateTime.DATE_FULL)}
  186. </p>
  187. <p>
  188. {currentAsset.exifInfo?.city || ''}
  189. {currentAsset.exifInfo?.country || ''}
  190. </p>
  191. </div>
  192. </div>
  193. </div>
  194. <!-- NEXT MEMORY -->
  195. <div
  196. class="rounded-xl w-[20vw] h-1/2"
  197. class:opacity-25={nextMemory}
  198. class:opacity-0={!nextMemory}
  199. class:hover:opacity-70={nextMemory}
  200. >
  201. <button class="rounded-2xl h-full w-full relative" on:click={toNextMemory} disabled={!nextMemory}>
  202. <img
  203. class="rounded-2xl h-full w-full object-cover"
  204. src={nextMemory ? api.getAssetThumbnailUrl(nextMemory.assets[0].id, 'JPEG') : noThumbnailUrl}
  205. alt=""
  206. draggable="false"
  207. />
  208. {#if nextMemory}
  209. <div class="absolute left-4 bottom-4 text-white text-left">
  210. <p class="font-semibold text-xs text-gray-200">UP NEXT</p>
  211. <p class="text-xl">{nextMemory.title}</p>
  212. </div>
  213. {/if}
  214. </button>
  215. </div>
  216. </div>
  217. </section>
  218. <!-- GALERY VIEWER -->
  219. <section class="bg-immich-dark-gray pl-4">
  220. <div
  221. class="sticky flex place-content-center place-items-center mb-10 mt-4 transition-all"
  222. class:opacity-0={galleryInView}
  223. class:opacity-100={!galleryInView}
  224. >
  225. <button on:click={() => memoryGallery.scrollIntoView({ behavior: 'smooth' })}>
  226. <CircleIconButton logo={ChevronDown} backgroundColor="white" forceDark />
  227. </button>
  228. </div>
  229. <IntersectionObserver
  230. once={false}
  231. on:intersected={() => (galleryInView = true)}
  232. on:hidden={() => (galleryInView = false)}
  233. bottom={-200}
  234. >
  235. <div id="gallery-memory" bind:this={memoryGallery}>
  236. <GalleryViewer assets={currentMemory.assets} viewFrom="album-page" />
  237. </div>
  238. </IntersectionObserver>
  239. </section>
  240. {/if}
  241. </section>
  242. <style>
  243. .main-view {
  244. box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.3), 0 8px 12px 6px rgba(0, 0, 0, 0.15);
  245. }
  246. </style>