diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index e40c20388b..fe7da0b2c0 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Shared Links', () => { test('download from a shared link', async ({ page }) => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); - await page.locator('.group > div').first().hover(); + await page.locator('.group').first().hover(); await page.waitForSelector('#asset-group-by-date svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); diff --git a/web/src/lib/actions/autogrow.ts b/web/src/lib/actions/autogrow.ts index b79671afc8..ff80454ef3 100644 --- a/web/src/lib/actions/autogrow.ts +++ b/web/src/lib/actions/autogrow.ts @@ -1,4 +1,7 @@ export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => { + if (!textarea) { + return; + } textarea.style.height = height; textarea.style.height = `${textarea.scrollHeight}px`; }; diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts new file mode 100644 index 0000000000..222f76be63 --- /dev/null +++ b/web/src/lib/actions/intersection-observer.ts @@ -0,0 +1,152 @@ +type Config = IntersectionObserverActionProperties & { + observer?: IntersectionObserver; +}; +type TrackedProperties = { + root?: Element | Document | null; + threshold?: number | number[]; + top?: string; + right?: string; + bottom?: string; + left?: string; +}; +type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown; +type OnSeperateCallback = (element: HTMLElement) => unknown; +type IntersectionObserverActionProperties = { + key?: string; + onSeparate?: OnSeperateCallback; + onIntersect?: OnIntersectCallback; + + root?: Element | Document | null; + threshold?: number | number[]; + top?: string; + right?: string; + bottom?: string; + left?: string; + + disabled?: boolean; +}; +type TaskKey = HTMLElement | string; + +function isEquivalent(a: TrackedProperties, b: TrackedProperties) { + return ( + a?.bottom === b?.bottom && + a?.top === b?.top && + a?.left === b?.left && + a?.right == b?.right && + a?.threshold === b?.threshold && + a?.root === b?.root + ); +} + +const elementToConfig = new Map<TaskKey, Config>(); + +const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => { + if (!target.isConnected) { + elementToConfig.get(key)?.observer?.unobserve(target); + return; + } + const { + root, + threshold, + top = '0px', + right = '0px', + bottom = '0px', + left = '0px', + onSeparate, + onIntersect, + } = properties; + const rootMargin = `${top} ${right} ${bottom} ${left}`; + const observer = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => { + // This IntersectionObserver is limited to observing a single element, the one the + // action is attached to. If there are multiple entries, it means that this + // observer is being notified of multiple events that have occured quickly together, + // and the latest element is the one we are interested in. + + entries.sort((a, b) => a.time - b.time); + + const latestEntry = entries.pop(); + if (latestEntry?.isIntersecting) { + onIntersect?.(latestEntry); + } else { + onSeparate?.(target); + } + }, + { + rootMargin, + threshold, + root, + }, + ); + observer.observe(target); + elementToConfig.set(key, { ...properties, observer }); +}; + +function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) { + elementToConfig.set(key, properties); + observe(key, element, properties); +} + +function _intersectionObserver( + key: HTMLElement | string, + element: HTMLElement, + properties: IntersectionObserverActionProperties, +) { + if (properties.disabled) { + properties.onIntersect?.(element); + } else { + configure(key, element, properties); + } + return { + update(properties: IntersectionObserverActionProperties) { + const config = elementToConfig.get(key); + if (!config) { + return; + } + if (isEquivalent(config, properties)) { + return; + } + configure(key, element, properties); + }, + destroy: () => { + if (properties.disabled) { + properties.onSeparate?.(element); + } else { + const config = elementToConfig.get(key); + const { observer, onSeparate } = config || {}; + observer?.unobserve(element); + elementToConfig.delete(key); + if (onSeparate) { + onSeparate?.(element); + } + } + }, + }; +} + +export function intersectionObserver( + element: HTMLElement, + properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[], +) { + // svelte doesn't allow multiple use:action directives of the same kind on the same element, + // so accept an array when multiple configurations are needed. + if (Array.isArray(properties)) { + if (!properties.every((p) => p.key)) { + throw new Error('Multiple configurations must specify key'); + } + const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p)); + return { + update: (properties: IntersectionObserverActionProperties[]) => { + for (const [i, props] of properties.entries()) { + observers[i].update(props); + } + }, + destroy: () => { + for (const observer of observers) { + observer.destroy(); + } + }, + }; + } + return _intersectionObserver(element, element, properties); +} diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts new file mode 100644 index 0000000000..9f3adc44b0 --- /dev/null +++ b/web/src/lib/actions/resize-observer.ts @@ -0,0 +1,43 @@ +type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; + +let observer: ResizeObserver; +let callbacks: WeakMap<HTMLElement, OnResizeCallback>; + +/** + * Installs a resizeObserver on the given element - when the element changes + * size, invokes a callback function with the width/height. Intended as a + * replacement for bind:clientWidth and bind:clientHeight in svelte4 which use + * an iframe to measure the size of the element, which can be bad for + * performance and memory usage. In svelte5, they adapted bind:clientHeight and + * bind:clientWidth to use an internal resize observer. + * + * TODO: When svelte5 is ready, go back to bind:clientWidth and + * bind:clientHeight. + */ +export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) { + if (!observer) { + callbacks = new WeakMap(); + observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const onResize = callbacks.get(entry.target as HTMLElement); + if (onResize) { + onResize({ + target: entry.target as HTMLElement, + width: entry.borderBoxSize[0].inlineSize, + height: entry.borderBoxSize[0].blockSize, + }); + } + } + }); + } + + callbacks.set(element, onResize); + observer.observe(element); + + return { + destroy: () => { + callbacks.delete(element); + observer.unobserve(element); + }, + }; +} diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts new file mode 100644 index 0000000000..ab9d28ffc9 --- /dev/null +++ b/web/src/lib/actions/thumbhash.ts @@ -0,0 +1,14 @@ +import { decodeBase64 } from '$lib/utils'; +import { thumbHashToRGBA } from 'thumbhash'; + +export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { + const ctx = canvas.getContext('2d'); + if (ctx) { + const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); + const pixels = ctx.createImageData(w, h); + canvas.width = w; + canvas.height = h; + pixels.data.set(rgba); + ctx.putImageData(pixels, 0, 0); + } +} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 7a88aa740b..2256c79eb0 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -19,6 +19,7 @@ import { handlePromiseError } from '$lib/utils'; import AlbumSummary from './album-summary.svelte'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let sharedLink: SharedLinkResponseDto; export let user: UserResponseDto | undefined = undefined; @@ -38,6 +39,9 @@ dragAndDropFilesStore.set({ isDragging: false, files: [] }); } }); + onDestroy(() => { + assetStore.destroy(); + }); </script> <svelte:window @@ -94,7 +98,7 @@ </header> <main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"> - <AssetGrid {album} {assetStore} {assetInteractionStore}> + <AssetGrid enableRouting={true} {album} {assetStore} {assetInteractionStore}> <section class="pt-8 md:pt-24"> <!-- ALBUM TITLE --> <h1 diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7c541fbf7a..3ed955848b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -15,7 +15,6 @@ import { websocketEvents } from '$lib/stores/websocket'; import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; - import { navigate } from '$lib/utils/navigation'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { AssetJobName, @@ -70,7 +69,8 @@ } = slideshowStore; const dispatch = createEventDispatcher<{ - close: void; + action: { type: AssetAction; asset: AssetResponseDto }; + close: { asset: AssetResponseDto }; next: void; previous: void; }>(); @@ -201,7 +201,6 @@ websocketEvents.on('on_asset_update', onAssetUpdate), ); - await navigate({ targetRoute: 'current', assetId: asset.id }); slideshowStateUnsubscribe = slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset(); @@ -268,9 +267,8 @@ $isShowDetail = !$isShowDetail; }; - const closeViewer = async () => { - dispatch('close'); - await navigate({ targetRoute: 'current', assetId: null }); + const closeViewer = () => { + dispatch('close', { asset }); }; const closeEditor = () => { @@ -378,9 +376,7 @@ } }; - const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => { - const { isMouseOver } = e.detail; - + const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { previewStackedAsset = isMouseOver ? asset : undefined; }; @@ -392,8 +388,7 @@ } case AssetAction.UNSTACK: { - await closeViewer(); - break; + closeViewer(); } } @@ -585,12 +580,11 @@ ? 'bg-transparent border-2 border-white' : 'bg-gray-700/40'} inline-block hover:bg-transparent" asset={stackedAsset} - onClick={(stackedAsset, event) => { - event.preventDefault(); + onClick={(stackedAsset) => { asset = stackedAsset; preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]]; }} - on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)} + onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} readonly thumbnailSize={stackedAsset.id == asset.id ? 65 : 60} showStackedIcon={false} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 4ff2084b9a..88417f248f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -212,7 +212,6 @@ title={person.name} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} /> </div> diff --git a/web/src/lib/components/asset-viewer/intersection-observer.svelte b/web/src/lib/components/asset-viewer/intersection-observer.svelte deleted file mode 100644 index df89a2ed7d..0000000000 --- a/web/src/lib/components/asset-viewer/intersection-observer.svelte +++ /dev/null @@ -1,82 +0,0 @@ -<script lang="ts"> - import { BucketPosition } from '$lib/stores/assets.store'; - import { createEventDispatcher, onMount } from 'svelte'; - - export let once = false; - export let top = 0; - export let bottom = 0; - export let left = 0; - export let right = 0; - export let root: HTMLElement | null = null; - - export let intersecting = false; - let container: HTMLDivElement; - const dispatch = createEventDispatcher<{ - hidden: HTMLDivElement; - intersected: { - container: HTMLDivElement; - position: BucketPosition; - }; - }>(); - - onMount(() => { - if (typeof IntersectionObserver !== 'undefined') { - const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`; - const observer = new IntersectionObserver( - (entries) => { - intersecting = entries.some((entry) => entry.isIntersecting); - if (!intersecting) { - dispatch('hidden', container); - } - - if (intersecting && once) { - observer.unobserve(container); - } - - if (intersecting) { - let position: BucketPosition = BucketPosition.Visible; - if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) { - position = BucketPosition.Below; - } else if (entries[0].boundingClientRect.bottom < 0) { - position = BucketPosition.Above; - } - - dispatch('intersected', { - container, - position, - }); - } - }, - { - rootMargin, - root, - }, - ); - - observer.observe(container); - return () => observer.unobserve(container); - } - - // The following is a fallback for older browsers - function handler() { - const bcr = container.getBoundingClientRect(); - - intersecting = - bcr.bottom + bottom > 0 && - bcr.right + right > 0 && - bcr.top - top < window.innerHeight && - bcr.left - left < window.innerWidth; - - if (intersecting && once) { - window.removeEventListener('scroll', handler); - } - } - - window.addEventListener('scroll', handler); - return () => window.removeEventListener('scroll', handler); - }); -</script> - -<div bind:this={container}> - <slot {intersecting} /> -</div> diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 966f382838..3919033e4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -12,7 +12,7 @@ import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard'; - import { onDestroy } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; @@ -33,6 +33,7 @@ let imageLoaded: boolean = false; let imageError: boolean = false; let forceUseOriginal: boolean = false; + let loader: HTMLImageElement; $: isWebCompatible = isWebCompatibleImage(asset); $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile; @@ -108,6 +109,25 @@ event.preventDefault(); handlePromiseError(copyImage()); }; + + onMount(() => { + const onload = () => { + imageLoaded = true; + assetFileUrl = imageLoaderUrl; + }; + const onerror = () => { + imageError = imageLoaded = true; + }; + if (loader.complete) { + onload(); + } + loader.addEventListener('load', onload); + loader.addEventListener('error', onerror); + return () => { + loader?.removeEventListener('load', onload); + loader?.removeEventListener('error', onerror); + }; + }); </script> <svelte:window @@ -119,6 +139,8 @@ {#if imageError} <div class="h-full flex items-center justify-center">{$t('error_loading_image')}</div> {/if} +<!-- svelte-ignore a11y-missing-attribute --> +<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" /> <div bind:this={element} class="relative h-full select-none"> <img style="display:none" @@ -128,7 +150,7 @@ on:error={() => (imageError = imageLoaded = true)} /> {#if !imageLoaded} - <div class="flex h-full items-center justify-center"> + <div id="spinner" class="flex h-full items-center justify-center"> <LoadingSpinner /> </div> {:else if !imageError} @@ -159,3 +181,15 @@ </div> {/if} </div> + +<style> + @keyframes delayedVisibility { + to { + visibility: visible; + } + } + #spinner { + visibility: hidden; + animation: 0s linear 0.4s forwards delayedVisibility; + } +</style> diff --git a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts index 91ea7d3ab1..2525b86160 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts @@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte'; describe('ImageThumbnail component', () => { beforeAll(() => { - Object.defineProperty(HTMLImageElement.prototype, 'decode', { - value: vi.fn(), + Object.defineProperty(HTMLImageElement.prototype, 'complete', { + value: true, }); }); @@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => { const sut = render(ImageThumbnail, { url: 'http://localhost/img.png', altText: 'test', - thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', widthStyle: '250px', }); - const [_, thumbhash] = sut.getAllByRole('img'); - expect(thumbhash.getAttribute('src')).toContain( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated - ); + const thumbhash = sut.getByTestId('thumbhash'); + expect(thumbhash).not.toBeFalsy(); }); }); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 8e391ecb59..e03dd35653 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,17 +1,19 @@ <script lang="ts"> - import { onMount, tick } from 'svelte'; - import { decodeBase64 } from '$lib/utils'; + import { onMount } from 'svelte'; + import { fade } from 'svelte/transition'; - import { thumbHashToDataURL } from 'thumbhash'; - import { mdiEyeOffOutline } from '@mdi/js'; + + import { thumbhash } from '$lib/actions/thumbhash'; import Icon from '$lib/components/elements/icon.svelte'; + import { TUNABLES } from '$lib/utils/tunables'; + import { mdiEyeOffOutline, mdiImageBrokenVariant } from '@mdi/js'; export let url: string; export let altText: string | undefined; export let title: string | null = null; export let heightStyle: string | undefined = undefined; export let widthStyle: string; - export let thumbhash: string | null = null; + export let base64ThumbHash: string | null = null; export let curve = false; export let shadow = false; export let circle = false; @@ -19,37 +21,58 @@ export let border = false; export let preload = true; export let hiddenIconClass = 'text-white'; + export let onComplete: (() => void) | undefined = undefined; + + let { + IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, + } = TUNABLES; + + let loaded = false; + let errored = false; - let complete = false; let img: HTMLImageElement; - onMount(async () => { - await img.decode(); - await tick(); - complete = true; + const setLoaded = () => { + loaded = true; + onComplete?.(); + }; + const setErrored = () => { + errored = true; + onComplete?.(); + }; + onMount(() => { + if (img.complete) { + setLoaded(); + } }); </script> -<img - bind:this={img} - loading={preload ? 'eager' : 'lazy'} - style:width={widthStyle} - style:height={heightStyle} - style:filter={hidden ? 'grayscale(50%)' : 'none'} - style:opacity={hidden ? '0.5' : '1'} - src={url} - alt={altText} - {title} - class="object-cover transition duration-300 {border - ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' - : ''}" - class:rounded-xl={curve} - class:shadow-lg={shadow} - class:rounded-full={circle} - class:aspect-square={circle || !heightStyle} - class:opacity-0={!thumbhash && !complete} - draggable="false" -/> +{#if errored} + <div class="absolute flex h-full w-full items-center justify-center p-4 z-10"> + <Icon path={mdiImageBrokenVariant} size="48" /> + </div> +{:else} + <img + bind:this={img} + on:load={setLoaded} + on:error={setErrored} + loading={preload ? 'eager' : 'lazy'} + style:width={widthStyle} + style:height={heightStyle} + style:filter={hidden ? 'grayscale(50%)' : 'none'} + style:opacity={hidden ? '0.5' : '1'} + src={url} + alt={loaded || errored ? altText : ''} + {title} + class="object-cover {border ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' : ''}" + class:rounded-xl={curve} + class:shadow-lg={shadow} + class:rounded-full={circle} + class:aspect-square={circle || !heightStyle} + class:opacity-0={!thumbhash && !loaded} + draggable="false" + /> +{/if} {#if hidden} <div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"> @@ -57,18 +80,18 @@ </div> {/if} -{#if thumbhash && !complete} - <img +{#if base64ThumbHash && (!loaded || errored)} + <canvas + use:thumbhash={{ base64ThumbHash }} + data-testid="thumbhash" style:width={widthStyle} style:height={heightStyle} - src={thumbHashToDataURL(decodeBase64(thumbhash))} - alt={altText} {title} class="absolute top-0 object-cover" class:rounded-xl={curve} class:shadow-lg={shadow} class:rounded-full={circle} draggable="false" - out:fade={{ duration: 300 }} + out:fade={{ duration: THUMBHASH_FADE_DURATION }} /> {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 6b0bd2ee75..c9fbf133c8 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte'; + import { intersectionObserver } from '$lib/actions/intersection-observer'; import Icon from '$lib/components/elements/icon.svelte'; import { ProjectionType } from '$lib/constants'; import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; @@ -18,18 +18,23 @@ mdiMotionPlayOutline, mdiRotate360, } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; + import { fade } from 'svelte/transition'; import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; + import { AssetStore } from '$lib/stores/assets.store'; - const dispatch = createEventDispatcher<{ - select: { asset: AssetResponseDto }; - 'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number }; - }>(); + import type { DateGroup } from '$lib/utils/timeline-util'; + + import { generateId } from '$lib/utils/generate-id'; + import { onDestroy } from 'svelte'; + import { TUNABLES } from '$lib/utils/tunables'; + import { thumbhash } from '$lib/actions/thumbhash'; export let asset: AssetResponseDto; + export let dateGroup: DateGroup | undefined = undefined; + export let assetStore: AssetStore | undefined = undefined; export let groupIndex = 0; export let thumbnailSize: number | undefined = undefined; export let thumbnailWidth: number | undefined = undefined; @@ -40,72 +45,181 @@ export let readonly = false; export let showArchiveIcon = false; export let showStackedIcon = true; - export let onClick: ((asset: AssetResponseDto, event: Event) => void) | undefined = undefined; + export let intersectionConfig: { + root?: HTMLElement; + bottom?: string; + top?: string; + left?: string; + priority?: number; + disabled?: boolean; + } = {}; + + export let retrieveElement: boolean = false; + export let onIntersected: (() => void) | undefined = undefined; + export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined; + export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined; + export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined; + export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined = + undefined; let className = ''; export { className as class }; + let { + IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, + } = TUNABLES; + + const componentId = generateId(); + let element: HTMLElement | undefined; let mouseOver = false; + let intersecting = false; + let lastRetrievedElement: HTMLElement | undefined; + let loaded = false; - $: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex }); + $: if (!retrieveElement) { + lastRetrievedElement = undefined; + } + $: if (retrieveElement && element && lastRetrievedElement !== element) { + lastRetrievedElement = element; + onRetrieveElement?.(element); + } - $: [width, height] = ((): [number, number] => { - if (thumbnailSize) { - return [thumbnailSize, thumbnailSize]; - } + $: width = thumbnailSize || thumbnailWidth || 235; + $: height = thumbnailSize || thumbnailHeight || 235; + $: display = intersecting; - if (thumbnailWidth && thumbnailHeight) { - return [thumbnailWidth, thumbnailHeight]; - } - - return [235, 235]; - })(); - - const onIconClickedHandler = (e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); + const onIconClickedHandler = (e?: MouseEvent) => { + e?.stopPropagation(); + e?.preventDefault(); if (!disabled) { - dispatch('select', { asset }); + onSelect?.(asset); } }; + const callClickHandlers = () => { + if (selected) { + onIconClickedHandler(); + return; + } + onClick?.(asset); + }; const handleClick = (e: MouseEvent) => { if (e.ctrlKey || e.metaKey) { return; } + e.stopPropagation(); + e.preventDefault(); + callClickHandlers(); + }; - if (selected) { - onIconClickedHandler(e); - return; - } - - onClick?.(asset, e); + const _onMouseEnter = () => { + mouseOver = true; + onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex }); }; const onMouseEnter = () => { - mouseOver = true; + if (dateGroup && assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() }); + } else { + _onMouseEnter(); + } }; const onMouseLeave = () => { - mouseOver = false; + if (dateGroup && assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) }); + } else { + mouseOver = false; + } }; + + const _onIntersect = () => { + intersecting = true; + onIntersected?.(); + }; + + const onIntersect = () => { + if (intersecting === true) { + return; + } + if (dateGroup && assetStore) { + assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect()); + } else { + void _onIntersect(); + } + }; + + const onSeparate = () => { + if (intersecting === false) { + return; + } + if (dateGroup && assetStore) { + assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false)); + } else { + intersecting = false; + } + }; + + onDestroy(() => { + assetStore?.taskManager.removeAllTasksForComponent(componentId); + }); </script> -<IntersectionObserver once={false} on:intersected let:intersecting> - <a - href={currentUrlReplaceAssetId(asset.id)} - style:width="{width}px" - style:height="{height}px" - class="group focus-visible:outline-none flex overflow-hidden {disabled - ? 'bg-gray-300' - : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}" - class:cursor-not-allowed={disabled} - on:mouseenter={onMouseEnter} - on:mouseleave={onMouseLeave} - tabindex={0} - on:click={handleClick} - > - {#if intersecting} +<div + bind:this={element} + use:intersectionObserver={{ + ...intersectionConfig, + onIntersect, + onSeparate, + }} + data-asset={asset.id} + data-int={intersecting} + style:width="{width}px" + style:height="{height}px" + class="group focus-visible:outline-none flex overflow-hidden {disabled + ? 'bg-gray-300' + : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}" +> + {#if !loaded && asset.thumbhash} + <canvas + use:thumbhash={{ base64ThumbHash: asset.thumbhash }} + class="absolute object-cover z-10" + style:width="{width}px" + style:height="{height}px" + out:fade={{ duration: THUMBHASH_FADE_DURATION }} + ></canvas> + {/if} + + {#if display} + <!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates + the navigation url on scroll. Replace this with button for now. --> + <div + class:cursor-not-allowed={disabled} + class:cursor-pointer={!disabled} + on:mouseenter={onMouseEnter} + on:mouseleave={onMouseLeave} + on:keypress={(evt) => { + if (evt.key === 'Enter') { + callClickHandlers(); + } + }} + tabindex={0} + on:click={handleClick} + role="link" + > + {#if mouseOver} + <!-- lazy show the url on mouse over--> + <a + class="absolute z-30 {className} top-[41px]" + style:cursor="unset" + style:width="{width}px" + style:height="{height}px" + href={currentUrlReplaceAssetId(asset.id)} + on:click={(evt) => evt.preventDefault()} + tabindex={0} + > + </a> + {/if} <div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px"> <!-- Select asset button --> {#if !readonly && (mouseOver || selected || selectionCandidate)} @@ -189,11 +303,11 @@ altText={$getAltText(asset)} widthStyle="{width}px" heightStyle="{height}px" - thumbhash={asset.thumbhash} curve={selected} + onComplete={() => (loaded = true)} /> {:else} - <div class="flex h-full w-full items-center justify-center p-4"> + <div class="absolute flex h-full w-full items-center justify-center p-4 z-10"> <Icon path={mdiImageBrokenVariant} size="48" /> </div> {/if} @@ -201,6 +315,7 @@ {#if asset.type === AssetTypeEnum.Video} <div class="absolute top-0 h-full w-full"> <VideoThumbnail + {assetStore} url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })} enablePlayback={mouseOver && $playVideoThumbnailOnHover} curve={selected} @@ -213,6 +328,7 @@ {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} <div class="absolute top-0 h-full w-full"> <VideoThumbnail + {assetStore} url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })} pauseIcon={mdiMotionPauseOutline} playIcon={mdiMotionPlayOutline} @@ -230,6 +346,6 @@ out:fade={{ duration: 100 }} /> {/if} - {/if} - </a> -</IntersectionObserver> + </div> + {/if} +</div> diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 5c4196e54b..5cac0b1945 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -3,7 +3,11 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import { AssetStore } from '$lib/stores/assets.store'; + import { generateId } from '$lib/utils/generate-id'; + import { onDestroy } from 'svelte'; + export let assetStore: AssetStore | undefined = undefined; export let url: string; export let durationInSeconds = 0; export let enablePlayback = false; @@ -13,6 +17,7 @@ export let playIcon = mdiPlayCircleOutline; export let pauseIcon = mdiPauseCircleOutline; + const componentId = generateId(); let remainingSeconds = durationInSeconds; let loading = true; let error = false; @@ -27,6 +32,43 @@ player.src = ''; } } + const onMouseEnter = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = true; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = true; + } + } + }; + + const onMouseLeave = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = false; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = false; + } + } + }; + + onDestroy(() => { + assetStore?.taskManager.removeAllTasksForComponent(componentId); + }); </script> <div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white"> @@ -37,19 +79,7 @@ {/if} <!-- svelte-ignore a11y-no-static-element-interactions --> - <span - class="pr-2 pt-2" - on:mouseenter={() => { - if (playbackOnIconHover) { - enablePlayback = true; - } - }} - on:mouseleave={() => { - if (playbackOnIconHover) { - enablePlayback = false; - } - }} - > + <span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}> {#if enablePlayback} {#if loading} <LoadingSpinner /> diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 0dd4251dab..eba26e6e61 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -113,7 +113,6 @@ title={$getPersonNameWithHiddenValue(person.name, person.isHidden)} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} /> </div> diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 712100763c..fd4fbdf964 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -265,8 +265,6 @@ title={$t('face_unassigned')} widthStyle="90px" heightStyle="90px" - thumbhash={null} - hidden={false} /> {:then data} <ImageThumbnail @@ -277,8 +275,6 @@ title={$t('face_unassigned')} widthStyle="90px" heightStyle="90px" - thumbhash={null} - hidden={false} /> {/await} {/if} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 2a6e121c3a..250cb379cc 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { shortcuts } from '$lib/actions/shortcut'; - import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte'; + import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; @@ -38,6 +38,8 @@ import { tweened } from 'svelte/motion'; import { fade } from 'svelte/transition'; import { t } from 'svelte-i18n'; + import { intersectionObserver } from '$lib/actions/intersection-observer'; + import { resizeObserver } from '$lib/actions/resize-observer'; import { locale } from '$lib/stores/preferences.store'; const parseIndex = (s: string | null, max: number | null) => @@ -383,21 +385,18 @@ /> </div> - <IntersectionObserver - once={false} - on:intersected={() => (galleryInView = true)} - on:hidden={() => (galleryInView = false)} - bottom={-200} + <div + id="gallery-memory" + use:intersectionObserver={{ + onIntersect: () => (galleryInView = true), + onSeparate: () => (galleryInView = false), + bottom: '-200px', + }} + use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))} + bind:this={memoryGallery} > - <div - id="gallery-memory" - bind:this={memoryGallery} - bind:clientHeight={viewport.height} - bind:clientWidth={viewport.width} - > - <GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets /> - </div> - </IntersectionObserver> + <GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets /> + </div> </section> {/if} </section> diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index dd57160fb4..5ca29967fe 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -1,84 +1,69 @@ <script lang="ts"> + import { intersectionObserver } from '$lib/actions/intersection-observer'; import Icon from '$lib/components/elements/icon.svelte'; + import Skeleton from '$lib/components/photos-page/skeleton.svelte'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; - import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { AssetStore, Viewport } from '$lib/stores/assets.store'; - import { locale } from '$lib/stores/preferences.store'; - import { getAssetRatio } from '$lib/utils/asset-utils'; - import { - calculateWidth, - formatGroupTitle, - fromLocalDateTime, - splitBucketIntoDateGroups, - } from '$lib/utils/timeline-util'; + import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store'; + import { navigate } from '$lib/utils/navigation'; + import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; - import justifiedLayout from 'justified-layout'; - import { createEventDispatcher } from 'svelte'; + import { createEventDispatcher, onDestroy } from 'svelte'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; + import { TUNABLES } from '$lib/utils/tunables'; + import { generateId } from '$lib/utils/generate-id'; - export let assets: AssetResponseDto[]; - export let bucketDate: string; - export let bucketHeight: number; + export let element: HTMLElement | undefined = undefined; export let isSelectionMode = false; export let viewport: Viewport; export let singleSelect = false; export let withStacked = false; export let showArchiveIcon = false; - + export let assetGridElement: HTMLElement | undefined = undefined; + export let renderThumbsAtBottomMargin: string | undefined = undefined; + export let renderThumbsAtTopMargin: string | undefined = undefined; export let assetStore: AssetStore; + export let bucket: AssetBucket; export let assetInteractionStore: AssetInteractionStore; + export let onScrollTarget: ScrollTargetListener | undefined = undefined; + export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; + + const componentId = generateId(); + $: bucketDate = bucket.bucketDate; + $: dateGroups = bucket.dateGroups; + + const { + DATEGROUP: { INTERSECTION_DISABLED, INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM }, + } = TUNABLES; + /* TODO figure out a way to calculate this*/ + const TITLE_HEIGHT = 51; + const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; const dispatch = createEventDispatcher<{ select: { title: string; assets: AssetResponseDto[] }; selectAssets: AssetResponseDto; selectAssetCandidates: AssetResponseDto | null; - shift: { heightDelta: number }; }>(); let isMouseOverGroup = false; - let actualBucketHeight: number; let hoveredDateGroup = ''; - $: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale); - - $: geometry = (() => { - const geometry = []; - for (let group of assetsGroupByDate) { - const justifiedLayoutResult = justifiedLayout( - group.map((assetGroup) => getAssetRatio(assetGroup)), - { - boxSpacing: 2, - containerWidth: Math.floor(viewport.width), - containerPadding: 0, - targetRowHeightTolerance: 0.15, - targetRowHeight: 235, - }, - ); - geometry.push({ - ...justifiedLayoutResult, - containerWidth: calculateWidth(justifiedLayoutResult.boxes), - }); + const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { + if (isSelectionMode || $isMultiSelectState) { + assetSelectHandler(asset, assets, groupTitle); + return; } - return geometry; - })(); + void navigate({ targetRoute: 'current', assetId: asset.id }); + }; - $: { - if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) { - const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight); - if (heightDelta !== 0) { - scrollTimeline(heightDelta); - } + const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => { + if (assetGridElement && onScrollTarget) { + const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT; + onScrollTarget({ bucket, dateGroup, asset, offset }); } - } - - function scrollTimeline(heightDelta: number) { - dispatch('shift', { - heightDelta, - }); - } + }; const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets }); @@ -104,93 +89,149 @@ dispatch('selectAssetCandidates', asset); } }; + + onDestroy(() => { + $assetStore.taskManager.removeAllTasksForComponent(componentId); + }); </script> -<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}> - {#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)} - {@const asset = groupAssets[0]} - {@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))} - <!-- Asset Group By Date --> +<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}> + {#each dateGroups as dateGroup, groupIndex (dateGroup.date)} + {@const display = + dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)} - <!-- svelte-ignore a11y-no-static-element-interactions --> <div - class="flex flex-col" - on:mouseenter={() => { - isMouseOverGroup = true; - assetMouseEventHandler(groupTitle, null); - }} - on:mouseleave={() => { - isMouseOverGroup = false; - assetMouseEventHandler(groupTitle, null); + id="date-group" + use:intersectionObserver={{ + onIntersect: () => { + $assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), + ); + }, + onSeparate: () => { + $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), + ); + }, + top: INTERSECTION_ROOT_TOP, + bottom: INTERSECTION_ROOT_BOTTOM, + root: assetGridElement, + disabled: INTERSECTION_DISABLED, }} + data-display={display} + data-date-group={dateGroup.date} + style:height={dateGroup.height + 'px'} + style:width={dateGroup.geometry.containerWidth + 'px'} + style:overflow={'clip'} > - <!-- Date group title --> - <div - class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" - style="width: {geometry[groupIndex].containerWidth}px" - > - {#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))} + {#if !display} + <Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} /> + {/if} + {#if display} + <!-- Asset Group By Date --> + <!-- svelte-ignore a11y-no-static-element-interactions --> + <div + on:mouseenter={() => + $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = true; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + })} + on:mouseleave={() => { + $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = false; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + }); + }} + > + <!-- Date group title --> <div - transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} - class="inline-block px-2 hover:cursor-pointer" - on:click={() => handleSelectGroup(groupTitle, groupAssets)} - on:keydown={() => handleSelectGroup(groupTitle, groupAssets)} + class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" + style:width={dateGroup.geometry.containerWidth + 'px'} > - {#if $selectedGroup.has(groupTitle)} - <Icon path={mdiCheckCircle} size="24" color="#4250af" /> - {:else} - <Icon path={mdiCircleOutline} size="24" color="#757575" /> + {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))} + <div + transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} + class="inline-block px-2 hover:cursor-pointer" + on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + > + {#if $selectedGroup.has(dateGroup.groupTitle)} + <Icon path={mdiCheckCircle} size="24" color="#4250af" /> + {:else} + <Icon path={mdiCircleOutline} size="24" color="#757575" /> + {/if} + </div> {/if} + + <span class="w-full truncate first-letter:capitalize" title={dateGroup.groupTitle}> + {dateGroup.groupTitle} + </span> </div> - {/if} - <span class="w-full truncate first-letter:capitalize" title={groupTitle}> - {groupTitle} - </span> - </div> - - <!-- Image grid --> - <div - class="relative" - style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px" - > - {#each groupAssets as asset, index (asset.id)} - {@const box = geometry[groupIndex].boxes[index]} + <!-- Image grid --> <div - class="absolute" - style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px" + class="relative overflow-clip" + style:height={dateGroup.geometry.containerHeight + 'px'} + style:width={dateGroup.geometry.containerWidth + 'px'} > - <Thumbnail - showStackedIcon={withStacked} - {showArchiveIcon} - {asset} - {groupIndex} - onClick={(asset, event) => { - if (isSelectionMode || $isMultiSelectState) { - event.preventDefault(); - assetSelectHandler(asset, groupAssets, groupTitle); - return; - } - - assetViewingStore.setAsset(asset); - }} - on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)} - on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)} - selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} - selectionCandidate={$assetSelectionCandidates.has(asset)} - disabled={$assetStore.albumAssets.has(asset.id)} - thumbnailWidth={box.width} - thumbnailHeight={box.height} - /> + {#each dateGroup.assets as asset, index (asset.id)} + {@const box = dateGroup.geometry.boxes[index]} + <!-- update ASSET_GRID_PADDING--> + <div + use:intersectionObserver={{ + onIntersect: () => onAssetInGrid?.(asset), + top: `-${TITLE_HEIGHT}px`, + bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`, + right: `-${viewport.width - 1}px`, + root: assetGridElement, + }} + data-asset-id={asset.id} + class="absolute" + style:width={box.width + 'px'} + style:height={box.height + 'px'} + style:top={box.top + 'px'} + style:left={box.left + 'px'} + > + <Thumbnail + {dateGroup} + {assetStore} + intersectionConfig={{ + root: assetGridElement, + bottom: renderThumbsAtBottomMargin, + top: renderThumbsAtTopMargin, + }} + retrieveElement={$assetStore.pendingScrollAssetId === asset.id} + onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)} + showStackedIcon={withStacked} + {showArchiveIcon} + {asset} + {groupIndex} + onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} + onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} + onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} + selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} + selectionCandidate={$assetSelectionCandidates.has(asset)} + disabled={$assetStore.albumAssets.has(asset.id)} + thumbnailWidth={box.width} + thumbnailHeight={box.height} + /> + </div> + {/each} </div> - {/each} - </div> + </div> + {/if} </div> {/each} </section> <style> #asset-group-by-date { - contain: layout; + contain: layout paint style; } </style> diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3e0935d938..db030ed14c 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -1,11 +1,17 @@ <script lang="ts"> - import { goto } from '$app/navigation'; + import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import type { Action } from '$lib/components/asset-viewer/actions/action'; import { AppRoute, AssetAction } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { BucketPosition, isSelectingAllAssets, type AssetStore, type Viewport } from '$lib/stores/assets.store'; + import { + AssetBucket, + AssetStore, + isSelectingAllAssets, + type BucketListener, + type ViewportXY, + } from '$lib/stores/assets.store'; import { locale, showDeleteModal } from '$lib/stores/preferences.store'; import { isSearchEnabled } from '$lib/stores/search.store'; import { featureFlags } from '$lib/stores/server-config.store'; @@ -13,19 +19,38 @@ import { deleteAssets } from '$lib/utils/actions'; import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; - import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util'; + import { + formatGroupTitle, + splitBucketIntoDateGroups, + type ScrubberListener, + type ScrollTargetListener, + } from '$lib/utils/timeline-util'; + import { TUNABLES } from '$lib/utils/tunables'; import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; - import { DateTime } from 'luxon'; + import { throttle } from 'lodash-es'; import { createEventDispatcher, onDestroy, onMount } from 'svelte'; - import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; import Portal from '../shared-components/portal/portal.svelte'; - import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte'; + import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; import AssetDateGroup from './asset-date-group.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte'; + import { resizeObserver } from '$lib/actions/resize-observer'; + import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte'; + import { intersectionObserver } from '$lib/actions/intersection-observer'; + import Skeleton from '$lib/components/photos-page/skeleton.svelte'; + import { page } from '$app/stores'; + import type { UpdatePayload } from 'vite'; + import { generateId } from '$lib/utils/generate-id'; + export let isSelectionMode = false; export let singleSelect = false; + + /** `true` if this asset grid is responds to navigation events; if `true`, then look at the + `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and + additionally, update the page location/url with the asset as the asset-grid is scrolled */ + export let enableRouting: boolean; + export let assetStore: AssetStore; export let assetInteractionStore: AssetInteractionStore; export let removeAction: @@ -40,17 +65,32 @@ export let album: AlbumResponseDto | null = null; export let isShowDeleteConfirmation = false; - $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash; - + let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = assetInteractionStore; - const viewport: Viewport = { width: 0, height: 0 }; - let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets } = assetViewingStore; + + const viewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 }; + const safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 }; + + const componentId = generateId(); let element: HTMLElement; + let timelineElement: HTMLElement; let showShortcuts = false; let showSkeleton = true; + let internalScroll = false; + let navigating = false; + let preMeasure: AssetBucket[] = []; + let lastIntersectedBucketDate: string | undefined; + let scrubBucketPercent = 0; + let scrubBucket: { bucketDate: string | undefined } | undefined; + let scrubOverallPercent: number = 0; + let topSectionHeight = 0; + let topSectionOffset = 0; + // 60 is the bottom spacer element at 60px + let bottomSectionHeight = 60; + let leadout = false; - $: timelineY = element?.scrollTop || 0; + $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash; $: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0; $: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id); $: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived); @@ -59,30 +99,329 @@ assetInteractionStore.clearMultiselect(); } } - $: { - void assetStore.updateViewport(viewport); + if (element && isViewportOrigin()) { + const rect = element.getBoundingClientRect(); + viewport.height = rect.height; + viewport.width = rect.width; + viewport.x = rect.x; + viewport.y = rect.y; + } + if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) { + safeViewport.height = viewport.height; + safeViewport.width = viewport.width; + safeViewport.x = viewport.x; + safeViewport.y = viewport.y; + updateViewport(); + } } + const { + ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW }, + BUCKET: { + INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP, + INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM, + }, + THUMBNAIL: { + INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP, + INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM, + }, + } = TUNABLES; const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>(); - onMount(async () => { - showSkeleton = false; - assetStore.connect(); - await assetStore.init(viewport); - }); + const isViewportOrigin = () => { + return viewport.height === 0 && viewport.width === 0; + }; - onDestroy(() => { - if ($showAssetViewer) { - $showAssetViewer = false; + const isEqual = (a: ViewportXY, b: ViewportXY) => { + return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y; + }; + + const completeNav = () => { + navigating = false; + if (internalScroll) { + internalScroll = false; + return; } - assetStore.disconnect(); + if ($gridScrollTarget?.at) { + void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => { + element.scrollTo({ top: 0 }); + showSkeleton = false; + }); + } else { + element.scrollTo({ top: 0 }); + showSkeleton = false; + } + }; + + afterNavigate((nav) => { + const { complete, type } = nav; + if (type === 'enter') { + return; + } + complete.then(completeNav, completeNav); }); + beforeNavigate(() => { + navigating = true; + }); + + const hmrSupport = () => { + // when hmr happens, skeleton is initialized to true by default + // normally, loading asset-grid is part of a navigation event, and the completion of + // that event triggers a scroll-to-asset, if necessary, when then clears the skeleton. + // this handler will run the navigation/scroll-to-asset handler when hmr is performed, + // preventing skeleton from showing after hmr + if (import.meta && import.meta.hot) { + const afterApdate = (payload: UpdatePayload) => { + const assetGridUpdate = payload.updates.some( + (update) => update.path.endsWith('asset-grid.svelte') || update.path.endsWith('assets-store.ts'), + ); + + if (assetGridUpdate) { + setTimeout(() => { + void $assetStore.updateViewport(safeViewport, true); + const asset = $page.url.searchParams.get('at'); + if (asset) { + $gridScrollTarget = { at: asset }; + void navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { replaceState: true, forceNavigate: true }, + ); + } else { + element.scrollTo({ top: 0 }); + showSkeleton = false; + } + }, 500); + } + }; + import.meta.hot?.on('vite:afterUpdate', afterApdate); + import.meta.hot?.on('vite:beforeUpdate', (payload) => { + const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte')); + if (assetGridUpdate) { + assetStore.destroy(); + } + }); + + return () => import.meta.hot?.off('vite:afterUpdate', afterApdate); + } + return () => void 0; + }; + + const _updateLastIntersectedBucketDate = () => { + let elem = document.elementFromPoint(safeViewport.x + 1, safeViewport.y + 1); + + while (elem != null) { + if (elem.id === 'bucket') { + break; + } + elem = elem.parentElement; + } + if (elem) { + lastIntersectedBucketDate = (elem as HTMLElement).dataset.bucketDate; + } + }; + const updateLastIntersectedBucketDate = throttle(_updateLastIntersectedBucketDate, 16, { + leading: false, + trailing: true, + }); + + const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => { + if (!lastIntersectedBucketDate) { + _updateLastIntersectedBucketDate(); + } + if (lastIntersectedBucketDate) { + const currentIndex = $assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate); + const deltaIndex = $assetStore.buckets.indexOf(adjustedBucket); + + if (deltaIndex < currentIndex) { + element?.scrollBy(0, delta); + } + } + }; + + const bucketListener: BucketListener = (event) => { + const { type } = event; + if (type === 'bucket-height') { + const { bucket, delta } = event; + scrollTolastIntersectedBucket(bucket, delta); + } + }; + + onMount(() => { + void $assetStore + .init({ bucketListener }) + .then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport))); + if (!enableRouting) { + showSkeleton = false; + } + const dispose = hmrSupport(); + return () => { + $assetStore.disconnect(); + $assetStore.destroy(); + dispose(); + }; + }); + + function getOffset(bucketDate: string) { + let offset = 0; + for (let a = 0; a < assetStore.buckets.length; a++) { + if (assetStore.buckets[a].bucketDate === bucketDate) { + break; + } + offset += assetStore.buckets[a].bucketHeight; + } + return offset; + } + const _updateViewport = () => void $assetStore.updateViewport(safeViewport); + const updateViewport = throttle(_updateViewport, 16); + + const getMaxScrollPercent = () => + ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) / + ($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight); + + const getMaxScroll = () => + topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight); + + const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => { + const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset; + const maxScrollPercent = getMaxScrollPercent(); + const delta = bucket.bucketHeight * bucketScrollPercent; + const scrollTop = (topOffset + delta) * maxScrollPercent; + element.scrollTop = scrollTop; + }; + + const _onScrub: ScrubberListener = ( + bucketDate: string | undefined, + scrollPercent: number, + bucketScrollPercent: number, + ) => { + if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) { + // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead + + const maxScroll = getMaxScroll(); + const offset = maxScroll * scrollPercent; + element.scrollTop = offset; + } else { + const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate); + if (!bucket) { + return; + } + scrollToBucketAndOffset(bucket, bucketScrollPercent); + } + }; + const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true }); + + const stopScrub: ScrubberListener = async ( + bucketDate: string | undefined, + _scrollPercent: number, + bucketScrollPercent: number, + ) => { + if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) { + // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead + return; + } + const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate); + if (!bucket) { + return; + } + if (bucket && !bucket.measured) { + preMeasure.push(bucket); + if (!bucket.loaded) { + await assetStore.loadBucket(bucket.bucketDate); + } + // Wait here, and collect the deltas that are above offset, which affect offset position + await bucket.measuredPromise; + scrollToBucketAndOffset(bucket, bucketScrollPercent); + } + }; + + const _handleTimelineScroll = () => { + leadout = false; + if ($assetStore.timelineHeight < safeViewport.height * 2) { + // edge case - scroll limited due to size of content, must adjust - use the overall percent instead + const maxScroll = getMaxScroll(); + scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); + + scrubBucket = undefined; + scrubBucketPercent = 0; + } else { + let top = element?.scrollTop; + if (top < topSectionHeight) { + // in the lead-in area + scrubBucket = undefined; + scrubBucketPercent = 0; + const maxScroll = getMaxScroll(); + + scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); + return; + } + + let maxScrollPercent = getMaxScrollPercent(); + let found = false; + + // create virtual buckets.... + const vbuckets = [ + { bucketHeight: topSectionHeight, bucketDate: undefined }, + ...assetStore.buckets, + { bucketHeight: bottomSectionHeight, bucketDate: undefined }, + ]; + + for (const bucket of vbuckets) { + let next = top - bucket.bucketHeight * maxScrollPercent; + if (next < 0) { + scrubBucket = bucket; + scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent); + found = true; + break; + } + top = next; + } + if (!found) { + leadout = true; + scrubBucket = undefined; + scrubBucketPercent = 0; + scrubOverallPercent = 1; + } + } + }; + const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true }); + + const _onAssetInGrid = async (asset: AssetResponseDto) => { + if (!enableRouting || navigating || internalScroll) { + return; + } + $gridScrollTarget = { at: asset.id }; + internalScroll = true; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { replaceState: true, forceNavigate: true }, + ); + }; + const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW + ? throttle(_onAssetInGrid, 16, { leading: false, trailing: true }) + : () => void 0; + + const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => { + element.scrollTo({ top: offset }); + if (!bucket.measured) { + preMeasure.push(bucket); + } + showSkeleton = false; + $assetStore.clearPendingScroll(); + // set intersecting true manually here, to reduce flicker that happens when + // clearing pending scroll, but the intersection observer hadn't yet had time to run + $assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); + }; + const trashOrDelete = async (force: boolean = false) => { isShowDeleteConfirmation = false; - await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets); + await deleteAssets( + !(isTrashEnabled && !force), + (assetIds) => $assetStore.removeAssets(assetIds), + idsSelectedAssets, + ); assetInteractionStore.clearMultiselect(); }; @@ -107,7 +446,7 @@ const onStackAssets = async () => { const ids = await stackAssets(Array.from($selectedAssets)); if (ids) { - assetStore.removeAssets(ids); + $assetStore.removeAssets(ids); dispatch('escape'); } }; @@ -115,7 +454,7 @@ const toggleArchive = async () => { const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived); if (ids) { - assetStore.removeAssets(ids); + $assetStore.removeAssets(ids); deselectAllAssets(); } }; @@ -135,7 +474,7 @@ { shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') }, { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, - { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) }, + { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement }, ]; @@ -154,29 +493,33 @@ })(); const handleSelectAsset = (asset: AssetResponseDto) => { - if (!assetStore.albumAssets.has(asset.id)) { + if (!$assetStore.albumAssets.has(asset.id)) { assetInteractionStore.selectAsset(asset); } }; - async function intersectedHandler(event: CustomEvent) { - const element_ = event.detail.container as HTMLElement; - const target = element_.firstChild as HTMLElement; - if (target) { - const bucketDate = target.id.split('_')[1]; - await assetStore.loadBucket(bucketDate, event.detail.position); - } + function intersectedHandler(bucket: AssetBucket) { + updateLastIntersectedBucketDate(); + const intersectedTask = () => { + $assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); + void $assetStore.loadBucket(bucket.bucketDate); + }; + $assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask); } - function handleScrollTimeline(event: CustomEvent) { - element.scrollBy(0, event.detail.heightDelta); + function seperatedHandler(bucket: AssetBucket) { + const seperatedTask = () => { + $assetStore.updateBucket(bucket.bucketDate, { intersecting: false }); + bucket.cancel(); + }; + $assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask); } const handlePrevious = async () => { - const previousAsset = await assetStore.getPreviousAsset($viewingAsset); + const previousAsset = await $assetStore.getPreviousAsset($viewingAsset); if (previousAsset) { - const preloadAsset = await assetStore.getPreviousAsset(previousAsset); + const preloadAsset = await $assetStore.getPreviousAsset(previousAsset); assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []); await navigate({ targetRoute: 'current', assetId: previousAsset.id }); } @@ -185,10 +528,10 @@ }; const handleNext = async () => { - const nextAsset = await assetStore.getNextAsset($viewingAsset); + const nextAsset = await $assetStore.getNextAsset($viewingAsset); if (nextAsset) { - const preloadAsset = await assetStore.getNextAsset(nextAsset); + const preloadAsset = await $assetStore.getNextAsset(nextAsset); assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []); await navigate({ targetRoute: 'current', assetId: nextAsset.id }); } @@ -196,7 +539,12 @@ return !!nextAsset; }; - const handleClose = () => assetViewingStore.showAssetViewer(false); + const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => { + assetViewingStore.showAssetViewer(false); + showSkeleton = true; + $gridScrollTarget = { at: asset.id }; + await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }); + }; const handleAction = async (action: Action) => { switch (action.type) { @@ -206,7 +554,7 @@ case AssetAction.DELETE: { // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (await handleNext()) || (await handlePrevious()) || handleClose(); + (await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } })); // delete after find the next one assetStore.removeAssets([action.asset.id]); @@ -232,20 +580,6 @@ } }; - let animationTick = false; - - const handleTimelineScroll = () => { - if (animationTick) { - return; - } - - animationTick = true; - window.requestAnimationFrame(() => { - timelineY = element?.scrollTop || 0; - animationTick = false; - }); - }; - let lastAssetMouseEvent: AssetResponseDto | null = null; $: if (!lastAssetMouseEvent) { @@ -355,7 +689,7 @@ // Select/deselect assets in all intermediate buckets for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) { const bucket = $assetStore.buckets[bucketIndex]; - await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await $assetStore.loadBucket(bucket.bucketDate); for (const asset of bucket.assets) { if (deselect) { assetInteractionStore.removeAssetFromMultiselectGroup(asset); @@ -370,11 +704,10 @@ const bucket = $assetStore.buckets[bucketIndex]; // Split bucket into date groups and check each group - const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale); - + const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); for (const dateGroup of assetsGroupByDate) { - const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day')); - if (dateGroup.every((a) => $selectedAssets.has(a))) { + const dateGroupTitle = formatGroupTitle(dateGroup.date); + if (dateGroup.assets.every((a) => $selectedAssets.has(a))) { assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle); } else { assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle); @@ -411,6 +744,9 @@ e.preventDefault(); } }; + onDestroy(() => { + assetStore.taskManager.removeAllTasksForComponent(componentId); + }); </script> <svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} /> @@ -427,78 +763,97 @@ <ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} /> {/if} -<Scrollbar +<Scrubber + invisible={showSkeleton} {assetStore} - height={viewport.height} - {timelineY} - on:scrollTimeline={({ detail }) => (element.scrollTop = detail)} + height={safeViewport.height} + timelineTopOffset={topSectionHeight} + timelineBottomOffset={bottomSectionHeight} + {leadout} + {scrubOverallPercent} + {scrubBucketPercent} + {scrubBucket} + {onScrub} + {stopScrub} /> <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> <section id="asset-grid" - class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty - ? 'm-0' - : 'ml-4 tall:ml-0 md:mr-[60px]'}" + class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}" tabindex="-1" - bind:clientHeight={viewport.height} - bind:clientWidth={viewport.width} + use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))} bind:this={element} - on:scroll={handleTimelineScroll} + on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} > - <!-- skeleton --> - {#if showSkeleton} - <div class="mt-8 animate-pulse"> - <div class="mb-2 h-4 w-24 rounded-full bg-immich-primary/20 dark:bg-immich-dark-primary/20" /> - <div class="flex w-[120%] flex-wrap"> - {#each Array.from({ length: 100 }) as _} - <div class="m-[1px] h-[10em] w-[16em] bg-immich-primary/20 dark:bg-immich-dark-primary/20" /> - {/each} - </div> - </div> - {/if} - - {#if element} + <section + use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))} + class:invisible={showSkeleton} + > <slot /> - - <!-- (optional) empty placeholder --> {#if isEmpty} + <!-- (optional) empty placeholder --> <slot name="empty" /> {/if} - <section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}> - {#each $assetStore.buckets as bucket (bucket.bucketDate)} - <IntersectionObserver - on:intersected={intersectedHandler} - on:hidden={() => assetStore.cancelBucket(bucket)} - let:intersecting - top={750} - bottom={750} - root={element} - > - <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}> - {#if intersecting} - <AssetDateGroup - {withStacked} - {showArchiveIcon} - {assetStore} - {assetInteractionStore} - {isSelectionMode} - {singleSelect} - on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)} - on:shift={handleScrollTimeline} - on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} - on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} - assets={bucket.assets} - bucketDate={bucket.bucketDate} - bucketHeight={bucket.bucketHeight} - {viewport} - /> - {/if} - </div> - </IntersectionObserver> - {/each} - </section> - {/if} + </section> + + <section + bind:this={timelineElement} + id="virtual-timeline" + class:invisible={showSkeleton} + style:height={$assetStore.timelineHeight + 'px'} + > + {#each $assetStore.buckets as bucket (bucket.bucketDate)} + {@const isPremeasure = preMeasure.includes(bucket)} + {@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure} + <div + id="bucket" + use:intersectionObserver={{ + onIntersect: () => intersectedHandler(bucket), + onSeparate: () => seperatedHandler(bucket), + top: BUCKET_INTERSECTION_ROOT_TOP, + bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, + root: element, + }} + data-bucket-display={bucket.intersecting} + data-bucket-date={bucket.bucketDate} + style:height={bucket.bucketHeight + 'px'} + > + {#if display && !bucket.measured} + <MeasureDateGroup + {bucket} + {assetStore} + onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))} + ></MeasureDateGroup> + {/if} + + {#if !display || !bucket.measured} + <Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} /> + {/if} + {#if display && bucket.measured} + <AssetDateGroup + assetGridElement={element} + renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP} + renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM} + {withStacked} + {showArchiveIcon} + {assetStore} + {assetInteractionStore} + {isSelectionMode} + {singleSelect} + {onScrollTarget} + {onAssetInGrid} + {bucket} + viewport={safeViewport} + on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)} + on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} + on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} + /> + {/if} + </div> + {/each} + <div class="h-[60px]"></div> + </section> </section> <Portal target="body"> @@ -522,7 +877,7 @@ <style> #asset-grid { - contain: layout; + contain: strict; scrollbar-width: none; } </style> diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte new file mode 100644 index 0000000000..98e423ae94 --- /dev/null +++ b/web/src/lib/components/photos-page/measure-date-group.svelte @@ -0,0 +1,89 @@ +<script lang="ts" context="module"> + const recentTimes: number[] = []; + // TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function adjustTunables(avg: number) {} + function addMeasure(time: number) { + recentTimes.push(time); + if (recentTimes.length > 10) { + recentTimes.shift(); + } + const sum = recentTimes.reduce((acc: number, val: number) => { + return acc + val; + }, 0); + const avg = sum / recentTimes.length; + adjustTunables(avg); + } +</script> + +<script lang="ts"> + import { resizeObserver } from '$lib/actions/resize-observer'; + import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store'; + + export let assetStore: AssetStore; + export let bucket: AssetBucket; + export let onMeasured: () => void; + + async function _measure(element: Element) { + try { + await bucket.complete; + const t1 = Date.now(); + let heightPending = bucket.dateGroups.some((group) => !group.heightActual); + if (heightPending) { + const listener: BucketListener = (event) => { + const { type } = event; + if (type === 'height') { + const { bucket: changedBucket } = event; + if (changedBucket === bucket && type === 'height') { + heightPending = bucket.dateGroups.some((group) => !group.heightActual); + if (!heightPending) { + const height = element.getBoundingClientRect().height; + if (height !== 0) { + $assetStore.updateBucket(bucket.bucketDate, { height: height, measured: true }); + } + + onMeasured(); + $assetStore.removeListener(listener); + const t2 = Date.now(); + + addMeasure((t2 - t1) / bucket.bucketCount); + } + } + } + }; + assetStore.addListener(listener); + } + } catch { + // ignore if complete rejects (canceled load) + } + } + function measure(element: Element) { + void _measure(element); + } +</script> + +<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure> + {#each bucket.dateGroups as dateGroup} + <div id="date-group" data-date-group={dateGroup.date}> + <div + use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: height })} + > + <div + class="flex z-[100] sticky top-[-1px] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" + style:width={dateGroup.geometry.containerWidth + 'px'} + > + <span class="w-full truncate first-letter:capitalize"> + {dateGroup.groupTitle} + </span> + </div> + + <div + class="relative overflow-clip" + style:height={dateGroup.geometry.containerHeight + 'px'} + style:width={dateGroup.geometry.containerWidth + 'px'} + style:visibility={'hidden'} + ></div> + </div> + </div> + {/each} +</section> diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 43c2958944..5bc55796ae 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -1,4 +1,5 @@ <script lang="ts"> + import { resizeObserver } from '$lib/actions/resize-observer'; import Icon from '$lib/components/elements/icon.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; import { memoryStore } from '$lib/stores/memory.store'; @@ -38,7 +39,7 @@ id="memory-lane" bind:this={memoryLaneElement} class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all" - bind:offsetWidth + use:resizeObserver={({ width }) => (offsetWidth = width)} on:scroll={onScroll} > {#if canScrollLeft || canScrollRight} @@ -67,7 +68,7 @@ {/if} </div> {/if} - <div class="inline-block" bind:offsetWidth={innerWidth}> + <div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}> {#each $memoryStore as memory, index (memory.yearsAgo)} {#if memory.assets.length > 0} <a diff --git a/web/src/lib/components/photos-page/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte new file mode 100644 index 0000000000..07836eb4db --- /dev/null +++ b/web/src/lib/components/photos-page/skeleton.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + export let title: string | null = null; + export let height: string | null = null; +</script> + +<div class="overflow-clip" style={`height: ${height}`}> + {#if title} + <div + class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" + > + <span class="w-full truncate first-letter:capitalize">{title}</span> + </div> + {/if} + <div id="skeleton" style={`height: ${height}`}></div> +</div> + +<style> + #skeleton { + background-image: url('/light_skeleton.png'); + background-repeat: repeat; + background-size: 235px, 235px; + } + :global(.dark) #skeleton { + background-image: url('/dark_skeleton.png'); + } + @keyframes delayedVisibility { + to { + visibility: visible; + } + } + #skeleton { + visibility: hidden; + animation: 0s linear 0.1s forwards delayedVisibility; + } +</style> diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index f977d91a99..c7b49f6012 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -4,25 +4,25 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { BucketPosition, Viewport } from '$lib/stores/assets.store'; + import type { Viewport } from '$lib/stores/assets.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; import { calculateWidth } from '$lib/utils/timeline-util'; import { type AssetResponseDto } from '@immich/sdk'; import justifiedLayout from 'justified-layout'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import Portal from '../portal/portal.svelte'; - - const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>(); + import { handlePromiseError } from '$lib/utils'; export let assets: AssetResponseDto[]; export let selectedAssets: Set<AssetResponseDto> = new Set(); export let disableAssetSelect = false; export let showArchiveIcon = false; export let viewport: Viewport; + export let onIntersected: (() => void) | undefined = undefined; export let showAssetName = false; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; @@ -127,18 +127,15 @@ <Thumbnail {asset} readonly={disableAssetSelect} - onClick={async (asset, e) => { - e.preventDefault(); - + onClick={(asset) => { if (isMultiSelectionMode) { selectAssetHandler(asset); return; } - await viewAssetHandler(asset); + void viewAssetHandler(asset); }} - on:select={(e) => selectAssetHandler(e.detail.asset)} - on:intersected={(event) => - i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined} + onSelect={(asset) => selectAssetHandler(asset)} + onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} selected={selectedAssets.has(asset)} {showArchiveIcon} thumbnailWidth={geometry.boxes[i].width} @@ -159,6 +156,15 @@ <!-- Overlay Asset Viewer --> {#if $isViewerOpen} <Portal target="body"> - <AssetViewer asset={$viewingAsset} onAction={handleAction} on:previous={handlePrevious} on:next={handleNext} /> + <AssetViewer + asset={$viewingAsset} + onAction={handleAction} + on:previous={handlePrevious} + on:next={handleNext} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} + /> </Portal> {/if} diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte deleted file mode 100644 index 9282c760c2..0000000000 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ /dev/null @@ -1,183 +0,0 @@ -<script lang="ts"> - import type { AssetStore, AssetBucket } from '$lib/stores/assets.store'; - import type { DateTime } from 'luxon'; - import { fromLocalDateTime } from '$lib/utils/timeline-util'; - import { createEventDispatcher } from 'svelte'; - import { clamp } from 'lodash-es'; - import { locale } from '$lib/stores/preferences.store'; - - export let timelineY = 0; - export let height = 0; - export let assetStore: AssetStore; - - let isHover = false; - let isDragging = false; - let isAnimating = false; - let hoverLabel = ''; - let hoverY = 0; - let clientY = 0; - let windowHeight = 0; - let scrollBar: HTMLElement | undefined; - - const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height; - const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height); - - const HOVER_DATE_HEIGHT = 30; - const MIN_YEAR_LABEL_DISTANCE = 16; - - $: { - hoverY = clamp(height - windowHeight + clientY, 0, height); - if (scrollBar) { - const rect = scrollBar.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + Math.min(hoverY, height - 1); - updateLabel(x, y); - } - } - - $: scrollY = toScrollY(timelineY); - - class Segment { - public count = 0; - public height = 0; - public timeGroup = ''; - public date!: DateTime; - public hasLabel = false; - } - - const calculateSegments = (buckets: AssetBucket[]) => { - let height = 0; - let previous: Segment; - return buckets.map((bucket) => { - const segment = new Segment(); - segment.count = bucket.assets.length; - segment.height = toScrollY(bucket.bucketHeight); - segment.timeGroup = bucket.bucketDate; - segment.date = fromLocalDateTime(segment.timeGroup); - - if (previous?.date.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) { - previous.hasLabel = true; - height = 0; - } - - height += segment.height; - previous = segment; - return segment; - }); - }; - - $: segments = calculateSegments($assetStore.buckets); - - const dispatch = createEventDispatcher<{ scrollTimeline: number }>(); - const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY)); - - const updateLabel = (cursorX: number, cursorY: number) => { - const segment = document.elementsFromPoint(cursorX, cursorY).find(({ id }) => id === 'time-segment'); - if (!segment) { - return; - } - const attr = (segment as HTMLElement).dataset.date; - if (!attr) { - return; - } - hoverLabel = new Date(attr).toLocaleString($locale, { - month: 'short', - year: 'numeric', - timeZone: 'UTC', - }); - }; - - const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => { - const wasDragging = isDragging; - - isDragging = event.isDragging ?? isDragging; - clientY = event.clientY; - - if (wasDragging === false && isDragging) { - scrollTimeline(); - } - - if (!isDragging || isAnimating) { - return; - } - - isAnimating = true; - - window.requestAnimationFrame(() => { - scrollTimeline(); - isAnimating = false; - }); - }; -</script> - -<svelte:window - bind:innerHeight={windowHeight} - on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })} - on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} - on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} -/> - -<!-- svelte-ignore a11y-no-static-element-interactions --> - -{#if $assetStore.timelineHeight > height} - <div - id="immich-scrubbable-scrollbar" - class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize" - style:width={isDragging ? '100vw' : '60px'} - style:height={height + 'px'} - style:background-color={isDragging ? 'transparent' : 'transparent'} - draggable="false" - bind:this={scrollBar} - on:mouseenter={() => (isHover = true)} - on:mouseleave={() => (isHover = false)} - > - {#if isHover || isDragging} - <div - id="time-label" - class="pointer-events-none absolute right-0 z-[100] min-w-24 w-fit whitespace-nowrap rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg" - style:top="{clamp(hoverY - HOVER_DATE_HEIGHT, 0, height - HOVER_DATE_HEIGHT - 2)}px" - > - {hoverLabel} - </div> - {/if} - - <!-- Scroll Position Indicator Line --> - {#if !isDragging} - <div - class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" - style:top="{scrollY}px" - /> - {/if} - <!-- Time Segment --> - {#each segments as segment} - <div - id="time-segment" - class="relative" - data-date={segment.date} - style:height={segment.height + 'px'} - aria-label={segment.timeGroup + ' ' + segment.count} - > - {#if segment.hasLabel} - <div - aria-label={segment.timeGroup + ' ' + segment.count} - class="absolute right-0 bottom-0 z-10 pr-5 text-[12px] dark:text-immich-dark-fg font-immich-mono" - > - {segment.date.year} - </div> - {:else if segment.height > 5} - <div - aria-label={segment.timeGroup + ' ' + segment.count} - class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300" - /> - {/if} - </div> - {/each} - </div> -{/if} - -<style> - #immich-scrubbable-scrollbar, - #time-segment { - contain: layout; - } -</style> diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte new file mode 100644 index 0000000000..e2cc638650 --- /dev/null +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -0,0 +1,281 @@ +<script lang="ts"> + import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets.store'; + import type { DateTime } from 'luxon'; + import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util'; + import { clamp } from 'lodash-es'; + import { onMount } from 'svelte'; + + export let timelineTopOffset = 0; + export let timelineBottomOffset = 0; + export let height = 0; + export let assetStore: AssetStore; + export let invisible = false; + export let scrubOverallPercent: number = 0; + export let scrubBucketPercent: number = 0; + export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined; + export let leadout: boolean = false; + export let onScrub: ScrubberListener | undefined = undefined; + export let startScrub: ScrubberListener | undefined = undefined; + export let stopScrub: ScrubberListener | undefined = undefined; + + let isHover = false; + let isDragging = false; + let hoverLabel: string | undefined; + let bucketDate: string | undefined; + let hoverY = 0; + let clientY = 0; + let windowHeight = 0; + let scrollBar: HTMLElement | undefined; + let segments: Segment[] = []; + + const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2); + const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2); + + const HOVER_DATE_HEIGHT = 31.75; + const MIN_YEAR_LABEL_DISTANCE = 16; + const MIN_DOT_DISTANCE = 8; + + const toScrollFromBucketPercentage = ( + scrubBucket: { bucketDate: string | undefined } | undefined, + scrubBucketPercent: number, + scrubOverallPercent: number, + ) => { + if (scrubBucket) { + let offset = relativeTopOffset; + let match = false; + for (const segment of segments) { + if (segment.bucketDate === scrubBucket.bucketDate) { + offset += scrubBucketPercent * segment.height; + match = true; + break; + } + offset += segment.height; + } + if (!match) { + offset += scrubBucketPercent * relativeBottomOffset; + } + // 2px is the height of the indicator + return offset - 2; + } else if (leadout) { + let offset = relativeTopOffset; + for (const segment of segments) { + offset += segment.height; + } + offset += scrubOverallPercent * relativeBottomOffset; + return offset - 2; + } else { + // 2px is the height of the indicator + return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2; + } + }; + $: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent); + $: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset; + $: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight); + $: relativeBottomOffset = toScrollY(timelineBottomOffset / timelineFullHeight); + + const listener: BucketListener = (event) => { + const { type } = event; + if (type === 'viewport') { + segments = calculateSegments($assetStore.buckets); + scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent); + } + }; + + onMount(() => { + assetStore.addListener(listener); + return () => assetStore.removeListener(listener); + }); + + type Segment = { + count: number; + height: number; + dateFormatted: string; + bucketDate: string; + date: DateTime; + hasLabel: boolean; + hasDot: boolean; + }; + + const calculateSegments = (buckets: AssetBucket[]) => { + let height = 0; + let dotHeight = 0; + + let segments: Segment[] = []; + let previousLabeledSegment: Segment | undefined; + + for (const [i, bucket] of buckets.entries()) { + const scrollBarPercentage = + bucket.bucketHeight / ($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset); + + const segment = { + count: bucket.assets.length, + height: toScrollY(scrollBarPercentage), + bucketDate: bucket.bucketDate, + date: fromLocalDateTime(bucket.bucketDate), + dateFormatted: bucket.bucketDateFormattted, + hasLabel: false, + hasDot: false, + }; + + if (i === 0) { + segment.hasDot = true; + segment.hasLabel = true; + previousLabeledSegment = segment; + } else { + if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) { + height = 0; + segment.hasLabel = true; + previousLabeledSegment = segment; + } + if (i !== 1 && segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) { + segment.hasDot = true; + dotHeight = 0; + } + + height += segment.height; + dotHeight += segment.height; + } + segments.push(segment); + } + + hoverLabel = segments[0]?.dateFormatted; + return segments; + }; + + const updateLabel = (segment: HTMLElement) => { + hoverLabel = segment.dataset.label; + bucketDate = segment.dataset.timeSegmentBucketDate; + }; + + const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => { + const wasDragging = isDragging; + + isDragging = event.isDragging ?? isDragging; + clientY = event.clientY; + + if (!scrollBar) { + return; + } + + const rect = scrollBar.getBoundingClientRect()!; + const lower = 0; + const upper = rect?.height - HOVER_DATE_HEIGHT * 2; + hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper); + const x = rect!.left + rect!.width / 2; + const elems = document.elementsFromPoint(x, clientY); + const segment = elems.find(({ id }) => id === 'time-segment'); + let bucketPercentY = 0; + if (segment) { + updateLabel(segment as HTMLElement); + const sr = segment.getBoundingClientRect(); + const sy = sr.y; + const relativeY = clientY - sy; + bucketPercentY = relativeY / sr.height; + } else { + const leadin = elems.find(({ id }) => id === 'lead-in'); + if (leadin) { + updateLabel(leadin as HTMLElement); + } else { + bucketDate = undefined; + bucketPercentY = 0; + } + } + + const scrollPercent = toTimelineY(hoverY); + if (wasDragging === false && isDragging) { + void startScrub?.(bucketDate, scrollPercent, bucketPercentY); + void onScrub?.(bucketDate, scrollPercent, bucketPercentY); + } + + if (wasDragging && !isDragging) { + void stopScrub?.(bucketDate, scrollPercent, bucketPercentY); + return; + } + + if (!isDragging) { + return; + } + + void onScrub?.(bucketDate, scrollPercent, bucketPercentY); + }; +</script> + +<svelte:window + bind:innerHeight={windowHeight} + on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })} + on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} + on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} +/> + +<!-- svelte-ignore a11y-no-static-element-interactions --> + +<div + id="immich-scrubbable-scrollbar" + class={`absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize`} + style:padding-top={HOVER_DATE_HEIGHT + 'px'} + style:padding-bottom={HOVER_DATE_HEIGHT + 'px'} + class:invisible + style:width={isDragging ? '100vw' : '60px'} + style:height={height + 'px'} + style:background-color={isDragging ? 'transparent' : 'transparent'} + draggable="false" + bind:this={scrollBar} + on:mouseenter={() => (isHover = true)} + on:mouseleave={() => (isHover = false)} +> + {#if hoverLabel && (isHover || isDragging)} + <div + id="time-label" + class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg" + style:top="{hoverY + 2}px" + > + {hoverLabel} + </div> + {/if} + <!-- Scroll Position Indicator Line --> + {#if !isDragging} + <div + class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" + style:top="{scrollY + HOVER_DATE_HEIGHT}px" + /> + {/if} + <div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}> + {#if relativeTopOffset > 6} + <div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300" /> + {/if} + </div> + <!-- Time Segment --> + {#each segments as segment} + <div + id="time-segment" + class="relative" + data-time-segment-bucket-date={segment.date} + data-label={segment.dateFormatted} + style:height={segment.height + 'px'} + aria-label={segment.dateFormatted + ' ' + segment.count} + > + {#if segment.hasLabel} + <div + aria-label={segment.dateFormatted + ' ' + segment.count} + class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono" + > + {segment.date.year} + </div> + {/if} + {#if segment.hasDot} + <div + aria-label={segment.dateFormatted + ' ' + segment.count} + class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300" + /> + {/if} + </div> + {/each} + <div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div> +</div> + +<style> + #immich-scrubbable-scrollbar, + #time-segment { + contain: layout size style; + } +</style> diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index fcf68fdb91..2f1efc487c 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -4,7 +4,8 @@ import Portal from '$lib/components/shared-components/portal/portal.svelte'; import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { suggestDuplicateByFileSize } from '$lib/utils'; + import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils'; + import { navigate } from '$lib/utils/navigation'; import { shortcuts } from '$lib/actions/shortcut'; import { type AssetResponseDto } from '@immich/sdk'; import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js'; @@ -158,7 +159,10 @@ const index = getAssetIndex($viewingAsset.id) - 1 + assets.length; setAsset(assets[index % assets.length]); }} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} /> </Portal> {/await} diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index cabe2e85a1..2e6e44511d 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,4 +1,5 @@ import { getKey } from '$lib/utils'; +import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { readonly, writable } from 'svelte/store'; @@ -6,6 +7,7 @@ function createAssetViewingStore() { const viewingAssetStoreState = writable<AssetResponseDto>(); const preloadAssets = writable<AssetResponseDto[]>([]); const viewState = writable<boolean>(false); + const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => { preloadAssets.set(assetsToPreload); @@ -26,6 +28,7 @@ function createAssetViewingStore() { asset: readonly(viewingAssetStoreState), preloadAssets: readonly(preloadAssets), isViewing: viewState, + gridScrollTarget, setAsset, setAssetId, showAssetViewer, diff --git a/web/src/lib/stores/asset.store.spec.ts b/web/src/lib/stores/asset.store.spec.ts index 3fd9e1e981..7787bf794d 100644 --- a/web/src/lib/stores/asset.store.spec.ts +++ b/web/src/lib/stores/asset.store.spec.ts @@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { AbortError } from '$lib/utils'; import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; import { assetFactory } from '@test-data/factories/asset-factory'; -import { AssetStore, BucketPosition } from './assets.store'; +import { AssetStore } from './assets.store'; describe('AssetStore', () => { beforeEach(() => { @@ -26,7 +26,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('should load buckets in viewport', () => { @@ -38,15 +39,15 @@ describe('AssetStore', () => { it('calculates bucket height', () => { expect(assetStore.buckets).toEqual( expect.arrayContaining([ - expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }), - expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }), - expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }), + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }), + expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(4230); + expect(assetStore.timelineHeight).toBe(4383); }); }); @@ -72,35 +73,28 @@ describe('AssetStore', () => { return bucketAssets[timeBucket]; }); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('loads a bucket', async () => { expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3); }); it('ignores invalid buckets', async () => { - await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2023-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(0); }); - it('only updates the position of loaded buckets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown); - - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible); - }); - it('cancels bucket loading', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort'); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); expect(abortSpy).toBeCalledTimes(1); await loadPromise; @@ -109,24 +103,24 @@ describe('AssetStore', () => { it('prevents loading buckets multiple times', async () => { await Promise.all([ - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), ]); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); }); it('allows loading a canceled bucket', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); await loadPromise; expect(bucket?.assets.length).toEqual(0); - await assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket!.bucketDate); expect(bucket!.assets.length).toEqual(3); }); }); @@ -137,7 +131,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('is empty initially', () => { @@ -219,7 +214,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores non-existing assets', () => { @@ -263,7 +259,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores invalid IDs', () => { @@ -312,7 +309,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid assetId', async () => { @@ -321,15 +319,15 @@ describe('AssetStore', () => { }); it('returns previous assetId', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]); }); it('returns previous assetId spanning multiple buckets', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); @@ -337,7 +335,7 @@ describe('AssetStore', () => { }); it('loads previous bucket', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); @@ -347,9 +345,9 @@ describe('AssetStore', () => { }); it('skips removed assets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const [assetOne, assetTwo, assetThree] = assetStore.assets; assetStore.removeAssets([assetTwo.id]); @@ -357,7 +355,7 @@ describe('AssetStore', () => { }); it('returns null when no more assets', async () => { - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull(); }); }); @@ -368,7 +366,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid buckets', () => { diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 1022729e91..7fd82b4c3a 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -1,6 +1,11 @@ +import { locale } from '$lib/stores/preferences.store'; import { getKey } from '$lib/utils'; -import { fromLocalDateTime } from '$lib/utils/timeline-util'; -import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager'; +import { getAssetRatio } from '$lib/utils/asset-utils'; +import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; +import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util'; +import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import createJustifiedLayout from 'justified-layout'; import { throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; import { websocketEvents } from './websocket'; -export enum BucketPosition { - Above = 'above', - Below = 'below', - Visible = 'visible', - Unknown = 'unknown', -} type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0]; export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>; +const LAYOUT_OPTIONS = { + boxSpacing: 2, + containerPadding: 0, + targetRowHeightTolerance: 0.15, + targetRowHeight: 235, +}; + export interface Viewport { width: number; height: number; } +export type ViewportXY = Viewport & { + x: number; + y: number; +}; interface AssetLookup { bucket: AssetBucket; @@ -29,16 +39,89 @@ interface AssetLookup { } export class AssetBucket { + store!: AssetStore; + bucketDate!: string; /** * The DOM height of the bucket in pixel * This value is first estimated by the number of asset and later is corrected as the user scroll */ - bucketHeight!: number; - bucketDate!: string; - bucketCount!: number; - assets!: AssetResponseDto[]; - cancelToken!: AbortController | null; - position!: BucketPosition; + bucketHeight: number = 0; + isBucketHeightActual: boolean = false; + bucketDateFormattted!: string; + bucketCount: number = 0; + assets: AssetResponseDto[] = []; + dateGroups: DateGroup[] = []; + cancelToken: AbortController | undefined; + /** + * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset. + */ + isPreventCancel: boolean = false; + /** + * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. + */ + complete!: Promise<void>; + loading: boolean = false; + isLoaded: boolean = false; + intersecting: boolean = false; + measured: boolean = false; + measuredPromise!: Promise<void>; + + constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) { + Object.assign(this, props); + this.init(); + } + + private init() { + // create a promise, and store its resolve/reject callbacks. The loadedSignal callback + // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal + // callback will be called if the bucket is canceled before it was loaded, rejecting the + // promise. + this.complete = new Promise((resolve, reject) => { + this.loadedSignal = resolve; + this.canceledSignal = reject; + }); + // if no-one waits on complete, and its rejected a uncaught rejection message is logged. + // We this message with an empty reject handler, since waiting on a bucket is optional. + this.complete.catch(() => void 0); + this.measuredPromise = new Promise((resolve) => { + this.measuredSignal = resolve; + }); + + this.bucketDateFormattted = fromLocalDateTime(this.bucketDate) + .startOf('month') + .toJSDate() + .toLocaleString(get(locale), { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + } + + private loadedSignal: (() => void) | undefined; + private canceledSignal: (() => void) | undefined; + measuredSignal: (() => void) | undefined; + + cancel() { + if (this.isLoaded) { + return; + } + if (this.isPreventCancel) { + return; + } + this.cancelToken?.abort(); + this.canceledSignal?.(); + this.init(); + } + + loaded() { + this.loadedSignal?.(); + this.isLoaded = true; + } + + errored() { + this.canceledSignal?.(); + this.init(); + } } const isMismatched = (option: boolean | undefined, value: boolean): boolean => @@ -65,34 +148,101 @@ interface TrashAssets { type: 'trash'; values: string[]; } +interface UpdateStackAssets { + type: 'update_stack_assets'; + values: string[]; +} export const photoViewer = writable<HTMLImageElement | null>(null); -type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets; +type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets; + +export type BucketListener = ( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, +) => void; + +type ViewPortEvent = { + type: 'viewport'; +}; +type BucketLoadEvent = { + type: 'load'; + bucket: AssetBucket; +}; +type BucketLoadedEvent = { + type: 'loaded'; + bucket: AssetBucket; +}; +type BucketCancelEvent = { + type: 'cancel'; + bucket: AssetBucket; +}; +type BucketHeightEvent = { + type: 'bucket-height'; + bucket: AssetBucket; + delta: number; +}; +type DateGroupIntersecting = { + type: 'intersecting'; + bucket: AssetBucket; + dateGroup: DateGroup; +}; +type DateGroupHeightEvent = { + type: 'height'; + bucket: AssetBucket; + dateGroup: DateGroup; + delta: number; + height: number; +}; export class AssetStore { - private store$ = writable(this); private assetToBucket: Record<string, AssetLookup> = {}; private pendingChanges: PendingChange[] = []; private unsubscribers: Unsubscriber[] = []; private options: AssetApiGetTimeBucketsRequest; + private viewport: Viewport = { + height: 0, + width: 0, + }; + private initializedSignal!: () => void; + private store$ = writable(this); + lastScrollTime: number = 0; + subscribe = this.store$.subscribe; + /** + * A promise that resolves once the store is initialized. + */ + taskManager = new AssetGridTaskManager(this); + complete!: Promise<void>; initialized = false; timelineHeight = 0; buckets: AssetBucket[] = []; assets: AssetResponseDto[] = []; albumAssets: Set<string> = new Set(); + pendingScrollBucket: AssetBucket | undefined; + pendingScrollAssetId: string | undefined; + + listeners: BucketListener[] = []; constructor( options: AssetStoreOptions, private albumId?: string, ) { this.options = { ...options, size: TimeBucketSize.Month }; + // create a promise, and store its resolve callbacks. The initializedSignal callback + // will be invoked when a the assetstore is initialized. + this.complete = new Promise((resolve) => { + this.initializedSignal = resolve; + }); this.store$.set(this); } - subscribe = this.store$.subscribe; - private addPendingChanges(...changes: PendingChange[]) { // prevent websocket events from happening before local client events setTimeout(() => { @@ -182,8 +332,35 @@ export class AssetStore { this.emit(true); }, 2500); - async init(viewport: Viewport) { - this.initialized = false; + addListener(bucketListener: BucketListener) { + this.listeners.push(bucketListener); + } + removeListener(bucketListener: BucketListener) { + this.listeners = this.listeners.filter((l) => l != bucketListener); + } + private notifyListeners( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, + ) { + for (const fn of this.listeners) { + fn(event); + } + } + async init({ bucketListener }: { bucketListener?: BucketListener } = {}) { + if (this.initialized) { + throw 'Can only init once'; + } + if (bucketListener) { + this.addListener(bucketListener); + } + // uncaught rejection go away + this.complete.catch(() => void 0); this.timelineHeight = 0; this.buckets = []; this.assets = []; @@ -194,65 +371,118 @@ export class AssetStore { ...this.options, key: getKey(), }); - + this.buckets = timebuckets.map( + (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }), + ); + this.initializedSignal(); this.initialized = true; - - this.buckets = timebuckets.map((bucket) => ({ - bucketDate: bucket.timeBucket, - bucketHeight: 0, - bucketCount: bucket.count, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - })); - - // if loading an asset, the grid-view may be hidden, which means - // it has 0 width and height. No need to update bucket or timeline - // heights in this case. Later, updateViewport will be called to - // update the heights. - if (viewport.height !== 0 && viewport.width !== 0) { - await this.updateViewport(viewport); - } } - async updateViewport(viewport: Viewport) { + public destroy() { + this.taskManager.destroy(); + this.listeners = []; + this.initialized = false; + } + + async updateViewport(viewport: Viewport, force?: boolean) { + if (!this.initialized) { + return; + } + if (viewport.height === 0 && viewport.width === 0) { + return; + } + + if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) { + return; + } + + // changing width invalidates the actual height, and needs to be remeasured, since width changes causes + // layout reflows. + const changedWidth = this.viewport.width != viewport.width; + this.viewport = { ...viewport }; + for (const bucket of this.buckets) { - const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); - const rows = Math.ceil(unwrappedWidth / viewport.width); - const height = rows * THUMBNAIL_HEIGHT; - bucket.bucketHeight = height; + this.updateGeometry(bucket, changedWidth); } this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); - let height = 0; const loaders = []; + let height = 0; for (const bucket of this.buckets) { - if (height < viewport.height) { - height += bucket.bucketHeight; - loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible)); - continue; + if (height >= viewport.height) { + break; } - break; + height += bucket.bucketHeight; + loaders.push(this.loadBucket(bucket.bucketDate)); } await Promise.all(loaders); + this.notifyListeners({ type: 'viewport' }); this.emit(false); } - async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> { + private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { + if (invalidateHeight) { + bucket.isBucketHeightActual = false; + bucket.measured = false; + for (const assetGroup of bucket.dateGroups) { + assetGroup.heightActual = false; + } + } + if (!bucket.isBucketHeightActual) { + const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = 51 + rows * THUMBNAIL_HEIGHT; + bucket.bucketHeight = height; + } + + for (const assetGroup of bucket.dateGroups) { + if (!assetGroup.heightActual) { + const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = rows * THUMBNAIL_HEIGHT; + assetGroup.height = height; + } + + const layoutResult = createJustifiedLayout( + assetGroup.assets.map((g) => getAssetRatio(g)), + { + ...LAYOUT_OPTIONS, + containerWidth: Math.floor(this.viewport.width), + }, + ); + assetGroup.geometry = { + ...layoutResult, + containerWidth: calculateWidth(layoutResult.boxes), + }; + } + } + + async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise<void> { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { return; } - - bucket.position = position; - - if (bucket.cancelToken || bucket.assets.length > 0) { - this.emit(false); + if (bucket.bucketCount === bucket.assets.length) { + // already loaded return; } - bucket.cancelToken = new AbortController(); + if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) { + // if promise is pending, and preventCancel is requested, then don't overwrite it + if (!bucket.isPreventCancel && options.preventCancel) { + bucket.isPreventCancel = options.preventCancel; + } + await bucket.complete; + return; + } + if (options.pending) { + this.pendingScrollBucket = bucket; + } + this.notifyListeners({ type: 'load', bucket }); + bucket.isPreventCancel = !!options.preventCancel; + + const cancelToken = (bucket.cancelToken = new AbortController()); try { const assets = await getTimeBucket( { @@ -260,9 +490,14 @@ export class AssetStore { timeBucket: bucketDate, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } + if (this.albumId) { const albumAssets = await getTimeBucket( { @@ -271,50 +506,87 @@ export class AssetStore { size: this.options.size, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); - + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } for (const asset of albumAssets) { this.albumAssets.add(asset.id); } } - if (bucket.cancelToken.signal.aborted) { + bucket.assets = assets; + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); + bucket.loaded(); + this.notifyListeners({ type: 'loaded', bucket }); + } catch (error) { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if ((error as any).name === 'AbortError') { return; } - - bucket.assets = assets; - - this.emit(true); - } catch (error) { const $t = get(t); handleError(error, $t('errors.failed_to_load_assets')); + bucket.errored(); } finally { - bucket.cancelToken = null; + bucket.cancelToken = undefined; + this.emit(true); } } - cancelBucket(bucket: AssetBucket) { - bucket.cancelToken?.abort(); - } - - updateBucket(bucketDate: string, height: number) { + updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { - return 0; + return {}; + } + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + delta = height - bucket.bucketHeight; + bucket.isBucketHeightActual = true; + bucket.bucketHeight = height; + this.timelineHeight += delta; + this.notifyListeners({ type: 'bucket-height', bucket, delta }); + } + if ('intersecting' in properties) { + bucket.intersecting = properties.intersecting!; + } + if ('measured' in properties) { + if (properties.measured) { + bucket.measuredSignal?.(); + } + bucket.measured = properties.measured!; } - - const delta = height - bucket.bucketHeight; - const scrollTimeline = bucket.position == BucketPosition.Above; - - bucket.bucketHeight = height; - bucket.position = BucketPosition.Unknown; - - this.timelineHeight += delta; - this.emit(false); + return { delta }; + } - return scrollTimeline ? delta : 0; + updateBucketDateGroup( + bucket: AssetBucket, + dateGroup: DateGroup, + properties: { height?: number; intersecting?: boolean }, + ) { + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + if (height > 0) { + delta = height - dateGroup.height; + dateGroup.heightActual = true; + dateGroup.height = height; + this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height }); + } + } + if ('intersecting' in properties) { + dateGroup.intersecting = properties.intersecting!; + if (dateGroup.intersecting) { + this.notifyListeners({ type: 'intersecting', bucket, dateGroup }); + } + } + this.emit(false); + return { delta }; } addAssets(assets: AssetResponseDto[]) { @@ -354,15 +626,7 @@ export class AssetStore { let bucket = this.getBucketByDate(timeBucket); if (!bucket) { - bucket = { - bucketDate: timeBucket, - bucketHeight: THUMBNAIL_HEIGHT, - bucketCount: 0, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - }; - + bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT }); this.buckets.push(bucket); } @@ -383,6 +647,8 @@ export class AssetStore { const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); return bDate.diff(aDate).milliseconds; }); + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); } this.emit(true); @@ -392,18 +658,73 @@ export class AssetStore { return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; } - async getBucketInfoForAssetId({ id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>) { + async findAndLoadBucketAsPending(id: string) { const bucketInfo = this.assetToBucket[id]; if (bucketInfo) { - return bucketInfo; + const bucket = bucketInfo.bucket; + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = id; + this.emit(false); + return bucket; } + const asset = await getAssetInfo({ id }); + if (asset) { + if (this.options.isArchived !== asset.isArchived) { + return; + } + const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true }); + if (bucket) { + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = asset.id; + this.emit(false); + } + return bucket; + } + } + + /* Must be paired with matching clearPendingScroll() call */ + async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) { + try { + const { at: assetId } = scrollTarget; + if (assetId) { + await this.complete; + const bucket = await this.findAndLoadBucketAsPending(assetId); + if (bucket) { + return; + } + } + } catch { + // failure + } + onFailure(); + } + + clearPendingScroll() { + this.pendingScrollBucket = undefined; + this.pendingScrollAssetId = undefined; + } + + private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) { let date = fromLocalDateTime(localDateTime); if (this.options.size == TimeBucketSize.Month) { date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); } else if (this.options.size == TimeBucketSize.Day) { date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); } - await this.loadBucket(date.toISO()!, BucketPosition.Unknown); + const iso = date.toISO()!; + await this.loadBucket(iso, options); + return this.getBucketByDate(iso); + } + + private async getBucketInfoForAsset( + { id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>, + options: { preventCancel?: boolean; pending?: boolean } = {}, + ) { + const bucketInfo = this.assetToBucket[id]; + if (bucketInfo) { + return bucketInfo; + } + await this.loadBucketAtTime(localDateTime, options); return this.assetToBucket[id] || null; } @@ -417,7 +738,7 @@ export class AssetStore { ); for (const bucket of this.buckets) { if (index < bucket.bucketCount) { - await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(bucket.bucketDate); return bucket.assets[index] || null; } @@ -458,6 +779,7 @@ export class AssetStore { // Iterate in reverse to allow array splicing. for (let index = this.buckets.length - 1; index >= 0; index--) { const bucket = this.buckets[index]; + let changed = false; for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) { const asset = bucket.assets[index_]; if (!idSet.has(asset.id)) { @@ -465,17 +787,22 @@ export class AssetStore { } bucket.assets.splice(index_, 1); + changed = true; if (bucket.assets.length === 0) { this.buckets.splice(index, 1); } } + if (changed) { + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + } } this.emit(true); } async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -491,12 +818,12 @@ export class AssetStore { } const previousBucket = this.buckets[bucketIndex - 1]; - await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(previousBucket.bucketDate); return previousBucket.assets.at(-1) || null; } async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -512,7 +839,7 @@ export class AssetStore { } const nextBucket = this.buckets[bucketIndex + 1]; - await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(nextBucket.bucketDate); return nextBucket.assets[0] || null; } @@ -537,8 +864,7 @@ export class AssetStore { } this.assetToBucket = assetToBucket; } - - this.store$.update(() => this); + this.store$.set(this); } } diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts new file mode 100644 index 0000000000..6ece1327c4 --- /dev/null +++ b/web/src/lib/utils/asset-store-task-manager.ts @@ -0,0 +1,465 @@ +import type { AssetBucket, AssetStore } from '$lib/stores/assets.store'; +import { generateId } from '$lib/utils/generate-id'; +import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support'; +import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue'; +import { type DateGroup } from '$lib/utils/timeline-util'; +import { TUNABLES } from '$lib/utils/tunables'; +import { type AssetResponseDto } from '@immich/sdk'; +import { clamp } from 'lodash-es'; + +type Task = () => void; + +class InternalTaskManager { + assetStore: AssetStore; + componentTasks = new Map<string, Set<string>>(); + priorityQueue = new KeyedPriorityQueue<string, Task>(); + idleQueue = new Map<string, Task>(); + taskCleaners = new Map<string, Task>(); + + queueTimer: ReturnType<typeof setTimeout> | undefined; + lastIdle: number | undefined; + + constructor(assetStore: AssetStore) { + this.assetStore = assetStore; + } + destroy() { + this.componentTasks.clear(); + this.priorityQueue.clear(); + this.idleQueue.clear(); + this.taskCleaners.clear(); + clearTimeout(this.queueTimer); + if (this.lastIdle) { + cancelIdleCB(this.lastIdle); + } + } + getOrCreateComponentTasks(componentId: string) { + let componentTaskSet = this.componentTasks.get(componentId); + if (!componentTaskSet) { + componentTaskSet = new Set<string>(); + this.componentTasks.set(componentId, componentTaskSet); + } + + return componentTaskSet; + } + deleteFromComponentTasks(componentId: string, taskId: string) { + if (this.componentTasks.has(componentId)) { + const componentTaskSet = this.componentTasks.get(componentId); + componentTaskSet?.delete(taskId); + if (componentTaskSet?.size === 0) { + this.componentTasks.delete(componentId); + } + } + } + + drainIntersectedQueue() { + let count = 0; + for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) { + t.value(); + if (this.taskCleaners.has(t.key)) { + this.taskCleaners.get(t.key)!(); + this.taskCleaners.delete(t.key); + } + if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) { + this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS); + break; + } + } + } + + scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) { + clearTimeout(this.queueTimer); + this.queueTimer = setTimeout(() => { + const delta = Date.now() - this.assetStore.lastScrollTime; + if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) { + let amount = clamp( + 1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR), + 1, + TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2, + ); + + const nextDelay = clamp( + amount > 1 + ? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR) + : TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS, + TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY, + TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY, + ); + + while (amount > 0) { + this.priorityQueue.shift()?.value(); + amount--; + } + if (this.priorityQueue.length > 0) { + this.scheduleDrainIntersectedQueue(nextDelay); + } + } else { + this.drainIntersectedQueue(); + } + }, delay); + } + + removeAllTasksForComponent(componentId: string) { + if (this.componentTasks.has(componentId)) { + const tasksIds = this.componentTasks.get(componentId) || []; + for (const taskId of tasksIds) { + this.priorityQueue.remove(taskId); + this.idleQueue.delete(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + } + } + this.componentTasks.delete(componentId); + } + + queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority = 10, + taskId = generateId(), + }: { + task: Task; + cleanup?: Task; + componentId: string; + priority?: number; + taskId?: string; + }) { + this.priorityQueue.push(taskId, task, priority); + if (cleanup) { + this.taskCleaners.set(taskId, cleanup); + } + this.getOrCreateComponentTasks(componentId).add(taskId); + const lastTime = this.assetStore.lastScrollTime; + const delta = Date.now() - lastTime; + if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) { + this.scheduleDrainIntersectedQueue(); + } else { + // flush the queue early + clearTimeout(this.queueTimer); + this.drainIntersectedQueue(); + } + } + + scheduleDrainSeparatedQueue() { + if (this.lastIdle) { + cancelIdleCB(this.lastIdle); + } + this.lastIdle = idleCB( + () => { + let count = 0; + let entry = this.idleQueue.entries().next().value; + while (entry) { + const [taskId, task] = entry; + this.idleQueue.delete(taskId); + task(); + if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) { + break; + } + entry = this.idleQueue.entries().next().value; + } + if (this.idleQueue.size > 0) { + this.scheduleDrainSeparatedQueue(); + } + }, + { timeout: 1000 }, + ); + } + queueSeparateTask({ + task, + cleanup, + componentId, + taskId, + }: { + task: Task; + cleanup: Task; + componentId: string; + taskId: string; + }) { + this.idleQueue.set(taskId, task); + this.taskCleaners.set(taskId, cleanup); + this.getOrCreateComponentTasks(componentId).add(taskId); + this.scheduleDrainSeparatedQueue(); + } + + removeIntersectedTask(taskId: string) { + const removed = this.priorityQueue.remove(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + return removed; + } + + removeSeparateTask(taskId: string) { + const removed = this.idleQueue.delete(taskId); + if (this.taskCleaners.has(taskId)) { + const cleanup = this.taskCleaners.get(taskId); + this.taskCleaners.delete(taskId); + cleanup!(); + } + return removed; + } +} + +export class AssetGridTaskManager { + private internalManager: InternalTaskManager; + constructor(assetStore: AssetStore) { + this.internalManager = new InternalTaskManager(assetStore); + } + + tasks: Map<AssetBucket, BucketTask> = new Map(); + + queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority = 10, + taskId = generateId(), + }: { + task: Task; + cleanup?: Task; + componentId: string; + priority?: number; + taskId?: string; + }) { + return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId }); + } + + removeAllTasksForComponent(componentId: string) { + return this.internalManager.removeAllTasksForComponent(componentId); + } + + destroy() { + return this.internalManager.destroy(); + } + + private getOrCreateBucketTask(bucket: AssetBucket) { + let bucketTask = this.tasks.get(bucket); + if (!bucketTask) { + bucketTask = this.createBucketTask(bucket); + } + return bucketTask; + } + + private createBucketTask(bucket: AssetBucket) { + const bucketTask = new BucketTask(this.internalManager, this, bucket); + this.tasks.set(bucket, bucketTask); + return bucketTask; + } + + intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleIntersected(componentId, task); + } + + seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleSeparated(componentId, seperated); + } + + intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.intersectedDateGroup(componentId, dateGroup, intersected); + } + + seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.separatedDateGroup(componentId, dateGroup, seperated); + } + + intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.intersectedThumbnail(componentId, asset, intersected); + } + + seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.separatedThumbnail(componentId, asset, seperated); + } +} + +class IntersectionTask { + internalTaskManager: InternalTaskManager; + seperatedKey; + intersectedKey; + priority; + + intersected: Task | undefined; + separated: Task | undefined; + + constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) { + this.internalTaskManager = internalTaskManager; + this.seperatedKey = keyPrefix + ':s:' + key; + this.intersectedKey = keyPrefix + ':i:' + key; + this.priority = priority; + } + + trackIntersectedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.separated) { + return; + } + task?.(); + }; + this.intersected = execTask; + const cleanup = () => { + this.intersected = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey); + }; + return { task: execTask, cleanup }; + } + + trackSeperatedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.intersected) { + return; + } + task?.(); + }; + this.separated = execTask; + const cleanup = () => { + this.separated = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey); + }; + return { task: execTask, cleanup }; + } + + removePendingSeparated() { + if (this.separated) { + this.internalTaskManager.removeSeparateTask(this.seperatedKey); + } + } + removePendingIntersected() { + if (this.intersected) { + this.internalTaskManager.removeIntersectedTask(this.intersectedKey); + } + } + + scheduleIntersected(componentId: string, intersected: Task) { + this.removePendingSeparated(); + if (this.intersected) { + return; + } + const { task, cleanup } = this.trackIntersectedTask(componentId, intersected); + this.internalTaskManager.queueScrollSensitiveTask({ + task, + cleanup, + componentId: componentId, + priority: this.priority, + taskId: this.intersectedKey, + }); + } + + scheduleSeparated(componentId: string, separated: Task) { + this.removePendingIntersected(); + + if (this.separated) { + return; + } + + const { task, cleanup } = this.trackSeperatedTask(componentId, separated); + this.internalTaskManager.queueSeparateTask({ + task, + cleanup, + componentId: componentId, + taskId: this.seperatedKey, + }); + } +} +class BucketTask extends IntersectionTask { + assetBucket: AssetBucket; + assetGridTaskManager: AssetGridTaskManager; + // indexed by dateGroup's date + dateTasks: Map<DateGroup, DateGroupTask> = new Map(); + + constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) { + super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY); + this.assetBucket = assetBucket; + this.assetGridTaskManager = parent; + } + + getOrCreateDateGroupTask(dateGroup: DateGroup) { + let dateGroupTask = this.dateTasks.get(dateGroup); + if (!dateGroupTask) { + dateGroupTask = this.createDateGroupTask(dateGroup); + } + return dateGroupTask; + } + + createDateGroupTask(dateGroup: DateGroup) { + const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup); + this.dateTasks.set(dateGroup, dateGroupTask); + return dateGroupTask; + } + + removePendingSeparated() { + super.removePendingSeparated(); + for (const dateGroupTask of this.dateTasks.values()) { + dateGroupTask.removePendingSeparated(); + } + } + + intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { + const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.scheduleIntersected(componentId, intersected); + } + + separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) { + const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.scheduleSeparated(componentId, separated); + } +} +class DateGroupTask extends IntersectionTask { + dateGroup: DateGroup; + bucketTask: BucketTask; + // indexed by thumbnail's asset + thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map(); + + constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) { + super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY); + this.dateGroup = dateGroup; + this.bucketTask = parent; + } + + removePendingSeparated() { + super.removePendingSeparated(); + for (const thumbnailTask of this.thumbnailTasks.values()) { + thumbnailTask.removePendingSeparated(); + } + } + + getOrCreateThumbnailTask(asset: AssetResponseDto) { + let thumbnailTask = this.thumbnailTasks.get(asset); + if (!thumbnailTask) { + thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset); + this.thumbnailTasks.set(asset, thumbnailTask); + } + return thumbnailTask; + } + + intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleIntersected(componentId, intersected); + } + + separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleSeparated(componentId, seperated); + } +} +class ThumbnailTask extends IntersectionTask { + asset: AssetResponseDto; + dateGroupTask: DateGroupTask; + + constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) { + super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY); + this.asset = asset; + this.dateGroupTask = parent; + } +} diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 2722745317..576b14b201 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -4,7 +4,7 @@ import { NotificationType, notificationController } from '$lib/components/shared import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; -import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; +import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; @@ -403,7 +403,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt try { for (const bucket of assetStore.buckets) { - await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket.bucketDate); if (!get(isSelectingAllAssets)) { break; // Cancelled diff --git a/web/src/lib/utils/idle-callback-support.ts b/web/src/lib/utils/idle-callback-support.ts new file mode 100644 index 0000000000..0f7f060084 --- /dev/null +++ b/web/src/lib/utils/idle-callback-support.ts @@ -0,0 +1,20 @@ +interface RequestIdleCallback { + didTimeout?: boolean; + timeRemaining?(): DOMHighResTimeStamp; +} +interface RequestIdleCallbackOptions { + timeout?: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) { + const start = Date.now(); + return setTimeout(cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }), 100); +} + +function fake_cancelIdleCallback(id: number) { + return clearTimeout(id); +} + +export const idleCB = window.requestIdleCallback || fake_requestIdleCallback; +export const cancelIdleCB = window.cancelIdleCallback || fake_cancelIdleCallback; diff --git a/web/src/lib/utils/keyed-priority-queue.ts b/web/src/lib/utils/keyed-priority-queue.ts new file mode 100644 index 0000000000..2483b22c6d --- /dev/null +++ b/web/src/lib/utils/keyed-priority-queue.ts @@ -0,0 +1,50 @@ +export class KeyedPriorityQueue<K, T> { + private items: { key: K; value: T; priority: number }[] = []; + private set: Set<K> = new Set(); + + clear() { + this.items = []; + this.set.clear(); + } + + remove(key: K) { + const removed = this.set.delete(key); + if (removed) { + const idx = this.items.findIndex((i) => i.key === key); + if (idx >= 0) { + this.items.splice(idx, 1); + } + } + return removed; + } + + push(key: K, value: T, priority: number) { + if (this.set.has(key)) { + return this.length; + } + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.set.add(key); + this.items.splice(i, 0, { key, value, priority }); + return this.length; + } + } + this.set.add(key); + return this.items.push({ key, value, priority }); + } + + shift() { + let item = this.items.shift(); + while (item) { + if (this.set.has(item.key)) { + this.set.delete(item.key); + return item; + } + item = this.items.shift(); + } + } + + get length() { + return this.set.size; + } +} diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 4d5660f173..304376b347 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk'; import type { NavigationTarget } from '@sveltejs/kit'; import { get } from 'svelte/store'; +export type AssetGridRouteSearchParams = { + at: string | null | undefined; +}; export const isExternalUrl = (url: string): boolean => { return new URL(url, window.location.href).origin !== window.location.origin; }; @@ -33,17 +36,38 @@ function currentUrlWithoutAsset() { export function currentUrlReplaceAssetId(assetId: string) { const $page = get(page); + const params = new URLSearchParams($page.url.search); + // always remove the assetGridScrollTargetParams + params.delete('at'); + const searchparams = params.size > 0 ? '?' + params.toString() : ''; // this contains special casing for the /photos/:assetId photos route, which hangs directly // off / instead of a subpath, unlike every other asset-containing route. return isPhotosRoute($page.route.id) - ? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}` - : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`; + ? `${AppRoute.PHOTOS}/${assetId}${searchparams}` + : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`; +} + +function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) { + const $page = get(page); + const parsed = new URL(url, $page.url); + + const { at: assetId } = searchParams || { at: null }; + + if (!assetId) { + return parsed.pathname; + } + + const params = new URLSearchParams($page.url.search); + if (assetId) { + params.set('at', assetId); + } + return parsed.pathname + '?' + params.toString(); } function currentUrl() { const $page = get(page); const current = $page.url; - return current.pathname + current.search; + return current.pathname + current.search + current.hash; } interface Route { @@ -55,24 +79,58 @@ interface Route { interface AssetRoute extends Route { targetRoute: 'current'; - assetId: string | null; + assetId: string | null | undefined; } +interface AssetGridRoute extends Route { + targetRoute: 'current'; + assetId: string | null | undefined; + assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined; +} + +type ImmichRoute = AssetRoute | AssetGridRoute; + +type NavOptions = { + /* navigate even if url is the same */ + forceNavigate?: boolean | undefined; + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + state?: App.PageState | undefined; +}; function isAssetRoute(route: Route): route is AssetRoute { return route.targetRoute === 'current' && 'assetId' in route; } -async function navigateAssetRoute(route: AssetRoute) { +function isAssetGridRoute(route: Route): route is AssetGridRoute { + return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route; +} + +async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) { const { assetId } = route; const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); - if (next !== currentUrl()) { - await goto(next, { replaceState: false }); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); } } -export function navigate<T extends Route>(change: T): Promise<void> { - if (isAssetRoute(change)) { - return navigateAssetRoute(change); +async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) { + const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route; + const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); + const next = replaceScrollTarget(assetUrl, assetGridScrollTarget); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); + } +} + +export function navigate(change: ImmichRoute, options?: NavOptions): Promise<void> { + if (isAssetGridRoute(change)) { + return navigateAssetGridRoute(change, options); + } else if (isAssetRoute(change)) { + return navigateAssetRoute(change, options); } // future navigation requests here throw `Invalid navigation: ${JSON.stringify(change)}`; diff --git a/web/src/lib/utils/priority-queue.ts b/web/src/lib/utils/priority-queue.ts new file mode 100644 index 0000000000..6b08ffe7ad --- /dev/null +++ b/web/src/lib/utils/priority-queue.ts @@ -0,0 +1,21 @@ +export class PriorityQueue<T> { + private items: { value: T; priority: number }[] = []; + + push(value: T, priority: number) { + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.items.splice(i, 0, { value, priority }); + return this.length; + } + } + return this.items.push({ value, priority }); + } + + shift() { + return this.items.shift(); + } + + get length() { + return this.items.length; + } +} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 76a0d1b5cb..3a8f66ee08 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,9 +1,38 @@ +import type { AssetBucket } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; import type { AssetResponseDto } from '@immich/sdk'; -import { groupBy, sortBy } from 'lodash-es'; +import type createJustifiedLayout from 'justified-layout'; +import { groupBy, memoize, sortBy } from 'lodash-es'; import { DateTime } from 'luxon'; import { get } from 'svelte/store'; +export type DateGroup = { + date: DateTime; + groupTitle: string; + assets: AssetResponseDto[]; + height: number; + heightActual: boolean; + intersecting: boolean; + geometry: Geometry; + bucket: AssetBucket; +}; +export type ScrubberListener = ( + bucketDate: string | undefined, + overallScrollPercent: number, + bucketScrollPercent: number, +) => void | Promise<void>; +export type ScrollTargetListener = ({ + bucket, + dateGroup, + asset, + offset, +}: { + bucket: AssetBucket; + dateGroup: DateGroup; + asset: AssetResponseDto; + offset: number; +}) => void; + export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); @@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string { return date.toLocaleString(groupDateFormat); } -export function splitBucketIntoDateGroups( - assets: AssetResponseDto[], - locale: string | undefined, -): AssetResponseDto[][] { - const grouped = groupBy(assets, (asset) => +type Geometry = ReturnType<typeof createJustifiedLayout> & { + containerWidth: number; +}; + +function emptyGeometry() { + return { + containerWidth: 0, + containerHeight: 0, + widowCount: 0, + boxes: [], + }; +} + +const formatDateGroupTitle = memoize(formatGroupTitle); + +export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] { + const grouped = groupBy(bucket.assets, (asset) => fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }), ); - return sortBy(grouped, (group) => assets.indexOf(group[0])); + const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0])); + return sorted.map((group) => { + const date = fromLocalDateTime(group[0].localDateTime).startOf('day'); + return { + date, + groupTitle: formatDateGroupTitle(date), + assets: group, + height: 0, + heightActual: false, + intersecting: false, + geometry: emptyGeometry(), + bucket: bucket, + }; + }); } export type LayoutBox = { + aspectRatio: number; top: number; - left: number; width: number; + height: number; + left: number; + forcedAspectRatio?: boolean; }; export function calculateWidth(boxes: LayoutBox[]): number { @@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number { width = box.left + box.width; } } - return width; } + +export function findTotalOffset(element: HTMLElement, stop: HTMLElement) { + let offset = 0; + while (element.offsetParent && element !== stop) { + offset += element.offsetTop; + element = element.offsetParent as HTMLElement; + } + return offset; +} diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts new file mode 100644 index 0000000000..e21c30de77 --- /dev/null +++ b/web/src/lib/utils/tunables.ts @@ -0,0 +1,63 @@ +function getBoolean(string: string | null, fallback: boolean) { + if (string === null) { + return fallback; + } + return 'true' === string; +} +function getNumber(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseInt(string); +} +function getFloat(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseFloat(string); +} +export const TUNABLES = { + SCROLL_TASK_QUEUE: { + TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25), + TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5), + TRICKLE_ACCELERATED_MIN_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'), + 8, + ), + TRICKLE_ACCELERATED_MAX_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'), + 2000, + ), + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15), + DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16), + MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200), + CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16), + }, + INTERSECTION_OBSERVER_QUEUE: { + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15), + THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16), + THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true), + }, + ASSET_GRID: { + NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), + }, + BUCKET: { + PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2), + INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%', + }, + DATEGROUP: { + PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4), + INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false), + INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%', + }, + THUMBNAIL: { + PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8), + INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%', + }, + IMAGE_THUMBNAIL: { + THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150), + }, +}; diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index 23f38b86f4..bf24d0e7e4 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -1,10 +1,10 @@ <script lang="ts"> import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte'; import { page } from '$app/stores'; - import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - let { isViewing: showAssetViewer, setAsset } = assetViewingStore; - // This block takes care of opening the viewer. + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; + // $page.data.asset is loaded by route specific +page.ts loaders if that // route contains the assetId path. $: { @@ -13,6 +13,8 @@ } else { $showAssetViewer = false; } + const asset = $page.url.searchParams.get('at'); + $gridScrollTarget = { at: asset }; } </script> diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9e670f714c..ff5709df99 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -43,7 +43,13 @@ import { downloadAlbum } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; - import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation'; + import { + isAlbumsRoute, + isPeopleRoute, + isSearchRoute, + navigate, + type AssetGridRouteSearchParams, + } from '$lib/utils/navigation'; import { AlbumUserRole, AssetOrder, @@ -78,12 +84,15 @@ import type { PageData } from './$types'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; - let { isViewing: showAssetViewer, setAsset } = assetViewingStore; + let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; let { slideshowState, slideshowNavigation } = slideshowStore; + let oldAt: AssetGridRouteSearchParams | null | undefined; + $: album = data.album; $: albumId = album.id; $: albumKey = `${albumId}_${albumOrder}`; @@ -244,7 +253,7 @@ } if (viewMode === ViewMode.SELECT_ASSETS) { - handleCloseSelectAssets(); + await handleCloseSelectAssets(); return; } if (viewMode === ViewMode.LINK_SHARING) { @@ -289,20 +298,37 @@ timelineInteractionStore.clearMultiselect(); viewMode = ViewMode.VIEW; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { replaceState: true, forceNavigate: true }, + ); } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); } }; - const handleCloseSelectAssets = () => { + const setModeToView = async () => { viewMode = ViewMode.VIEW; + assetStore.destroy(); + assetStore = new AssetStore({ albumId, order: albumOrder }); + timelineStore.destroy(); + timelineStore = new AssetStore({ isArchived: false }, albumId); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } }, + { replaceState: true, forceNavigate: true }, + ); + oldAt = null; + }; + + const handleCloseSelectAssets = async () => { timelineInteractionStore.clearMultiselect(); + await setModeToView(); }; const handleSelectFromComputer = async () => { await openFileUploadDialog({ albumId: album.id }); timelineInteractionStore.clearMultiselect(); - viewMode = ViewMode.VIEW; + await setModeToView(); }; const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => { @@ -400,6 +426,11 @@ await deleteAlbum(album); } }); + + onDestroy(() => { + assetStore.destroy(); + timelineStore.destroy(); + }); </script> <div class="flex overflow-hidden" bind:clientWidth={globalWidth}> @@ -444,7 +475,14 @@ {#if isEditor} <CircleIconButton title={$t('add_photos')} - on:click={() => (viewMode = ViewMode.SELECT_ASSETS)} + on:click={async () => { + viewMode = ViewMode.SELECT_ASSETS; + oldAt = { at: $gridScrollTarget?.at }; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } }, + { replaceState: true }, + ); + }} icon={mdiImagePlusOutline} /> {/if} @@ -530,12 +568,14 @@ {#key albumKey} {#if viewMode === ViewMode.SELECT_ASSETS} <AssetGrid + enableRouting={false} assetStore={timelineStore} assetInteractionStore={timelineInteractionStore} isSelectionMode={true} /> {:else} <AssetGrid + enableRouting={true} {album} {assetStore} {assetInteractionStore} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6e3fb4cb28..2ce1309351 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -17,6 +17,7 @@ import type { PageData } from './$types'; import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -25,6 +26,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); + + onDestroy(() => { + assetStore.destroy(); + }); </script> {#if $isMultiSelectState} @@ -45,7 +50,7 @@ {/if} <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> - <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}> + <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}> <EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" /> </AssetGrid> </UserPageLayout> diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49af165ac9..13e70c9161 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,6 +19,7 @@ import type { PageData } from './$types'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -27,6 +28,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); + + onDestroy(() => { + assetStore.destroy(); + }); </script> <!-- Multiselection mode app bar --> @@ -50,7 +55,7 @@ {/if} <UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}> - <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}> + <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}> <EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" /> </AssetGrid> </UserPageLayout> diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3eb65ca1bd..0ea0ed18bb 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -124,7 +124,10 @@ showNavigation={viewingAssets.length > 1} on:next={navigateNext} on:previous={navigatePrevious} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} isShared={false} /> {/await} diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83e2ba3c1f..b580c4faa5 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -23,6 +23,7 @@ onDestroy(() => { assetInteractionStore.clearMultiselect(); + assetStore.destroy(); }); </script> @@ -45,5 +46,5 @@ </svelte:fragment> </ControlAppBar> {/if} - <AssetGrid {assetStore} {assetInteractionStore} /> + <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} /> </main> diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 02afe7f610..26e803deb6 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -52,7 +52,7 @@ mdiEyeOutline, mdiPlus, } from '@mdi/js'; - import { onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import type { PageData } from './$types'; import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; @@ -155,6 +155,7 @@ } if (previousPersonId !== data.person.id) { handlePromiseError(updateAssetCount()); + assetStore.destroy(); assetStore = new AssetStore({ isArchived: false, personId: data.person.id, @@ -344,6 +345,10 @@ await goto($page.url); } }; + + onDestroy(() => { + assetStore.destroy(); + }); </script> {#if viewMode === ViewMode.UNASSIGN_ASSETS} @@ -442,6 +447,7 @@ <main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"> {#key refreshAssetGrid} <AssetGrid + enableRouting={true} {assetStore} {assetInteractionStore} isSelectionMode={viewMode === ViewMode.SELECT_PERSON} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 85a497fa99..9bcbdbeea0 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -24,6 +24,7 @@ import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { preferences, user } from '$lib/stores/user.store'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; let { isViewing: showAssetViewer } = assetViewingStore; const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); @@ -48,6 +49,10 @@ return; } }; + + onDestroy(() => { + assetStore.destroy(); + }); </script> {#if $isMultiSelectState} @@ -84,6 +89,7 @@ <UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}> <AssetGrid + enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.ARCHIVE} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7e5362ba0f..cd4def1765 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -291,7 +291,7 @@ <GalleryViewer assets={searchResultAssets} bind:selectedAssets - on:intersected={loadNextPage} + onIntersected={loadNextPage} showArchiveIcon={true} {viewport} /> diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5ebb0e294c..f4fac282ba 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,8 +11,13 @@ import type { PageData } from './$types'; import { setSharedLink } from '$lib/utils'; import { t } from 'svelte-i18n'; + import { navigate } from '$lib/utils/navigation'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { tick } from 'svelte'; export let data: PageData; + + let { gridScrollTarget } = assetViewingStore; let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data; let { title, description } = meta; let isOwned = $user ? $user.id === sharedLink?.userId : false; @@ -29,6 +34,11 @@ description = sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } }); + await tick(); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { forceNavigate: true, replaceState: true }, + ); } catch (error) { handleError(error, $t('errors.unable_to_get_shared_link')); } diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2907a542b3..27ad5bb3f0 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -25,6 +25,7 @@ import { handlePromiseError } from '$lib/utils'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -84,6 +85,10 @@ handleError(error, $t('errors.unable_to_restore_trash')); } }; + + onDestroy(() => { + assetStore.destroy(); + }); </script> {#if $isMultiSelectState} @@ -111,7 +116,7 @@ </LinkButton> </div> - <AssetGrid {assetStore} {assetInteractionStore}> + <AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}> <p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4"> {$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })} </p> diff --git a/web/static/dark_skeleton.png b/web/static/dark_skeleton.png new file mode 100644 index 0000000000..2a115a8496 Binary files /dev/null and b/web/static/dark_skeleton.png differ diff --git a/web/static/light_skeleton.png b/web/static/light_skeleton.png new file mode 100644 index 0000000000..22c7eae754 Binary files /dev/null and b/web/static/light_skeleton.png differ