From e96ffd43e75b88663af026140d04f9c9fdbbd381 Mon Sep 17 00:00:00 2001 From: Min Idzelis <min123@gmail.com> Date: Tue, 18 Mar 2025 10:14:46 -0400 Subject: [PATCH] feat: timeline performance (#16446) * Squash - feature complete * remove need to init assetstore * More optimizations. No need to init. Fix tests * lint * add missing selector for e2e * e2e selectors again * Update: fully reactive store, some transitions, bugfixes * merge fallout * Test fallout * safari quirk * security * lint * lint * Bug fixes * lint/format * accidental commit * lock * null check, more throttle * revert long duration * Fix intersection bounds * Fix bugs in intersection calculation * lint, tweak scrubber ui a tiny bit * bugfix - deselecting asset doesnt work * fix not loading bucket, scroll off-by-1 error, jsdoc, naming --- e2e/src/web/specs/shared-link.e2e-spec.ts | 2 +- web/package-lock.json | 10 +- web/package.json | 6 +- web/src/app.css | 25 +- web/src/lib/actions/intersection-observer.ts | 12 +- web/src/lib/actions/resize-observer.ts | 2 +- .../components/album-page/album-viewer.svelte | 8 +- .../asset-viewer/asset-viewer.svelte | 2 +- .../assets/thumbnail/image-thumbnail.svelte | 17 +- .../assets/thumbnail/thumbnail.svelte | 420 ++--- .../assets/thumbnail/video-thumbnail.svelte | 40 +- .../photos-page/asset-date-group.svelte | 305 ++- .../components/photos-page/asset-grid.svelte | 497 ++--- .../photos-page/measure-date-group.svelte | 91 - .../components/photos-page/skeleton.svelte | 32 +- .../shared-components/control-app-bar.svelte | 2 +- .../gallery-viewer/gallery-viewer.svelte | 199 +- .../scrubber/scrubber.svelte | 97 +- .../side-bar/purchase-info.svelte | 13 +- .../shared-components/tree/breadcrumbs.svelte | 2 +- .../duplicates-compare-control.svelte | 2 +- web/src/lib/constants.ts | 27 +- .../lib/stores/asset-interaction.svelte.ts | 11 +- web/src/lib/stores/assets-store.spec.ts | 219 ++- web/src/lib/stores/assets-store.svelte.ts | 1651 ++++++++++------- web/src/lib/stores/timeline.store.ts | 3 - web/src/lib/utils/asset-store-task-manager.ts | 465 ----- web/src/lib/utils/asset-utils.ts | 2 +- web/src/lib/utils/cancellable-task.ts | 135 ++ web/src/lib/utils/idle-callback-support.ts | 22 - web/src/lib/utils/keyed-priority-queue.ts | 50 - web/src/lib/utils/layout-utils.ts | 46 +- web/src/lib/utils/priority-queue.ts | 21 - web/src/lib/utils/timeline-util.ts | 87 +- web/src/lib/utils/tunables.ts | 45 +- .../[[assetId=id]]/+page.svelte | 278 +-- .../[[assetId=id]]/+page.svelte | 29 +- .../[[assetId=id]]/+page.svelte | 9 +- .../[[assetId=id]]/+page.svelte | 14 +- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 8 +- web/src/routes/(user)/people/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 23 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 18 +- .../[[assetId=id]]/+page.svelte | 97 +- .../[[assetId=id]]/+page.svelte | 10 +- .../[[assetId=id]]/+page.svelte | 18 +- web/tsconfig.json | 2 +- 48 files changed, 2318 insertions(+), 2764 deletions(-) delete mode 100644 web/src/lib/components/photos-page/measure-date-group.svelte delete mode 100644 web/src/lib/stores/timeline.store.ts delete mode 100644 web/src/lib/utils/asset-store-task-manager.ts create mode 100644 web/src/lib/utils/cancellable-task.ts delete mode 100644 web/src/lib/utils/idle-callback-support.ts delete mode 100644 web/src/lib/utils/keyed-priority-queue.ts delete mode 100644 web/src/lib/utils/priority-queue.ts diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index ed81db4ef5..9313526dab 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -45,7 +45,7 @@ test.describe('Shared Links', () => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.locator(`[data-asset-id="${asset.id}"]`).hover(); - await page.waitForSelector('#asset-group-by-date svg'); + await page.waitForSelector('[data-group] svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); await page.getByText('DOWNLOADING', { exact: true }).waitFor(); diff --git a/web/package-lock.json b/web/package-lock.json index 6b2a0e9bfb..a2da0e1ae9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -70,8 +70,8 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^5.14.0", - "svelte": "^5.17.4", - "svelte-check": "^4.1.4", + "svelte": "^5.22.6", + "svelte-check": "^4.1.5", "tailwindcss": "^3.4.17", "tslib": "^2.6.2", "typescript": "^5.7.3", @@ -9579,9 +9579,9 @@ } }, "node_modules/vite": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz", - "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index af45e08659..77da2d24cb 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build:stats": "BUILD_STATS=true vite build", "package": "svelte-kit package", "preview": "vite preview", - "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore'", + "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte", "check:typescript": "tsc --noEmit", "check:watch": "npm run check:svelte -- --watch", "check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript", @@ -56,8 +56,8 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^5.14.0", - "svelte": "^5.17.4", - "svelte-check": "^4.1.4", + "svelte": "^5.22.6", + "svelte-check": "^4.1.5", "tailwindcss": "^3.4.17", "tslib": "^2.6.2", "typescript": "^5.7.3", diff --git a/web/src/app.css b/web/src/app.css index 9bc1695a8f..a256cc9d80 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -135,32 +135,13 @@ input:focus-visible { } /* width */ - .immich-scrollbar::-webkit-scrollbar { - width: 8px; - } - - /* Track */ - .immich-scrollbar::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 16px; - } - - /* Handle */ - .immich-scrollbar::-webkit-scrollbar-thumb { - background: rgba(85, 86, 87, 0.408); - border-radius: 16px; - } - - /* Handle on hover */ - .immich-scrollbar::-webkit-scrollbar-thumb:hover { - background: #4250afad; - border-radius: 16px; + .immich-scrollbar { + scrollbar-width: thin; } /* Hidden scrollbar */ /* width */ - .scrollbar-hidden::-webkit-scrollbar { - display: none; + .scrollbar-hidden { scrollbar-width: none; } diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts index 3a10074051..74643aa95d 100644 --- a/web/src/lib/actions/intersection-observer.ts +++ b/web/src/lib/actions/intersection-observer.ts @@ -13,6 +13,7 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem type OnSeparateCallback = (element: HTMLElement) => unknown; type IntersectionObserverActionProperties = { key?: string; + disabled?: boolean; /** Function to execute when the element leaves the viewport */ onSeparate?: OnSeparateCallback; /** Function to execute when the element enters the viewport */ @@ -83,8 +84,15 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int }; function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) { - elementToConfig.set(key, properties); - observe(key, element, properties); + if (properties.disabled) { + const config = elementToConfig.get(key); + const { observer } = config || {}; + observer?.unobserve(element); + elementToConfig.delete(key); + } else { + elementToConfig.set(key, properties); + observe(key, element, properties); + } } function _intersectionObserver( diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts index 9f3adc44b0..4fa35c7d93 100644 --- a/web/src/lib/actions/resize-observer.ts +++ b/web/src/lib/actions/resize-observer.ts @@ -1,4 +1,4 @@ -type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; +export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; let observer: ResizeObserver; let callbacks: WeakMap<HTMLElement, OnResizeCallback>; diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 8b5b2bff8b..c176402576 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -33,7 +33,10 @@ let { isViewing: showAssetViewer } = assetViewingStore; - const assetStore = new AssetStore({ albumId: album.id, order: album.order }); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order })); + onDestroy(() => assetStore.destroy()); + const assetInteraction = new AssetInteraction(); dragAndDropFilesStore.subscribe((value) => { @@ -42,9 +45,6 @@ dragAndDropFilesStore.set({ isDragging: false, files: [] }); } }); - onDestroy(() => { - assetStore.destroy(); - }); </script> <svelte:window diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 14a273f1c8..dec96f92f8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -64,7 +64,7 @@ onClose: (dto: { asset: AssetResponseDto }) => void; onNext: () => Promise<HasAsset>; onPrevious: () => Promise<HasAsset>; - onRandom: () => Promise<AssetResponseDto | null>; + onRandom: () => Promise<AssetResponseDto | undefined>; copyImage?: () => Promise<void>; } diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 9d69bdeeb2..119efe71b5 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -4,7 +4,6 @@ import Icon from '$lib/components/elements/icon.svelte'; import { TUNABLES } from '$lib/utils/tunables'; import { mdiEyeOffOutline } from '@mdi/js'; - import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; interface Props { @@ -37,7 +36,6 @@ circle = false, hidden = false, border = false, - preload = true, hiddenIconClass = 'text-white', onComplete = undefined, }: Props = $props(); @@ -49,8 +47,6 @@ let loaded = $state(false); let errored = $state(false); - let img = $state<HTMLImageElement>(); - const setLoaded = () => { loaded = true; onComplete?.(); @@ -59,11 +55,13 @@ errored = true; onComplete?.(); }; - onMount(() => { - if (img?.complete) { - setLoaded(); + + function mount(elem: HTMLImageElement) { + if (elem.complete) { + loaded = true; + onComplete?.(); } - }); + } let optionalClasses = $derived( [ @@ -82,10 +80,9 @@ <BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} /> {:else} <img - bind:this={img} + use:mount onload={setLoaded} onerror={setErrored} - loading={preload ? 'eager' : 'lazy'} style:width={widthStyle} style:height={heightStyle} style:filter={hidden ? 'grayscale(50%)' : 'none'} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 3f867cce01..535f8e1408 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,5 +1,4 @@ <script lang="ts"> - 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'; @@ -22,19 +21,11 @@ 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.svelte'; - - 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'; interface Props { asset: AssetResponseDto; - dateGroup?: DateGroup | undefined; - assetStore?: AssetStore | undefined; groupIndex?: number; thumbnailSize?: number | undefined; thumbnailWidth?: number | undefined; @@ -47,29 +38,16 @@ showArchiveIcon?: boolean; showStackedIcon?: boolean; disableMouseOver?: boolean; - intersectionConfig?: { - root?: HTMLElement; - bottom?: string; - top?: string; - left?: string; - priority?: number; - disabled?: boolean; - }; - retrieveElement?: boolean; - onIntersected?: (() => void) | undefined; + onClick?: ((asset: AssetResponseDto) => void) | undefined; - onRetrieveElement?: ((elment: HTMLElement) => void) | undefined; onSelect?: ((asset: AssetResponseDto) => void) | undefined; onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; handleFocus?: (() => void) | undefined; class?: string; - overrideDisplayForTest?: boolean; } let { - asset, - dateGroup = undefined, - assetStore = undefined, + asset = $bindable(), groupIndex = 0, thumbnailSize = undefined, thumbnailWidth = undefined, @@ -82,42 +60,21 @@ showArchiveIcon = false, showStackedIcon = true, disableMouseOver = false, - intersectionConfig = {}, - retrieveElement = false, - onIntersected = undefined, onClick = undefined, - onRetrieveElement = undefined, onSelect = undefined, onMouseEvent = undefined, handleFocus = undefined, class: className = '', - overrideDisplayForTest = false, }: Props = $props(); let { IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, } = TUNABLES; - const componentId = generateId(); - let element: HTMLElement | undefined = $state(); let focussableElement: HTMLElement | undefined = $state(); let mouseOver = $state(false); - let intersecting = $state(false); - let lastRetrievedElement: HTMLElement | undefined = $state(); let loaded = $state(false); - $effect(() => { - if (!retrieveElement) { - lastRetrievedElement = undefined; - } - }); - $effect(() => { - if (retrieveElement && element && lastRetrievedElement !== element) { - lastRetrievedElement = element; - onRetrieveElement?.(element); - } - }); - $effect(() => { if (focussed && document.activeElement !== focussableElement) { focussableElement?.focus(); @@ -126,13 +83,12 @@ let width = $derived(thumbnailSize || thumbnailWidth || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235); - let display = $derived(intersecting); const onIconClickedHandler = (e?: MouseEvent) => { e?.stopPropagation(); e?.preventDefault(); if (!disabled) { - onSelect?.(asset); + onSelect?.($state.snapshot(asset)); } }; @@ -141,7 +97,7 @@ onIconClickedHandler(); return; } - onClick?.(asset); + onClick?.($state.snapshot(asset)); }; const handleClick = (e: MouseEvent) => { if (e.ctrlKey || e.metaKey) { @@ -152,68 +108,18 @@ callClickHandlers(); }; - const _onMouseEnter = () => { + const onMouseEnter = () => { mouseOver = true; onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex }); }; - const onMouseEnter = () => { - if (dateGroup && assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() }); - } else { - _onMouseEnter(); - } - }; - const onMouseLeave = () => { - if (dateGroup && assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) }); - } else { - mouseOver = false; - } + 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.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false)); - } else { - intersecting = false; - } - }; - - onDestroy(() => { - assetStore?.taskManager.removeAllTasksForComponent(componentId); - }); </script> <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="focus-visible:outline-none flex overflow-hidden {disabled @@ -230,166 +136,164 @@ ></canvas> {/if} - {#if display || overrideDisplayForTest} - <!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates + <!-- 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="group" - class:cursor-not-allowed={disabled} - class:cursor-pointer={!disabled} - onmouseenter={onMouseEnter} - onmouseleave={onMouseLeave} - onkeydown={(evt) => { - if (evt.key === 'Enter') { - callClickHandlers(); - } - if (evt.key === 'x') { - onSelect?.(asset); - } - }} - tabindex={0} - onclick={handleClick} - role="link" - bind:this={focussableElement} - onfocus={handleFocus} - data-testid="container-with-tabindex" - > - {#if mouseOver && !disableMouseOver} - <!-- 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)} - onclick={(evt) => evt.preventDefault()} - tabindex={-1} - aria-label="Thumbnail URL" - > - </a> - {/if} - <div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px"> - <!-- Select asset button --> - {#if !readonly && (mouseOver || selected || selectionCandidate)} - <button - type="button" - onclick={onIconClickedHandler} - class="absolute p-2 focus:outline-none" - class:cursor-not-allowed={disabled} - role="checkbox" - tabindex={-1} - onfocus={handleFocus} - aria-checked={selected} - {disabled} - > - {#if disabled} - <Icon path={mdiCheckCircle} size="24" class="text-zinc-800" /> - {:else if selected} - <div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]"> - <Icon path={mdiCheckCircle} size="24" class="text-immich-primary" /> - </div> - {:else} - <Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" /> - {/if} - </button> - {/if} - </div> - - <div - class="absolute h-full w-full select-none bg-transparent transition-transform" - class:scale-[0.85]={selected} - class:rounded-xl={selected} + <div + class="group" + style:width="{width}px" + style:height="{height}px" + class:cursor-not-allowed={disabled} + class:cursor-pointer={!disabled} + onmouseenter={onMouseEnter} + onmouseleave={onMouseLeave} + onkeydown={(evt) => { + if (evt.key === 'Enter') { + callClickHandlers(); + } + if (evt.key === 'x') { + onSelect?.(asset); + } + }} + tabindex={0} + onclick={handleClick} + role="link" + bind:this={focussableElement} + onfocus={handleFocus} + data-testid="container-with-tabindex" + > + {#if mouseOver && !disableMouseOver} + <!-- 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)} + onclick={(evt) => evt.preventDefault()} + tabindex={-1} + aria-label="Thumbnail URL" > - <!-- Gradient overlay on hover --> - <div - class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100" - class:rounded-xl={selected} - ></div> - - <!-- Outline on focus --> - <div - class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary" - ></div> - - <!-- Favorite asset star --> - {#if !isSharedLink() && asset.isFavorite} - <div class="absolute bottom-2 left-2 z-10"> - <Icon path={mdiHeart} size="24" class="text-white" /> - </div> - {/if} - - {#if !isSharedLink() && showArchiveIcon && asset.isArchived} - <div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10"> - <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" /> - </div> - {/if} - - {#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR} - <div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white"> - <span class="pr-2 pt-2"> - <Icon path={mdiRotate360} size="24" /> - </span> - </div> - {/if} - - <!-- Stacked asset --> - - {#if asset.stack && showStackedIcon} - <div - class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined - ? 'top-0 right-0' - : 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white" - > - <span class="pr-2 pt-2 flex place-items-center gap-1"> - <p>{asset.stack.assetCount.toLocaleString($locale)}</p> - <Icon path={mdiCameraBurst} size="24" /> - </span> - </div> - {/if} - - <ImageThumbnail - url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} - altText={$getAltText(asset)} - widthStyle="{width}px" - heightStyle="{height}px" - curve={selected} - onComplete={() => (loaded = true)} - /> - - {#if asset.type === AssetTypeEnum.Video} - <div class="absolute top-0 h-full w-full"> - <VideoThumbnail - {assetStore} - url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })} - enablePlayback={mouseOver && $playVideoThumbnailOnHover} - curve={selected} - durationInSeconds={timeToSeconds(asset.duration)} - playbackOnIconHover={!$playVideoThumbnailOnHover} - /> - </div> - {/if} - - {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} - <div class="absolute top-0 h-full w-full"> - <VideoThumbnail - {assetStore} - url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })} - pauseIcon={mdiMotionPauseOutline} - playIcon={mdiMotionPlayOutline} - showTime={false} - curve={selected} - playbackOnIconHover - /> - </div> - {/if} - </div> - {#if selectionCandidate} - <div - class="absolute top-0 h-full w-full bg-immich-primary opacity-40" - in:fade={{ duration: 100 }} - out:fade={{ duration: 100 }} - ></div> + </a> + {/if} + <div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px"> + <!-- Select asset button --> + {#if !readonly && (mouseOver || selected || selectionCandidate)} + <button + type="button" + onclick={onIconClickedHandler} + class="absolute p-2 focus:outline-none" + class:cursor-not-allowed={disabled} + role="checkbox" + tabindex={-1} + onfocus={handleFocus} + aria-checked={selected} + {disabled} + > + {#if disabled} + <Icon path={mdiCheckCircle} size="24" class="text-zinc-800" /> + {:else if selected} + <div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]"> + <Icon path={mdiCheckCircle} size="24" class="text-immich-primary" /> + </div> + {:else} + <Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" /> + {/if} + </button> {/if} </div> - {/if} + + <div + class="absolute h-full w-full select-none bg-transparent transition-transform" + class:scale-[0.85]={selected} + class:rounded-xl={selected} + > + <!-- Gradient overlay on hover --> + <div + class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100" + class:rounded-xl={selected} + ></div> + + <!-- Outline on focus --> + <div + class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary" + ></div> + + <!-- Favorite asset star --> + {#if !isSharedLink() && asset.isFavorite} + <div class="absolute bottom-2 left-2 z-10"> + <Icon path={mdiHeart} size="24" class="text-white" /> + </div> + {/if} + + {#if !isSharedLink() && showArchiveIcon && asset.isArchived} + <div class="absolute {asset.isFavorite ? 'bottom-10' : 'bottom-2'} left-2 z-10"> + <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" /> + </div> + {/if} + + {#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR} + <div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white"> + <span class="pr-2 pt-2"> + <Icon path={mdiRotate360} size="24" /> + </span> + </div> + {/if} + + <!-- Stacked asset --> + + {#if asset.stack && showStackedIcon} + <div + class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined + ? 'top-0 right-0' + : 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white" + > + <span class="pr-2 pt-2 flex place-items-center gap-1"> + <p>{asset.stack.assetCount.toLocaleString($locale)}</p> + <Icon path={mdiCameraBurst} size="24" /> + </span> + </div> + {/if} + + <ImageThumbnail + url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} + altText={$getAltText(asset)} + widthStyle="{width}px" + heightStyle="{height}px" + curve={selected} + onComplete={() => (loaded = true)} + /> + + {#if asset.type === AssetTypeEnum.Video} + <div class="absolute top-0 h-full w-full"> + <VideoThumbnail + url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })} + enablePlayback={mouseOver && $playVideoThumbnailOnHover} + curve={selected} + durationInSeconds={timeToSeconds(asset.duration)} + playbackOnIconHover={!$playVideoThumbnailOnHover} + /> + </div> + {/if} + + {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} + <div class="absolute top-0 h-full w-full"> + <VideoThumbnail + url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })} + pauseIcon={mdiMotionPauseOutline} + playIcon={mdiMotionPlayOutline} + showTime={false} + curve={selected} + playbackOnIconHover + /> + </div> + {/if} + </div> + {#if selectionCandidate} + <div + class="absolute top-0 h-full w-full bg-immich-primary opacity-40" + in:fade={{ duration: 100 }} + out:fade={{ duration: 100 }} + ></div> + {/if} + </div> </div> diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index a2e30be543..fc3cb2e951 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -3,12 +3,8 @@ 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.svelte'; - import { generateId } from '$lib/utils/generate-id'; - import { onDestroy } from 'svelte'; interface Props { - assetStore?: AssetStore | undefined; url: string; durationInSeconds?: number; enablePlayback?: boolean; @@ -20,7 +16,6 @@ } let { - assetStore = undefined, url, durationInSeconds = 0, enablePlayback = $bindable(false), @@ -31,7 +26,6 @@ pauseIcon = mdiPauseCircleOutline, }: Props = $props(); - const componentId = generateId(); let remainingSeconds = $state(durationInSeconds); let loading = $state(true); let error = $state(false); @@ -49,42 +43,16 @@ } }); const onMouseEnter = () => { - if (assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ - componentId, - task: () => { - if (playbackOnIconHover) { - enablePlayback = true; - } - }, - }); - } else { - if (playbackOnIconHover) { - enablePlayback = true; - } + if (playbackOnIconHover) { + enablePlayback = true; } }; const onMouseLeave = () => { - if (assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ - componentId, - task: () => { - if (playbackOnIconHover) { - enablePlayback = false; - } - }, - }); - } else { - if (playbackOnIconHover) { - enablePlayback = false; - } + 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"> 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 4cc43ef199..e993d3694d 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -1,56 +1,51 @@ <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 { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte'; + import { AssetBucket } from '$lib/stores/assets-store.svelte'; import { navigate } from '$lib/utils/navigation'; - import { - findTotalOffset, - type DateGroup, - type ScrollTargetListener, - getDateLocaleString, - } from '$lib/utils/timeline-util'; + import { getDateLocaleString } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; - import { 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'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { scale } from 'svelte/transition'; - 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 assetInteraction: AssetInteraction; + import { flip } from 'svelte/animate'; - export let onScrollTarget: ScrollTargetListener | undefined = undefined; - export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; - export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; - export let onSelectAssets: (asset: AssetResponseDto) => void; - export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; + import { uploadAssetsStore } from '$lib/stores/upload'; - const componentId = generateId(); - $: bucketDate = bucket.bucketDate; - $: dateGroups = bucket.dateGroups; + let { isUploading } = uploadAssetsStore; - const { - DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM }, - } = TUNABLES; - /* TODO figure out a way to calculate this*/ - const TITLE_HEIGHT = 51; + interface Props { + isSelectionMode: boolean; + singleSelect: boolean; + withStacked: boolean; + showArchiveIcon: boolean; + bucket: AssetBucket; + assetInteraction: AssetInteraction; - let isMouseOverGroup = false; - let hoveredDateGroup = ''; + onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; + onSelectAssets: (asset: AssetResponseDto) => void; + onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; + } + let { + isSelectionMode, + singleSelect, + withStacked, + showArchiveIcon, + bucket = $bindable(), + assetInteraction, + onSelect, + onSelectAssets, + onSelectAssetCandidates, + }: Props = $props(); + + let isMouseOverGroup = $state(false); + let hoveredDateGroup = $state(); + + const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150)); + const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { if (isSelectionMode || assetInteraction.selectionActive) { assetSelectHandler(asset, assets, groupTitle); @@ -59,13 +54,6 @@ void navigate({ targetRoute: 'current', assetId: asset.id }); }; - const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => { - if (assetGridElement && onScrollTarget) { - const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT; - onScrollTarget({ bucket, dateGroup, asset, offset }); - } - }; - const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { @@ -73,7 +61,7 @@ // Check if all assets are selected in a group to toggle the group selection's icon let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => - assetInteraction.selectedAssets.has(asset), + assetInteraction.hasSelectedAsset(asset.id), ).length; // if all assets are selected in a group, add the group to selected group @@ -83,7 +71,9 @@ assetInteraction.removeGroupFromMultiselectGroup(groupTitle); } }; - + const snapshotAssetArray = (assets: AssetResponseDto[]) => { + return assets.map((a) => $state.snapshot(a)); + }; const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => { // Show multi select icon on hover on date group hoveredDateGroup = groupTitle; @@ -96,155 +86,100 @@ const assetOnFocusHandler = (asset: AssetResponseDto) => { assetInteraction.focussedAssetId = asset.id; }; - - onDestroy(() => { - assetStore.taskManager.removeAllTasksForComponent(componentId); - }); + function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) { + return intersectable.filter((int) => int.intersecting); + } </script> -<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)} - {@const geometry = dateGroup.geometry!} +{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)} + {@const absoluteWidth = dateGroup.left} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <section + class={[ + { 'transition-all': !bucket.store.suspendTransitions }, + !bucket.store.suspendTransitions && `delay-${transitionDuration}`, + ]} + data-group + style:position="absolute" + style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`} + onmouseenter={() => { + isMouseOverGroup = true; + assetMouseEventHandler(dateGroup.groupTitle, null); + }} + onmouseleave={() => { + isMouseOverGroup = false; + assetMouseEventHandler(dateGroup.groupTitle, null); + }} + > + <!-- Date group title --> <div - id="date-group" - use:intersectionObserver={{ - onIntersect: () => { - assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => - assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), - ); - }, - onSeparate: () => { - assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () => - assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), - ); - }, - top: INTERSECTION_ROOT_TOP, - bottom: INTERSECTION_ROOT_BOTTOM, - root: assetGridElement, - }} - data-display={display} - data-date-group={dateGroup.date} - style:height={dateGroup.height + 'px'} - style:width={geometry.containerWidth + 'px'} - style:overflow="clip" + class="flex z-[100] 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.width + 'px'} > - {#if !display} - <Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} /> - {/if} - {#if display} - <!-- Asset Group By Date --> - <!-- svelte-ignore a11y-no-static-element-interactions --> + {#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))} <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); - }, - }); - }} + transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} + class="inline-block px-2 hover:cursor-pointer" + onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))} + onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))} > - <!-- Date group title --> - <div - 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={geometry.containerWidth + 'px'} - > - {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.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 assetInteraction.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={getDateLocaleString(dateGroup.date)}> - {dateGroup.groupTitle} - </span> - </div> - - <!-- Image grid --> - <div - class="relative overflow-clip" - style:height={geometry.containerHeight + 'px'} - style:width={geometry.containerWidth + 'px'} - > - {#each dateGroup.assets as asset, i (asset.id)} - <!-- getting these together here in this order is very cache-efficient --> - {@const top = geometry.getTop(i)} - {@const left = geometry.getLeft(i)} - {@const width = geometry.getWidth(i)} - {@const height = geometry.getHeight(i)} - <!-- 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:top={top + 'px'} - style:left={left + 'px'} - style:width={width + 'px'} - style:height={height + '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={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)} - handleFocus={() => assetOnFocusHandler(asset)} - focussed={assetInteraction.isFocussedAsset(asset)} - selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} - disabled={assetStore.albumAssets.has(asset.id)} - thumbnailWidth={width} - thumbnailHeight={height} - /> - </div> - {/each} - </div> + {#if assetInteraction.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={getDateLocaleString(dateGroup.date)}> + {dateGroup.groupTitle} + </span> </div> - {/each} -</section> + + <!-- Image grid --> + <div class="relative overflow-clip" style:height={dateGroup.height + 'px'} style:width={dateGroup.width + 'px'}> + {#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)} + {@const position = intersectingAsset.position!} + {@const asset = intersectingAsset.asset!} + + <!-- {#if intersectingAsset.intersecting} --> + <!-- note: don't remove data-asset-id - its used by web e2e tests --> + <div + data-asset-id={asset.id} + class="absolute" + style:top={position.top + 'px'} + style:left={position.left + 'px'} + style:width={position.width + 'px'} + style:height={position.height + 'px'} + out:scale|global={{ start: 0.1, duration: scaleDuration }} + animate:flip={{ duration: transitionDuration }} + > + <Thumbnail + showStackedIcon={withStacked} + {showArchiveIcon} + {asset} + {groupIndex} + focussed={assetInteraction.isFocussedAsset(asset)} + onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)} + onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)} + onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))} + selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + handleFocus={() => assetOnFocusHandler(asset)} + disabled={dateGroup.bucket.store.albumAssets.has(asset.id)} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> + </div> + <!-- {/if} --> + {/each} + </div> + </section> +{/each} <style> - #asset-group-by-date { + section { 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 1f4f9aca85..970f09793f 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -4,38 +4,26 @@ import type { Action } from '$lib/components/asset-viewer/actions/action'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte'; - import { locale, showDeleteModal } from '$lib/stores/preferences.store'; + import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte'; + import { showDeleteModal } from '$lib/stores/preferences.store'; import { isSearchEnabled } from '$lib/stores/search.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { handlePromiseError } from '$lib/utils'; import { deleteAssets } from '$lib/utils/actions'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; - import { - formatGroupTitle, - splitBucketIntoDateGroups, - type ScrubberListener, - type ScrollTargetListener, - } from '$lib/utils/timeline-util'; - import { TUNABLES } from '$lib/utils/tunables'; + import { type ScrubberListener } from '$lib/utils/timeline-util'; import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; - import { throttle } from 'lodash-es'; - import { onDestroy, onMount, type Snippet } from 'svelte'; + import { onMount, type Snippet } from 'svelte'; import Portal from '../shared-components/portal/portal.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 { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-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'; - import { isTimelineScrolling } from '$lib/stores/timeline.store'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { @@ -81,64 +69,41 @@ let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; - const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); - const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); - - const componentId = generateId(); let element: HTMLElement | undefined = $state(); + let timelineElement: HTMLElement | undefined = $state(); let showShortcuts = $state(false); let showSkeleton = $state(true); - let internalScroll = false; - let navigating = false; - let preMeasure: AssetBucket[] = $state([]); - let lastIntersectedBucketDate: string | undefined; let scrubBucketPercent = $state(0); let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); let scrubOverallPercent: number = $state(0); - let topSectionHeight = $state(0); - let topSectionOffset = $state(0); + // 60 is the bottom spacer element at 60px let bottomSectionHeight = 60; let leadout = $state(false); - 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 isViewportOrigin = () => { - return viewport.height === 0 && viewport.width === 0; - }; - 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; - } - + const completeNav = async () => { if ($gridScrollTarget?.at) { - void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => { + try { + const bucket = await assetStore.findBucketForAsset($gridScrollTarget.at); + if (bucket) { + const height = bucket.findAssetAbsolutePosition($gridScrollTarget.at); + if (height) { + element?.scrollTo({ top: height }); + showSkeleton = false; + assetStore.updateIntersections(); + } + } + } catch { element?.scrollTo({ top: 0 }); showSkeleton = false; - }); + } } else { element?.scrollTo({ top: 0 }); showSkeleton = false; } }; - + beforeNavigate(() => (assetStore.suspendTransitions = true)); afterNavigate((nav) => { const { complete, type } = nav; if (type === 'enter') { @@ -147,10 +112,6 @@ 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 @@ -165,7 +126,6 @@ if (assetGridUpdate) { setTimeout(() => { - void assetStore.updateViewport(safeViewport, true); const asset = $page.url.searchParams.get('at'); if (asset) { $gridScrollTarget = { at: asset }; @@ -193,94 +153,60 @@ return () => void 0; }; - const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => { - 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); - } - }; + const updateIsScrolling = () => (assetStore.scrolling = true); + // note: don't throttle, debounch, or otherwise do this function async - it causes flicker + const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0); + const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta); + const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height); onMount(() => { - void assetStore - .init({ bucketListener }) - .then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport))); + assetStore.setCompensateScrollCallback(compensateScrollCallback); if (!enableRouting) { showSkeleton = false; } - const dispose = hmrSupport(); + const disposeHmr = hmrSupport(); return () => { - assetStore.disconnect(); - assetStore.destroy(); - dispose(); + assetStore.setCompensateScrollCallback(); + disposeHmr(); }; }); - const _updateViewport = () => void assetStore.updateViewport(safeViewport); - const updateViewport = throttle(_updateViewport, 16); - - 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 getMaxScrollPercent = () => - (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) / - (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight); + const getMaxScrollPercent = () => { + const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight; + return (totalHeight - assetStore.viewportHeight) / totalHeight; + }; const getMaxScroll = () => { if (!element || !timelineElement) { return 0; } - - return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight); + return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight); }; const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => { - const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset; + const topOffset = bucket.top; const maxScrollPercent = getMaxScrollPercent(); const delta = bucket.bucketHeight * bucketScrollPercent; const scrollTop = (topOffset + delta) * maxScrollPercent; - if (!element) { - return; + if (element) { + element.scrollTop = scrollTop; } - - element.scrollTop = scrollTop; }; - const _onScrub: ScrubberListener = ( + // note: don't throttle, debounch, or otherwise make this function async - it causes flicker + const onScrub: ScrubberListener = ( bucketDate: string | undefined, scrollPercent: number, bucketScrollPercent: number, ) => { - if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) { + if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 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; - if (!element) { return; } - element.scrollTop = offset; } else { const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate); @@ -290,47 +216,16 @@ 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); - await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true }); - await bucket.measuredPromise; - scrollToBucketAndOffset(bucket, bucketScrollPercent); - } - }; - - let scrollObserverTimer: NodeJS.Timeout; - - const _handleTimelineScroll = () => { - $isTimelineScrolling = true; - if (scrollObserverTimer) { - clearTimeout(scrollObserverTimer); - } - scrollObserverTimer = setTimeout(() => { - $isTimelineScrolling = false; - }, 1000); + // note: don't throttle, debounch, or otherwise make this function async - it causes flicker + const handleTimelineScroll = () => { leadout = false; if (!element) { return; } - if (assetStore.timelineHeight < safeViewport.height * 2) { + if (assetStore.timelineHeight < assetStore.viewportHeight * 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); @@ -338,8 +233,8 @@ scrubBucket = undefined; scrubBucketPercent = 0; } else { - let top = element?.scrollTop; - if (top < topSectionHeight) { + let top = element.scrollTop; + if (top < assetStore.topSectionHeight) { // in the lead-in area scrubBucket = undefined; scrubBucketPercent = 0; @@ -352,18 +247,24 @@ 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; + const bucketsLength = assetStore.buckets.length; + for (let i = -1; i < bucketsLength + 1; i++) { + let bucket: { bucketDate: string | undefined } | undefined; + let bucketHeight = 0; + if (i === -1) { + // lead-in + bucketHeight = assetStore.topSectionHeight; + } else if (i === bucketsLength) { + // lead-out + bucketHeight = bottomSectionHeight; + } else { + bucket = assetStore.buckets[i]; + bucketHeight = assetStore.buckets[i].bucketHeight; + } + let next = top - bucketHeight * maxScrollPercent; if (next < 0) { scrubBucket = bucket; - scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent); + scrubBucketPercent = top / (bucketHeight * maxScrollPercent); found = true; break; } @@ -377,34 +278,6 @@ } } }; - 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; @@ -439,11 +312,9 @@ }; const toggleArchive = async () => { - const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); - if (ids) { - assetStore.removeAssets(ids); - deselectAllAssets(); - } + await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); + assetStore.updateAssets(assetInteraction.selectedAssetsArray); + deselectAllAssets(); }; const focusElement = () => { @@ -458,23 +329,6 @@ } }; - function handleIntersect(bucket: AssetBucket) { - // updateLastIntersectedBucketDate(); - const task = () => { - assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); - void assetStore.loadBucket(bucket.bucketDate); - }; - assetStore.taskManager.intersectedBucket(componentId, bucket, task); - } - - function handleSeparate(bucket: AssetBucket) { - const task = () => { - assetStore.updateBucket(bucket.bucketDate, { intersecting: false }); - bucket.cancel(); - }; - assetStore.taskManager.separatedBucket(componentId, bucket, task); - } - const handlePrevious = async () => { const previousAsset = await assetStore.getPreviousAsset($viewingAsset); @@ -610,7 +464,6 @@ if (!asset) { return; } - onSelect(asset); if (singleSelect && element) { @@ -619,7 +472,7 @@ } const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0; - const deselect = assetInteraction.selectedAssets.has(asset); + const deselect = assetInteraction.hasSelectedAsset(asset.id); // Select/deselect already loaded assets if (deselect) { @@ -637,39 +490,48 @@ assetInteraction.clearAssetSelectionCandidates(); if (assetInteraction.assetSelectionStart && rangeSelection) { - let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); - let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id); + let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); + let endBucket = assetStore.getBucketIndexByAssetId(asset.id); - if (startBucketIndex === null || endBucketIndex === null) { + if (startBucket === null || endBucket === null) { return; } - if (endBucketIndex < startBucketIndex) { - [startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex]; - } - - // 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); - for (const asset of bucket.assets) { - if (deselect) { - assetInteraction.removeAssetFromMultiselectGroup(asset); - } else { - handleSelectAsset(asset); + // Select/deselect assets in range (start,end] + let started = false; + for (const bucket of assetStore.buckets) { + if (bucket === startBucket) { + started = true; + } + if (bucket === endBucket) { + break; + } + if (started) { + await assetStore.loadBucket(bucket.bucketDate); + for (const asset of bucket.getAssets()) { + if (deselect) { + assetInteraction.removeAssetFromMultiselectGroup(asset); + } else { + handleSelectAsset(asset); + } } } } // Update date group selection - for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) { - const bucket = assetStore.buckets[bucketIndex]; + started = false; + for (const bucket of assetStore.buckets) { + if (bucket === startBucket) { + started = true; + } + if (bucket === endBucket) { + break; + } // Split bucket into date groups and check each group - const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); - for (const dateGroup of assetsGroupByDate) { - const dateGroupTitle = formatGroupTitle(dateGroup.date); - if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) { + for (const dateGroup of bucket.dateGroups) { + const dateGroupTitle = dateGroup.groupTitle; + if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) { assetInteraction.addGroupToMultiselectGroup(dateGroupTitle); } else { assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle); @@ -691,14 +553,16 @@ return; } - let start = assetStore.assets.findIndex((a) => a.id === startAsset.id); - let end = assetStore.assets.findIndex((a) => a.id === endAsset.id); + const assets = assetStore.getAssets(); + + let start = assets.findIndex((a) => a.id === startAsset.id); + let end = assets.findIndex((a) => a.id === endAsset.id); if (start > end) { [start, end] = [end, start]; } - assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1)); + assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1)); }; const onSelectStart = (e: Event) => { @@ -710,14 +574,14 @@ const focusNextAsset = async () => { if (assetInteraction.focussedAssetId === null) { const firstAsset = assetStore.getFirstAsset(); - if (firstAsset !== null) { + if (firstAsset) { assetInteraction.focussedAssetId = firstAsset.id; } } else { - const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); + const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId); if (focussedAsset) { const nextAsset = await assetStore.getNextAsset(focussedAsset); - if (nextAsset !== null) { + if (nextAsset) { assetInteraction.focussedAssetId = nextAsset.id; } } @@ -726,7 +590,7 @@ const focusPreviousAsset = async () => { if (assetInteraction.focussedAssetId !== null) { - const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); + const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId); if (focussedAsset) { const previousAsset = await assetStore.getPreviousAsset(focussedAsset); if (previousAsset) { @@ -736,11 +600,8 @@ } }; - onDestroy(() => { - assetStore.taskManager.removeAllTasksForComponent(componentId); - }); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); - let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0); + let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); $effect(() => { @@ -749,23 +610,6 @@ } }); - $effect(() => { - 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(); - } - }); - let shortcutList = $derived( (() => { if ($isSearchEnabled || $showAssetViewer) { @@ -829,19 +673,34 @@ {#if showShortcuts} <ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} /> {/if} + {#if assetStore.buckets.length > 0} <Scrubber invisible={showSkeleton} {assetStore} - height={safeViewport.height} - timelineTopOffset={topSectionHeight} + height={assetStore.viewportHeight} + timelineTopOffset={assetStore.topSectionHeight} timelineBottomOffset={bottomSectionHeight} {leadout} {scrubOverallPercent} {scrubBucketPercent} {scrubBucket} {onScrub} - {stopScrub} + onScrubKeyDown={(evt) => { + evt.preventDefault(); + let amount = 50; + if (shiftKeyIsDown) { + amount = 500; + } + if (evt.key === 'ArrowUp') { + amount = -amount; + if (shiftKeyIsDown) { + element?.scrollBy({ top: amount, behavior: 'smooth' }); + } + } else if (evt.key === 'ArrowDown') { + element?.scrollBy({ top: amount, behavior: 'smooth' }); + } + }} /> {/if} @@ -850,90 +709,67 @@ id="asset-grid" class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}" tabindex="-1" - use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))} + bind:clientHeight={assetStore.viewportHeight} + bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())} bind:this={element} - onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} + onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} > - <section - use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))} - class:invisible={showSkeleton} - > - {@render children?.()} - {#if isEmpty} - <!-- (optional) empty placeholder --> - {@render empty?.()} - {/if} - </section> - <section bind:this={timelineElement} id="virtual-timeline" class:invisible={showSkeleton} style:height={assetStore.timelineHeight + 'px'} > + <section + use:resizeObserver={topSectionResizeObserver} + class:invisible={showSkeleton} + style:position="absolute" + style:left="0" + style:right="0" + > + {@render children?.()} + {#if isEmpty} + <!-- (optional) empty placeholder --> + {@render empty?.()} + {/if} + </section> + {#each assetStore.buckets as bucket (bucket.viewId)} - {@const isPremeasure = preMeasure.includes(bucket)} - {@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure} + {@const display = bucket.intersecting} + {@const absoluteHeight = bucket.top} - <div - class="bucket" - style:overflow={bucket.measured ? 'visible' : 'clip'} - use:intersectionObserver={[ - { - key: bucket.viewId, - onIntersect: () => handleIntersect(bucket), - onSeparate: () => handleSeparate(bucket), - top: BUCKET_INTERSECTION_ROOT_TOP, - bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, - root: element, - }, - { - key: bucket.viewId + '.bucketintersection', - onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate), - top: '0px', - bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px', - left: '0px', - right: '0px', - }, - ]} - 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} + {#if !bucket.isLoaded} + <div + style:height={bucket.bucketHeight + 'px'} + style:position="absolute" + style:transform={`translate3d(0,${absoluteHeight}px,0)`} + style:width="100%" + > + <Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} /> + </div> + {:else if display} + <div + class="bucket" + style:height={bucket.bucketHeight + 'px'} + style:position="absolute" + style:transform={`translate3d(0,${absoluteHeight}px,0)`} + style:width="100%" + > <AssetDateGroup - assetGridElement={element} - renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP} - renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM} {withStacked} {showArchiveIcon} - {assetStore} {assetInteraction} {isSelectionMode} {singleSelect} - {onScrollTarget} - {onAssetInGrid} {bucket} - viewport={safeViewport} onSelect={({ title, assets }) => handleGroupSelect(title, assets)} onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssets={handleSelectAssets} /> - {/if} - </div> + </div> + {/if} {/each} - <div class="h-[60px]"></div> + <!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> --> </section> </section> @@ -965,6 +801,9 @@ } .bucket { - contain: layout size; + contain: layout size paint; + transform-style: flat; + backface-visibility: hidden; + transform-origin: center center; } </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 deleted file mode 100644 index d3dabaa51d..0000000000 --- a/web/src/lib/components/photos-page/measure-date-group.svelte +++ /dev/null @@ -1,91 +0,0 @@ -<script lang="ts" 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.svelte'; - - interface Props { - assetStore: AssetStore; - bucket: AssetBucket; - onMeasured: () => void; - } - - let { assetStore, bucket, onMeasured }: Props = $props(); - - 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, 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 (dateGroup.date)} - <div id="date-group" data-date-group={dateGroup.date}> - <div use:resizeObserver={({ height }) => assetStore.updateBucketDateGroup(bucket, dateGroup, { 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/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte index 601a40cce2..9d1ba69aec 100644 --- a/web/src/lib/components/photos-page/skeleton.svelte +++ b/web/src/lib/components/photos-page/skeleton.svelte @@ -1,30 +1,28 @@ <script lang="ts"> interface Props { - title?: string | null; - height?: string | null; + height: number; + title: string; } - let { title = null, height = null }: Props = $props(); + let { height = 0, title }: Props = $props(); </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 class="overflow-clip" style:height={height + 'px'}> + <div + class="flex z-[100] 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" + > + {title} + </div> + <div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div> </div> <style> - #skeleton { + [data-skeleton] { background-image: url('/light_skeleton.png'); background-repeat: repeat; background-size: 235px, 235px; } - :global(.dark) #skeleton { + :global(.dark) [data-skeleton] { background-image: url('/dark_skeleton.png'); } @keyframes delayedVisibility { @@ -32,8 +30,10 @@ visibility: visible; } } - #skeleton { + [data-skeleton] { visibility: hidden; - animation: 0s linear 0.1s forwards delayedVisibility; + animation: + 0s linear 0.1s forwards delayedVisibility, + pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } </style> diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index ef0bf3cda7..dbd8ca5a61 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -69,7 +69,7 @@ <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent"> <div id="asset-selection-app-bar" - class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${ + class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 my-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${ forceDark && 'bg-immich-dark-gray text-white' }`} > 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 e7f6bfc5f1..1625c92d3c 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 @@ -8,13 +8,11 @@ import type { Viewport } from '$lib/stores/assets-store.svelte'; import { showDeleteModal } from '$lib/stores/preferences.store'; import { deleteAssets } from '$lib/utils/actions'; - import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils'; + import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { featureFlags } from '$lib/stores/server-config.store'; 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 { t } from 'svelte-i18n'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import ShowShortcuts from '../show-shortcuts.svelte'; @@ -22,6 +20,8 @@ import { handlePromiseError } from '$lib/utils'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { debounce } from 'lodash-es'; + import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; interface Props { assets: AssetResponseDto[]; @@ -53,11 +53,84 @@ let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; + let geometry: CommonJustifiedLayout | undefined = $state(); + + $effect(() => { + const _assets = assets; + updateSlidingWindow(); + + geometry = getJustifiedLayoutFromAssets(_assets, { + spacing: 2, + heightTolerance: 0.15, + rowHeight: 235, + rowWidth: Math.floor(viewport.width), + }); + }); + + let assetLayouts = $derived.by(() => { + const assetLayout = []; + let containerHeight = 0; + let containerWidth = 0; + if (geometry) { + containerHeight = geometry.containerHeight; + containerWidth = geometry.containerWidth; + for (const [i, asset] of assets.entries()) { + const layout = { + asset, + top: geometry.getTop(i), + left: geometry.getLeft(i), + width: geometry.getWidth(i), + height: geometry.getHeight(i), + }; + // 54 is the content height of the asset-selection-app-bar + const layoutTopWithOffset = layout.top + 54; + const layoutBottom = layoutTopWithOffset + layout.height; + + const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top; + assetLayout.push({ ...layout, display }); + } + } + + return { + assetLayout, + containerHeight, + containerWidth, + }; + }); + let showShortcuts = $state(false); let currentViewAssetIndex = 0; let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: AssetResponseDto | null = $state(null); + let slidingWindow = $state({ top: 0, bottom: 0 }); + const updateSlidingWindow = () => { + const v = $state.snapshot(viewport); + const top = document.scrollingElement?.scrollTop || 0; + const bottom = top + v.height; + const w = { + top, + bottom, + }; + slidingWindow = w; + }; + const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); + + let lastIntersectedHeight = 0; + $effect(() => { + // notify we got to (near) the end of scroll + const scrollPercentage = + ((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) * + 100; + + if (scrollPercentage > 90) { + const intersectedHeight = geometry?.containerHeight || 0; + if (lastIntersectedHeight !== intersectedHeight) { + debouncedOnIntersected(); + lastIntersectedHeight = intersectedHeight; + } + } + }); const viewAssetHandler = async (asset: AssetResponseDto) => { currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id); setAsset(assets[currentViewAssetIndex]); @@ -75,6 +148,7 @@ const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Shift') { event.preventDefault(); + shiftKeyIsDown = true; } }; @@ -90,7 +164,7 @@ if (!asset) { return; } - const deselect = assetInteraction.selectedAssets.has(asset); + const deselect = assetInteraction.hasSelectedAsset(asset.id); // Select/deselect already loaded assets if (deselect) { @@ -173,7 +247,7 @@ const toggleArchive = async () => { const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); if (ids) { - assets.filter((asset) => !ids.includes(asset.id)); + assets = assets.filter((asset) => !ids.includes(asset.id)); deselectAllAssets(); } }; @@ -248,7 +322,7 @@ } }; - const handleRandom = async (): Promise<AssetResponseDto | null> => { + const handleRandom = async (): Promise<AssetResponseDto | undefined> => { try { let asset: AssetResponseDto | undefined; if (onRandom) { @@ -261,14 +335,14 @@ } if (!asset) { - return null; + return; } await navigateToAsset(asset); return asset; } catch (error) { handleError(error, $t('errors.cannot_navigate_next_asset')); - return null; + return; } }; @@ -335,26 +409,6 @@ let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); - let geometry = $derived( - (() => { - const justifiedLayoutResult = justifiedLayout( - assets.map((asset) => getAssetRatio(asset)), - { - boxSpacing: 2, - containerWidth: Math.floor(viewport.width), - containerPadding: 0, - targetRowHeightTolerance: 0.15, - targetRowHeight: 235, - }, - ); - - return { - ...justifiedLayoutResult, - containerWidth: calculateWidth(justifiedLayoutResult.boxes), - }; - })(), - ); - $effect(() => { if (!lastAssetMouseEvent) { assetInteraction.clearAssetSelectionCandidates(); @@ -374,7 +428,13 @@ }); </script> -<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} /> +<svelte:window + onkeydown={onKeyDown} + onkeyup={onKeyUp} + onselectstart={onSelectStart} + use:shortcuts={shortcutList} + onscroll={() => updateSlidingWindow()} +/> {#if isShowDeleteConfirmation} <DeleteAssetDialog @@ -389,43 +449,50 @@ {/if} {#if assets.length > 0} - <div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px "> - {#each assets as asset, i (i)} - <div - class="absolute" - style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i] - .top}px; left: {geometry.boxes[i].left}px" - title={showAssetName ? asset.originalFileName : ''} - > - <Thumbnail - readonly={disableAssetSelect} - onClick={(asset) => { - if (assetInteraction.selectionActive) { - handleSelectAssets(asset); - return; - } - void viewAssetHandler(asset); - }} - onSelect={(asset) => handleSelectAssets(asset)} - onMouseEvent={() => assetMouseEventHandler(asset)} - handleFocus={() => assetOnFocusHandler(asset)} - onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} - {showArchiveIcon} - {asset} - selected={assetInteraction.selectedAssets.has(asset)} - focussed={assetInteraction.isFocussedAsset(asset)} - selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} - thumbnailWidth={geometry.boxes[i].width} - thumbnailHeight={geometry.boxes[i].height} - /> - {#if showAssetName} - <div - class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap" - > - {asset.originalFileName} - </div> - {/if} - </div> + <div + style:position="relative" + style:height={assetLayouts.containerHeight + 'px'} + style:width={assetLayouts.containerWidth - 1 + 'px'} + > + {#each assetLayouts.assetLayout as layout (layout.asset.id)} + {@const asset = layout.asset} + + {#if layout.display} + <div + class="absolute" + style:overflow="clip" + style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px" + title={showAssetName ? asset.originalFileName : ''} + > + <Thumbnail + readonly={disableAssetSelect} + onClick={(asset) => { + if (assetInteraction.selectionActive) { + handleSelectAssets(asset); + return; + } + void viewAssetHandler(asset); + }} + onSelect={(asset) => handleSelectAssets(asset)} + onMouseEvent={() => assetMouseEventHandler(asset)} + handleFocus={() => assetOnFocusHandler(asset)} + {showArchiveIcon} + {asset} + selected={assetInteraction.hasSelectedAsset(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + focussed={assetInteraction.isFocussedAsset(asset)} + thumbnailWidth={layout.width} + thumbnailHeight={layout.height} + /> + {#if showAssetName} + <div + class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap" + > + {asset.originalFileName} + </div> + {/if} + </div> + {/if} {/each} </div> {/if} diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte index 729810d022..d13c12cf6a 100644 --- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -1,10 +1,8 @@ <script lang="ts"> - import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets-store.svelte'; - import { DateTime } from 'luxon'; + import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte'; import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util'; import { clamp } from 'lodash-es'; - import { onMount } from 'svelte'; - import { isTimelineScrolling } from '$lib/stores/timeline.store'; + import { DateTime } from 'luxon'; import { fade, fly } from 'svelte/transition'; interface Props { @@ -15,11 +13,12 @@ invisible?: boolean; scrubOverallPercent?: number; scrubBucketPercent?: number; - scrubBucket?: { bucketDate: string | undefined } | undefined; + scrubBucket?: { bucketDate: string | undefined }; leadout?: boolean; - onScrub?: ScrubberListener | undefined; - startScrub?: ScrubberListener | undefined; - stopScrub?: ScrubberListener | undefined; + onScrub?: ScrubberListener; + onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; + startScrub?: ScrubberListener; + stopScrub?: ScrubberListener; } let { @@ -27,25 +26,22 @@ timelineBottomOffset = 0, height = 0, assetStore, - invisible = false, scrubOverallPercent = 0, scrubBucketPercent = 0, scrubBucket = undefined, leadout = false, onScrub = undefined, + onScrubKeyDown = undefined, startScrub = undefined, stopScrub = undefined, }: Props = $props(); let isHover = $state(false); let isDragging = $state(false); - let hoverLabel: string | undefined = $state(); - let bucketDate: string | undefined; let hoverY = $state(0); let clientY = 0; let windowHeight = $state(0); let scrollBar: HTMLElement | undefined = $state(); - let segments: Segment[] = $state([]); const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2); const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2); @@ -87,28 +83,11 @@ return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2; } }; - let scrollY = $state(0); - $effect(() => { - scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent); - }); - - let timelineFullHeight = $derived(assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset); + let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent)); + let timelineFullHeight = $derived(assetStore.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset); let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); let relativeBottomOffset = $derived(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; @@ -119,7 +98,7 @@ hasDot: boolean; }; - const calculateSegments = (buckets: AssetBucket[]) => { + const calculateSegments = (buckets: LiteBucket[]) => { let height = 0; let dotHeight = 0; @@ -127,11 +106,10 @@ let previousLabeledSegment: Segment | undefined; for (const [i, bucket] of buckets.entries()) { - const scrollBarPercentage = - bucket.bucketHeight / (assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset); + const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight; const segment = { - count: bucket.assets.length, + count: bucket.assetCount, height: toScrollY(scrollBarPercentage), bucketDate: bucket.bucketDate, date: fromLocalDateTime(bucket.bucketDate), @@ -161,14 +139,23 @@ segments.push(segment); } - hoverLabel = segments[0]?.dateFormatted; return segments; }; - - const updateLabel = (segment: HTMLElement) => { - hoverLabel = segment.dataset.label; - bucketDate = segment.dataset.timeSegmentBucketDate; - }; + let activeSegment: HTMLElement | undefined = $state(); + const segments = $derived(calculateSegments(assetStore.scrubberBuckets)); + const hoverLabel = $derived(activeSegment?.dataset.label); + const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate); + const scrollHoverLabel = $derived.by(() => { + const y = scrollY; + let cur = 0; + for (const segment of segments) { + if (y <= cur + segment.height + relativeTopOffset) { + return segment.dateFormatted; + } + cur += segment.height; + } + return ''; + }); const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => { const wasDragging = isDragging; @@ -189,7 +176,8 @@ const segment = elems.find(({ id }) => id === 'time-segment'); let bucketPercentY = 0; if (segment) { - updateLabel(segment as HTMLElement); + activeSegment = segment as HTMLElement; + const sr = segment.getBoundingClientRect(); const sy = sr.y; const relativeY = clientY - sy; @@ -197,9 +185,9 @@ } else { const leadin = elems.find(({ id }) => id === 'lead-in'); if (leadin) { - updateLabel(leadin as HTMLElement); + activeSegment = leadin as HTMLElement; } else { - bucketDate = undefined; + activeSegment = undefined; bucketPercentY = 0; } } @@ -230,27 +218,34 @@ onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} /> -<!-- svelte-ignore a11y_no_static_element_interactions --> - <div transition:fly={{ x: 50, duration: 250 }} + tabindex="-1" + role="scrollbar" + aria-controls="time-label" + aria-valuenow={scrollY + HOVER_DATE_HEIGHT} + aria-valuemax={toScrollY(100)} + aria-valuemin={toScrollY(0)} 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} onmouseenter={() => (isHover = true)} onmouseleave={() => (isHover = false)} + onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)} > {#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" + class={[ + { 'border-b-2': isDragging }, + { 'rounded-bl-md': !isDragging }, + 'truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md 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} @@ -262,12 +257,12 @@ class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" style:top="{scrollY + HOVER_DATE_HEIGHT}px" > - {#if $isTimelineScrolling && scrubBucket?.bucketDate} + {#if assetStore.scrolling && scrollHoverLabel} <p transition:fade={{ duration: 200 }} class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 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/80 dark:text-immich-dark-fg" > - {assetStore.getBucketByDate(scrubBucket.bucketDate)?.bucketDateFormattted} + {scrollHoverLabel} </p> {/if} </div> diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index f14a836be6..3faa87f28f 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -121,15 +121,14 @@ <Portal target="body"> {#if showMessage} - <div + <dialog + open class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6" transition:fade={{ duration: 150 }} onmouseover={() => (hoverMessage = true)} onmouseleave={() => (hoverMessage = false)} onfocus={() => (hoverMessage = true)} onblur={() => (hoverMessage = false)} - role="dialog" - tabindex="0" > <div class="flex justify-between place-items-center"> <div class="h-10 w-10"> @@ -178,6 +177,12 @@ {$t('purchase_button_reminder')} </Button> </div> - </div> + </dialog> {/if} </Portal> + +<style> + dialog { + margin: 0; + } +</style> diff --git a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte index 6ed915e683..7da2215a77 100644 --- a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte +++ b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte @@ -45,7 +45,7 @@ onclick={() => {}} /> </li> - {#each pathSegments as segment, index (segment)} + {#each pathSegments as segment, index (index)} {@const isLastSegment = index === pathSegments.length - 1} <li class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary" 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 e6b9954349..e59e2daa70 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 @@ -62,7 +62,7 @@ const onRandom = () => { if (assets.length <= 0) { - return Promise.resolve(null); + return Promise.resolve(undefined); } const index = Math.floor(Math.random() * assets.length); const asset = assets[index]; diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index b296e49f17..84173fe944 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -358,15 +358,24 @@ export enum SettingInputFieldType { COLOR = 'color', } -export enum AlbumPageViewMode { - LINK_SHARING = 'link-sharing', - SELECT_USERS = 'select-users', - SELECT_THUMBNAIL = 'select-thumbnail', - SELECT_ASSETS = 'select-assets', - VIEW_USERS = 'view-users', - VIEW = 'view', - OPTIONS = 'options', -} +export const AlbumPageViewMode = { + LINK_SHARING: 'link-sharing', + SELECT_USERS: 'select-users', + SELECT_THUMBNAIL: 'select-thumbnail', + SELECT_ASSETS: 'select-assets', + VIEW_USERS: 'view-users', + VIEW: 'view', + OPTIONS: 'options', +}; + +export type AlbumPageViewMode = + | typeof AlbumPageViewMode.LINK_SHARING + | typeof AlbumPageViewMode.SELECT_USERS + | typeof AlbumPageViewMode.SELECT_THUMBNAIL + | typeof AlbumPageViewMode.SELECT_ASSETS + | typeof AlbumPageViewMode.VIEW_USERS + | typeof AlbumPageViewMode.VIEW + | typeof AlbumPageViewMode.OPTIONS; export enum PersonPageViewMode { VIEW_ASSETS = 'view-assets', diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts index fb91af579d..c940e6cec7 100644 --- a/web/src/lib/stores/asset-interaction.svelte.ts +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -5,8 +5,14 @@ import { fromStore } from 'svelte/store'; export class AssetInteraction { readonly selectedAssets = new SvelteSet<AssetResponseDto>(); + hasSelectedAsset(assetId: string) { + return [...this.selectedAssets.values()].some((asset) => asset.id === assetId); + } readonly selectedGroup = new SvelteSet<string>(); assetSelectionCandidates = $state(new SvelteSet<AssetResponseDto>()); + hasSelectionCandidate(assetId: string) { + return [...this.assetSelectionCandidates.values()].some((asset) => asset.id === assetId); + } assetSelectionStart = $state<AssetResponseDto | null>(null); focussedAssetId = $state<string | null>(null); @@ -32,7 +38,10 @@ export class AssetInteraction { } removeAssetFromMultiselectGroup(asset: AssetResponseDto) { - this.selectedAssets.delete(asset); + const selectedAsset = [...this.selectedAssets.values()].find((a) => a.id === asset.id); + if (selectedAsset) { + this.selectedAssets.delete(selectedAsset); + } } addGroupToMultiselectGroup(group: string) { diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts index 4ee551bd7b..0685103a1b 100644 --- a/web/src/lib/stores/assets-store.spec.ts +++ b/web/src/lib/stores/assets-store.spec.ts @@ -12,21 +12,25 @@ describe('AssetStore', () => { describe('init', () => { let assetStore: AssetStore; const bucketAssets: Record<string, AssetResponseDto[]> = { - '2024-03-01T00:00:00.000Z': assetFactory.buildList(1), - '2024-02-01T00:00:00.000Z': assetFactory.buildList(100), - '2024-01-01T00:00:00.000Z': assetFactory.buildList(3), + '2024-03-01T00:00:00.000Z': assetFactory + .buildList(1) + .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), + '2024-02-01T00:00:00.000Z': assetFactory + .buildList(100) + .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), + '2024-01-01T00:00:00.000Z': assetFactory + .buildList(3) + .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - - await assetStore.init(); await assetStore.updateViewport({ width: 1588, height: 1000 }); }); @@ -37,51 +41,57 @@ describe('AssetStore', () => { }); it('calculates bucket height', () => { - expect(assetStore.buckets).toEqual( + const plainBuckets = assetStore.buckets.map((bucket) => ({ + bucketDate: bucket.bucketDate, + bucketHeight: bucket.bucketHeight, + })); + + expect(plainBuckets).toEqual( expect.arrayContaining([ - 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-03-01T00:00:00.000Z', bucketHeight: 304 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }), expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(4383); + expect(assetStore.timelineHeight).toBe(5105.333_333_333_333); }); }); describe('loadBucket', () => { let assetStore: AssetStore; const bucketAssets: Record<string, AssetResponseDto[]> = { - '2024-01-03T00:00:00.000Z': assetFactory.buildList(1), - '2024-01-01T00:00:00.000Z': assetFactory.buildList(3), + '2024-01-03T00:00:00.000Z': assetFactory + .buildList(1) + .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), + '2024-01-01T00:00:00.000Z': assetFactory + .buildList(3) + .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ - { count: 1, timeBucket: '2024-01-03T00:00:00.000Z' }, + { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, ]); sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => { - // Allow request to be aborted await new Promise((resolve) => setTimeout(resolve, 0)); if (signal?.aborted) { throw new AbortError(); } - return bucketAssets[timeBucket]; }); - await assetStore.init(); - await assetStore.updateViewport({ width: 0, height: 0 }); + await assetStore.updateViewport({ width: 1588, height: 0 }); }); it('loads a bucket', async () => { - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); + expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0); 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); + expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3); }); it('ignores invalid buckets', async () => { @@ -90,15 +100,13 @@ describe('AssetStore', () => { }); it('cancels bucket loading', async () => { - const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate); - - const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort'); + const bucket = assetStore.getBucketByDate(2024, 1)!; + void assetStore.loadBucket(bucket!.bucketDate); + const abortSpy = vi.spyOn(bucket!.loader!.cancelToken!, 'abort'); bucket?.cancel(); expect(abortSpy).toBeCalledTimes(1); - - await loadPromise; - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); + await assetStore.loadBucket(bucket!.bucketDate); + expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3); }); it('prevents loading buckets multiple times', async () => { @@ -113,15 +121,15 @@ describe('AssetStore', () => { }); it('allows loading a canceled bucket', async () => { - const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); + const bucket = assetStore.getBucketByDate(2024, 1)!; const loadPromise = assetStore.loadBucket(bucket!.bucketDate); - bucket?.cancel(); + bucket.cancel(); await loadPromise; - expect(bucket?.assets.length).toEqual(0); + expect(bucket?.getAssets().length).toEqual(0); - await assetStore.loadBucket(bucket!.bucketDate); - expect(bucket!.assets.length).toEqual(3); + await assetStore.loadBucket(bucket.bucketDate); + expect(bucket!.getAssets().length).toEqual(3); }); }); @@ -129,15 +137,15 @@ describe('AssetStore', () => { let assetStore: AssetStore; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('is empty initially', () => { expect(assetStore.buckets.length).toEqual(0); - expect(assetStore.assets.length).toEqual(0); + expect(assetStore.getAssets().length).toEqual(0); }); it('adds assets to new bucket', () => { @@ -148,10 +156,10 @@ describe('AssetStore', () => { assetStore.addAssets([asset]); expect(assetStore.buckets.length).toEqual(1); - expect(assetStore.assets.length).toEqual(1); - expect(assetStore.buckets[0].assets.length).toEqual(1); + expect(assetStore.getAssets().length).toEqual(1); + expect(assetStore.buckets[0].getAssets().length).toEqual(1); expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z'); - expect(assetStore.assets[0].id).toEqual(asset.id); + expect(assetStore.getAssets()[0].id).toEqual(asset.id); }); it('adds assets to existing bucket', () => { @@ -163,8 +171,8 @@ describe('AssetStore', () => { assetStore.addAssets([assetTwo]); expect(assetStore.buckets.length).toEqual(1); - expect(assetStore.assets.length).toEqual(2); - expect(assetStore.buckets[0].assets.length).toEqual(2); + expect(assetStore.getAssets().length).toEqual(2); + expect(assetStore.buckets[0].getAssets().length).toEqual(2); expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z'); }); @@ -183,12 +191,12 @@ describe('AssetStore', () => { }); assetStore.addAssets([assetOne, assetTwo, assetThree]); - const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); + const bucket = assetStore.getBucketByDate(2024, 1); expect(bucket).not.toBeNull(); - expect(bucket?.assets.length).toEqual(3); - expect(bucket?.assets[0].id).toEqual(assetOne.id); - expect(bucket?.assets[1].id).toEqual(assetThree.id); - expect(bucket?.assets[2].id).toEqual(assetTwo.id); + expect(bucket?.getAssets().length).toEqual(3); + expect(bucket?.getAssets()[0].id).toEqual(assetOne.id); + expect(bucket?.getAssets()[1].id).toEqual(assetThree.id); + expect(bucket?.getAssets()[2].id).toEqual(assetTwo.id); }); it('orders buckets by descending date', () => { @@ -210,17 +218,18 @@ describe('AssetStore', () => { assetStore.addAssets([asset]); expect(updateAssetsSpy).toBeCalledWith([asset]); - expect(assetStore.assets.length).toEqual(1); + expect(assetStore.getAssets().length).toEqual(1); }); // disabled due to the wasm Justified Layout import - it.skip('ignores trashed assets when isTrashed is true', () => { + it('ignores trashed assets when isTrashed is true', async () => { const asset = assetFactory.build({ isTrashed: false }); const trashedAsset = assetFactory.build({ isTrashed: true }); - const assetStore = new AssetStore({ isTrashed: true }); + const assetStore = new AssetStore(); + await assetStore.updateOptions({ isTrashed: true }); assetStore.addAssets([asset, trashedAsset]); - expect(assetStore.assets).toEqual([trashedAsset]); + expect(assetStore.getAssets()).toEqual([trashedAsset]); }); }); @@ -228,9 +237,9 @@ describe('AssetStore', () => { let assetStore: AssetStore; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); @@ -238,7 +247,7 @@ describe('AssetStore', () => { assetStore.updateAssets([assetFactory.build()]); expect(assetStore.buckets.length).toEqual(0); - expect(assetStore.assets.length).toEqual(0); + expect(assetStore.getAssets().length).toEqual(0); }); it('updates an asset', () => { @@ -246,26 +255,29 @@ describe('AssetStore', () => { const updatedAsset = { ...asset, isFavorite: true }; assetStore.addAssets([asset]); - expect(assetStore.assets.length).toEqual(1); - expect(assetStore.assets[0].isFavorite).toEqual(false); + expect(assetStore.getAssets().length).toEqual(1); + expect(assetStore.getAssets()[0].isFavorite).toEqual(false); assetStore.updateAssets([updatedAsset]); - expect(assetStore.assets.length).toEqual(1); - expect(assetStore.assets[0].isFavorite).toEqual(true); + expect(assetStore.getAssets().length).toEqual(1); + expect(assetStore.getAssets()[0].isFavorite).toEqual(true); }); - it('replaces bucket date when asset date changes', () => { + it('asset moves buckets when asset date changes', () => { const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' }; assetStore.addAssets([asset]); expect(assetStore.buckets.length).toEqual(1); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).not.toBeNull(); + expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined(); + expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(1); assetStore.updateAssets([updatedAsset]); - expect(assetStore.buckets.length).toEqual(1); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).toBeNull(); - expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).not.toBeNull(); + expect(assetStore.buckets.length).toEqual(2); + expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined(); + expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0); + expect(assetStore.getBucketByDate(2024, 3)).not.toBeUndefined(); + expect(assetStore.getBucketByDate(2024, 3)?.getAssets().length).toEqual(1); }); }); @@ -273,9 +285,9 @@ describe('AssetStore', () => { let assetStore: AssetStore; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); @@ -283,9 +295,9 @@ describe('AssetStore', () => { assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' })); assetStore.removeAssets(['', 'invalid', '4c7d9acc']); - expect(assetStore.assets.length).toEqual(2); + expect(assetStore.getAssets().length).toEqual(2); expect(assetStore.buckets.length).toEqual(1); - expect(assetStore.buckets[0].assets.length).toEqual(2); + expect(assetStore.buckets[0].getAssets().length).toEqual(2); }); it('removes asset from bucket', () => { @@ -293,18 +305,18 @@ describe('AssetStore', () => { assetStore.addAssets([assetOne, assetTwo]); assetStore.removeAssets([assetOne.id]); - expect(assetStore.assets.length).toEqual(1); + expect(assetStore.getAssets().length).toEqual(1); expect(assetStore.buckets.length).toEqual(1); - expect(assetStore.buckets[0].assets.length).toEqual(1); + expect(assetStore.buckets[0].getAssets().length).toEqual(1); }); - it('removes bucket when empty', () => { + it('does not remove bucket when empty', () => { const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); assetStore.addAssets(assets); assetStore.removeAssets(assets.map((asset) => asset.id)); - expect(assetStore.assets.length).toEqual(0); - expect(assetStore.buckets.length).toEqual(0); + expect(assetStore.getAssets().length).toEqual(0); + expect(assetStore.buckets.length).toEqual(1); }); }); @@ -312,14 +324,13 @@ describe('AssetStore', () => { let assetStore: AssetStore; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init(); await assetStore.updateViewport({ width: 0, height: 0 }); }); it('empty store returns null', () => { - expect(assetStore.getFirstAsset()).toBeNull(); + expect(assetStore.getFirstAsset()).toBeUndefined(); }); it('populated store returns first asset', () => { @@ -339,13 +350,19 @@ describe('AssetStore', () => { describe('getPreviousAsset', () => { let assetStore: AssetStore; const bucketAssets: Record<string, AssetResponseDto[]> = { - '2024-03-01T00:00:00.000Z': assetFactory.buildList(1), - '2024-02-01T00:00:00.000Z': assetFactory.buildList(6), - '2024-01-01T00:00:00.000Z': assetFactory.buildList(3), + '2024-03-01T00:00:00.000Z': assetFactory + .buildList(1) + .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), + '2024-02-01T00:00:00.000Z': assetFactory + .buildList(6) + .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), + '2024-01-01T00:00:00.000Z': assetFactory + .buildList(3) + .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' }, { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' }, @@ -353,38 +370,46 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init(); - await assetStore.updateViewport({ width: 0, height: 0 }); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('returns null for invalid assetId', async () => { expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow(); - expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeNull(); + expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined(); }); it('returns previous assetId', async () => { await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); - const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); + const bucket = assetStore.getBucketByDate(2024, 1); - expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]); + const a = bucket!.getAssets()[0]; + const b = bucket!.getAssets()[1]; + const previous = await assetStore.getPreviousAsset(b); + expect(previous).toEqual(a); }); it('returns previous assetId spanning multiple buckets', async () => { 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'); - expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]); + const bucket = assetStore.getBucketByDate(2024, 2); + const previousBucket = assetStore.getBucketByDate(2024, 3); + const a = bucket!.getAssets()[0]; + const b = previousBucket!.getAssets()[0]; + const previous = await assetStore.getPreviousAsset(a); + expect(previous).toEqual(b); }); it('loads previous bucket', async () => { 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'); - const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); - expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]); + const bucket = assetStore.getBucketByDate(2024, 2); + const previousBucket = assetStore.getBucketByDate(2024, 3); + const a = bucket!.getAssets()[0]; + const b = previousBucket!.getAssets()[0]; + const previous = await assetStore.getPreviousAsset(a); + expect(previous).toEqual(b); expect(loadBucketSpy).toBeCalledTimes(1); }); @@ -393,14 +418,14 @@ describe('AssetStore', () => { await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); - const [assetOne, assetTwo, assetThree] = assetStore.assets; + const [assetOne, assetTwo, assetThree] = assetStore.getAssets(); assetStore.removeAssets([assetTwo.id]); expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne); }); it('returns null when no more assets', async () => { await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); - expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull(); + expect(await assetStore.getPreviousAsset(assetStore.getAssets()[0])).toBeUndefined(); }); }); @@ -408,15 +433,15 @@ describe('AssetStore', () => { let assetStore: AssetStore; beforeEach(async () => { - assetStore = new AssetStore({}); + assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid buckets', () => { - expect(assetStore.getBucketByDate('invalid')).toBeNull(); - expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).toBeNull(); + expect(assetStore.getBucketByDate(-1, -1)).toBeUndefined(); + expect(assetStore.getBucketByDate(2024, 3)).toBeUndefined(); }); it('returns the bucket index', () => { @@ -424,8 +449,8 @@ describe('AssetStore', () => { const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); assetStore.addAssets([assetOne, assetTwo]); - expect(assetStore.getBucketIndexByAssetId(assetTwo.id)).toEqual(0); - expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(1); + expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z'); + expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z'); }); it('ignores removed buckets', () => { @@ -434,7 +459,7 @@ describe('AssetStore', () => { assetStore.addAssets([assetOne, assetTwo]); assetStore.removeAssets([assetTwo.id]); - expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(0); + expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z'); }); }); }); diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index c8d2bc7979..249de56a84 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -1,23 +1,201 @@ import { locale } from '$lib/stores/preferences.store'; import { getKey } from '$lib/utils'; -import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager'; -import { generateId } from '$lib/utils/generate-id'; -import { type getJustifiedLayoutFromAssetsFunction } from '$lib/utils/layout-utils'; -import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; -import { fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util'; -import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; -import { throttle } from 'lodash-es'; +import { CancellableTask } from '$lib/utils/cancellable-task'; +import { + getJustifiedLayoutFromAssets, + getPosition, + type CommonLayoutOptions, + type CommonPosition, +} from '$lib/utils/layout-utils'; +import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util'; +import { TUNABLES } from '$lib/utils/tunables'; +import { getAssetInfo, getTimeBucket, getTimeBuckets, TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; +import { debounce, isEqual, throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; + import { SvelteSet } from 'svelte/reactivity'; import { get, writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; import { websocketEvents } from './websocket'; -let getJustifiedLayoutFromAssets: getJustifiedLayoutFromAssetsFunction; +const { + TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, +} = TUNABLES; + +const THUMBNAIL_HEIGHT = 235; +const GAP = 12; +const HEADER = 49; //(1.5rem) type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0]; -export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>; +export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & { + timelineAlbumId?: string; + deferInit?: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function updateObject(target: any, source: any): boolean { + if (!target) { + return false; + } + let updated = false; + for (const key in source) { + // eslint-disable-next-line no-prototype-builtins + if (!source.hasOwnProperty(key)) { + continue; + } + if (typeof target[key] === 'object') { + updated = updated || updateObject(target[key], source[key]); + } else { + // Otherwise, directly copy the value + if (target[key] !== source[key]) { + target[key] = source[key]; + updated = true; + } + } + } + return updated; +} + +class IntersectingAsset { + // --- public --- + readonly #group: AssetDateGroup; + + intersecting = $derived.by(() => { + if (!this.position) { + return false; + } + + const store = this.#group.bucket.store; + const topWindow = store.visibleWindow.top + HEADER - INTERSECTION_EXPAND_TOP; + const bottomWindow = store.visibleWindow.bottom + HEADER + INTERSECTION_EXPAND_BOTTOM; + const positionTop = this.#group.absoluteDateGroupTop + this.position.top; + const positionBottom = positionTop + this.position.height; + + const intersecting = + (positionTop >= topWindow && positionTop < bottomWindow) || + (positionBottom >= topWindow && positionBottom < bottomWindow) || + (positionTop < topWindow && positionBottom >= bottomWindow); + return intersecting; + }); + + position: CommonPosition | undefined = $state(); + asset: AssetResponseDto | undefined = $state(); + id: string = $derived.by(() => this.asset!.id); + + constructor(group: AssetDateGroup, asset: AssetResponseDto) { + this.#group = group; + this.asset = asset; + } +} +type AssetOperation = (asset: AssetResponseDto) => { remove: boolean }; + +type MoveAsset = { asset: AssetResponseDto; year: number; month: number }; +export class AssetDateGroup { + // --- public + readonly bucket: AssetBucket; + readonly index: number; + readonly date: DateTime; + readonly dayOfMonth: number; + intersetingAssets: IntersectingAsset[] = $state([]); + dodo: IntersectingAsset[] = $state([]); + + height = $state(0); + width = $state(0); + intersecting = $derived.by(() => this.intersetingAssets.some((asset) => asset.intersecting)); + + // --- private + top: number = $state(0); + left: number = $state(0); + row = $state(0); + col = $state(0); + + constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) { + this.index = index; + this.bucket = bucket; + this.date = date; + this.dayOfMonth = dayOfMonth; + } + + sortAssets() { + this.intersetingAssets.sort((a, b) => { + const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC(); + const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC(); + return bDate.diff(aDate).milliseconds; + }); + } + + getFirstAsset() { + return this.intersetingAssets[0]?.asset; + } + getRandomAsset() { + const random = Math.floor(Math.random() * this.intersetingAssets.length); + return this.intersetingAssets[random]; + } + + getAssets() { + return this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!); + } + + runAssetOperation(ids: Set<string>, operation: AssetOperation) { + if (ids.size === 0) { + return { + moveAssets: [] as MoveAsset[], + processedIds: new Set<string>(), + unprocessedIds: ids, + changedGeometry: false, + }; + } + const unprocessedIds = new Set<string>(ids); + const processedIds = new Set<string>(); + const moveAssets: MoveAsset[] = []; + let changedGeometry = false; + for (const assetId of unprocessedIds) { + const index = this.intersetingAssets.findIndex((ia) => ia.id == assetId); + if (index !== -1) { + const asset = this.intersetingAssets[index].asset!; + const oldTime = asset.localDateTime; + let { remove } = operation(asset); + const newTime = asset.localDateTime; + if (oldTime !== newTime) { + const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); + const year = utc.get('year'); + const month = utc.get('month'); + if (this.bucket.year !== year || this.bucket.month !== month) { + remove = true; + moveAssets.push({ asset, year, month }); + } + } + unprocessedIds.delete(assetId); + processedIds.add(assetId); + if (remove || this.bucket.store.isExcluded(asset)) { + this.intersetingAssets.splice(index, 1); + changedGeometry = true; + } + } + } + return { moveAssets, processedIds, unprocessedIds, changedGeometry }; + } + + layout(options: CommonLayoutOptions) { + const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!); + const geometry = getJustifiedLayoutFromAssets(assets, options); + this.width = geometry.containerWidth; + this.height = assets.length === 0 ? 0 : geometry.containerHeight; + for (let i = 0; i < this.intersetingAssets.length; i++) { + const position = getPosition(geometry, i); + this.intersetingAssets[i].position = position; + } + } + + get absoluteDateGroupTop() { + return this.bucket.top + this.top; + } + + get groupTitle() { + return formatDateGroupTitle(this.date); + } +} export interface Viewport { width: number; @@ -28,112 +206,276 @@ export type ViewportXY = Viewport & { y: number; }; -interface AssetLookup { - bucket: AssetBucket; - bucketIndex: number; - assetIndex: number; -} - export class AssetBucket { - store!: AssetStore; - bucketDate: string = $state(''); + // --- public --- + #intersecting: boolean = $state(false); + isLoaded: boolean = $state(false); + dateGroups: AssetDateGroup[] = $state([]); + readonly store: AssetStore; + + // --- private --- /** * 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 * Do not derive this height, it is important for it to be updated at specific times, so that * calculateing a delta between estimated and actual (when measured) is correct. */ - bucketHeight: number = $state(0); - isBucketHeightActual: boolean = $state(false); - bucketDateFormattted!: string; - bucketCount: number = $derived.by(() => (this.isLoaded ? this.assets.length : this.initialCount)); - initialCount: number = 0; - assets: AssetResponseDto[] = $state([]); - dateGroups: DateGroup[] = $state([]); - cancelToken: AbortController | undefined = $state(); - /** - * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset. - */ - isPreventCancel: boolean = $state(false); - /** - * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. - */ - complete!: Promise<void>; - loading: boolean = $state(false); - isLoaded: boolean = $state(false); - intersecting: boolean = $state(false); - measured: boolean = $state(false); - measuredPromise!: Promise<void>; + #bucketHeight: number = $state(0); + #top: number = $state(0); + #initialCount: number = 0; - constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) { - Object.assign(this, props); - this.init(); + // --- should be private, but is used by AssetStore --- + + bucketCount: number = $derived( + this.isLoaded + ? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersetingAssets.length, 0) + : this.#initialCount, + ); + loader: CancellableTask | undefined; + isBucketHeightActual: boolean = $state(false); + + readonly bucketDateFormatted: string; + readonly bucketDate: string; + readonly month: number; + readonly year: number; + + constructor(store: AssetStore, utcDate: DateTime, initialCount: number) { + this.store = store; + this.#initialCount = initialCount; + + const year = utcDate.get('year'); + const month = utcDate.get('month'); + const bucketDateFormatted = utcDate.toJSDate().toLocaleString(get(locale), { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + this.bucketDate = utcDate.toISO()!.toString(); + this.bucketDateFormatted = bucketDateFormatted; + this.month = month; + this.year = year; + + this.loader = new CancellableTask( + () => { + this.isLoaded = true; + }, + () => { + this.isLoaded = false; + }, + this.handleLoadError, + ); } + set intersecting(newValue: boolean) { + const old = this.#intersecting; + if (old !== newValue) { + this.#intersecting = newValue; + if (newValue) { + void this.store.loadBucket(this.bucketDate); + } else { + this.cancel(); + } + } + } + + get intersecting() { + return this.#intersecting; + } + + get lastDateGroup() { + return this.dateGroups.at(-1); + } + getFirstAsset() { + return this.dateGroups[0]?.getFirstAsset(); + } + getAssets() { + // eslint-disable-next-line unicorn/no-array-reduce + return this.dateGroups.reduce( + (accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()), + [], + ); + } + + containsAssetId(id: string) { + for (const group of this.dateGroups) { + const index = group.intersetingAssets.findIndex((a) => a.id == id); + if (index !== -1) { + return true; + } + } + return false; + } + + sortDateGroups() { + this.dateGroups.sort((a, b) => b.date.diff(a.date).milliseconds); + } + + runAssetOperation(ids: Set<string>, operation: AssetOperation) { + if (ids.size === 0) { + return { + moveAssets: [] as MoveAsset[], + processedIds: new Set<string>(), + unprocessedIds: ids, + changedGeometry: false, + }; + } + const { dateGroups } = this; + let combinedChangedGeometry = false; + let idsToProcess = new Set(ids); + const idsProcessed = new Set<string>(); + const combinedMoveAssets: MoveAsset[][] = []; + let index = dateGroups.length; + while (index--) { + if (idsToProcess.size > 0) { + const group = dateGroups[index]; + const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation); + if (moveAssets.length > 0) { + combinedMoveAssets.push(moveAssets); + } + idsToProcess = idsToProcess.difference(processedIds); + for (const id of processedIds) { + idsProcessed.add(id); + } + combinedChangedGeometry = combinedChangedGeometry || changedGeometry; + if (group.intersetingAssets.length === 0) { + dateGroups.splice(index, 1); + combinedChangedGeometry = true; + } + } + } + return { + moveAssets: combinedMoveAssets.flat(), + unprocessedIds: idsToProcess, + processedIds: idsProcessed, + changedGeometry: combinedChangedGeometry, + }; + } + + // note - if the assets are not part of this bucket, they will not be added + addAssets(assets: AssetResponseDto[]) { + const lookupCache: { + [dayOfMonth: number]: AssetDateGroup; + } = {}; + const unprocessedAssets: AssetResponseDto[] = []; + const changedDateGroups = new Set<AssetDateGroup>(); + const newDateGroups = new Set<AssetDateGroup>(); + for (const asset of assets) { + const date = DateTime.fromISO(asset.localDateTime).toUTC(); + const month = date.get('month'); + const year = date.get('year'); + if (this.month === month && this.year === year) { + const day = date.get('day'); + let dateGroup: AssetDateGroup | undefined = lookupCache[day]; + if (!dateGroup) { + dateGroup = this.findDateGroupByDay(day); + if (dateGroup) { + lookupCache[day] = dateGroup; + } + } + if (dateGroup) { + const intersectingAsset = new IntersectingAsset(dateGroup, asset); + dateGroup.intersetingAssets.push(intersectingAsset); + changedDateGroups.add(dateGroup); + } else { + dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); + dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset)); + this.dateGroups.push(dateGroup); + lookupCache[day] = dateGroup; + newDateGroups.add(dateGroup); + } + } else { + unprocessedAssets.push(asset); + } + } + for (const group of changedDateGroups) { + group.sortAssets(); + } + for (const group of newDateGroups) { + group.sortAssets(); + } + if (newDateGroups.size > 0) { + this.sortDateGroups(); + } + return unprocessedAssets; + } + getRandomDateGroup() { + const random = Math.floor(Math.random() * this.dateGroups.length); + return this.dateGroups[random]; + } + + getRandomAsset() { + return this.getRandomDateGroup()?.getRandomAsset()?.asset; + } + /** The svelte key for this view model object */ get viewId() { - return this.store.viewId + '-' + this.bucketDate; - } - 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<void>((resolve, reject) => { - this.loadedSignal = resolve; - this.canceledSignal = reject; - }).catch( - () => - // 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. - 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', - }); + return this.bucketDate; } - private loadedSignal: (() => void) | undefined; - private canceledSignal: (() => void) | undefined; - measuredSignal: (() => void) | undefined; + set bucketHeight(height: number) { + const { store } = this; + const index = store.buckets.indexOf(this); + const bucketHeightDelta = height - this.#bucketHeight; + const prevBucket = store.buckets[index - 1]; + if (prevBucket) { + this.#top = prevBucket.#top + prevBucket.#bucketHeight; + } + if (bucketHeightDelta) { + let cursor = index + 1; + while (cursor < store.buckets.length) { + const nextBucket = this.store.buckets[cursor]; + nextBucket.#top += bucketHeightDelta; + cursor++; + } + } + this.#bucketHeight = height; + if (store.topIntersectingBucket) { + const currentIndex = store.buckets.indexOf(store.topIntersectingBucket); + // if the bucket is 'before' the last intersecting bucket in the sliding window + // then adjust the scroll position by the delta, to compensate for the bucket + // size adjustment + if (currentIndex > 0 && index <= currentIndex) { + store.compensateScrollCallback?.(bucketHeightDelta); + } + } + } + get bucketHeight() { + return this.#bucketHeight; + } + + set top(top: number) { + this.#top = top; + } + get top() { + return this.#top + this.store.topSectionHeight; + } + + handleLoadError(error: unknown) { + const _$t = get(t); + handleError(error, _$t('errors.failed_to_load_assets')); + } + + findDateGroupByDay(dayOfMonth: number) { + return this.dateGroups.find((group) => group.dayOfMonth === dayOfMonth); + } + + findAssetAbsolutePosition(assetId: string) { + for (const group of this.dateGroups) { + const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId); + if (intersectingAsset) { + return this.top + group.top + intersectingAsset.position!.top + HEADER; + } + } + return -1; + } 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(); + this.loader?.cancel(); } } const isMismatched = (option: boolean | undefined, value: boolean): boolean => option === undefined ? false : option !== value; -const THUMBNAIL_HEIGHT = 235; - interface AddAsset { type: 'add'; values: AssetResponseDto[]; @@ -162,746 +504,719 @@ export const photoViewerImgElement = writable<HTMLImageElement | null>(null); 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 type LiteBucket = { + bucketHeight: number; + assetCount: number; + bucketDate: string; + bucketDateFormattted: string; }; export class AssetStore { - private assetToBucket: Record<string, AssetLookup> = $derived.by(() => { - const result: Record<string, AssetLookup> = {}; - for (let index = 0; index < this.buckets.length; index++) { - const bucket = this.buckets[index]; - for (let index_ = 0; index_ < bucket.assets.length; index_++) { - const asset = bucket.assets[index_]; - result[asset.id] = { bucket, bucketIndex: index, assetIndex: index_ }; - } - } - return result; - }); - private pendingChanges: PendingChange[] = []; - private unsubscribers: Unsubscriber[] = []; - private options!: AssetApiGetTimeBucketsRequest; - viewport: Viewport = $state({ - height: 0, - width: 0, - }); - private initializedSignal!: () => void; - private store$ = writable(this); - - /** The svelte key for this view model object */ - viewId = generateId(); - - lastScrollTime: number = $state(0); - - // subscribe = this.store$.subscribe; - /** - * A promise that resolves once the store is initialized. - */ - private complete!: Promise<void>; - taskManager = new AssetGridTaskManager(this); - initialized = $state(false); - timelineHeight = $state(0); + // --- public ---- + isInitialized = $state(false); buckets: AssetBucket[] = $state([]); - assets: AssetResponseDto[] = $derived.by(() => { - return this.buckets.flatMap(({ assets }) => assets); - }); + topSectionHeight = $state(0); + timelineHeight = $derived( + this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight, + ); + + // todo - name this better albumAssets: Set<string> = new SvelteSet(); - pendingScrollBucket: AssetBucket | undefined = $state(); - pendingScrollAssetId: string | undefined = $state(); - maxBucketAssets = $state(0); - private listeners: BucketListener[] = []; + // -- for scrubber only + scrubberBuckets: LiteBucket[] = $state([]); + scrubberTimelineHeight: number = $state(0); - constructor( - options: AssetStoreOptions, - private albumId?: string, - ) { - this.setOptions(options); - this.createInitializationSignal(); - this.store$.set(this); + // -- should be private, but used by AssetBucket + compensateScrollCallback: ((delta: number) => void) | undefined; + topIntersectingBucket: AssetBucket | undefined = $state(); + + visibleWindow = $derived.by(() => ({ + top: this.#scrollTop, + bottom: this.#scrollTop + this.viewportHeight, + })); + + initTask = new CancellableTask( + () => { + this.isInitialized = true; + this.connect(); + }, + () => { + this.disconnect(); + this.isInitialized = false; + }, + () => void 0, + ); + + // --- private + static #INIT_OPTIONS = {}; + #viewportHeight = $state(0); + #viewportWidth = $state(0); + #scrollTop = $state(0); + #pendingChanges: PendingChange[] = []; + #unsubscribers: Unsubscriber[] = []; + + #options: AssetStoreOptions = AssetStore.#INIT_OPTIONS; + + #scrolling = $state(false); + #suspendTransitions = $state(false); + #resetScrolling = debounce(() => (this.#scrolling = false), 1000); + #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); + + constructor() {} + + set scrolling(value: boolean) { + this.#scrolling = value; + if (value) { + this.suspendTransitions = true; + this.#resetScrolling(); + } } - private setOptions(options: AssetStoreOptions) { - this.options = { ...options, size: TimeBucketSize.Month }; + get scrolling() { + return this.#scrolling; } - private createInitializationSignal() { - // create a promise, and store its resolve callbacks. The initializedSignal callback - // will be invoked when a the assetstore is initialized. - this.complete = new Promise<void>((resolve) => { - this.initializedSignal = resolve; - }).catch(() => void 0); + set suspendTransitions(value: boolean) { + this.#suspendTransitions = value; + if (value) { + this.#resetSuspendTransitions(); + } } - private addPendingChanges(...changes: PendingChange[]) { - // prevent websocket events from happening before local client events - setTimeout(() => { - this.pendingChanges.push(...changes); - this.processPendingChanges(); - }, 1000); + get suspendTransitions() { + return this.#suspendTransitions; + } + + set viewportWidth(value: number) { + const changed = value !== this.#viewportWidth; + this.#viewportWidth = value; + this.suspendTransitions = true; + // side-effect - its ok! + void this.#updateViewportGeometry(changed); + } + + get viewportWidth() { + return this.#viewportWidth; + } + + set viewportHeight(value: number) { + this.#viewportHeight = value; + this.#suspendTransitions = true; + // side-effect - its ok! + void this.#updateViewportGeometry(false); + } + + get viewportHeight() { + return this.#viewportHeight; + } + + getAssets() { + return this.buckets.flatMap((bucket) => bucket.getAssets()); + } + + #addPendingChanges(...changes: PendingChange[]) { + this.#pendingChanges.push(...changes); + this.#processPendingChanges(); } connect() { - this.unsubscribers.push( - websocketEvents.on('on_upload_success', (_) => { - // TODO!: Temporarily disable to avoid flashing effect of the timeline - // this.addPendingChanges({ type: 'add', values: [asset] }); - }), - websocketEvents.on('on_asset_trash', (ids) => { - this.addPendingChanges({ type: 'trash', values: ids }); - }), - websocketEvents.on('on_asset_update', (asset) => { - this.addPendingChanges({ type: 'update', values: [asset] }); - }), - websocketEvents.on('on_asset_delete', (id: string) => { - this.addPendingChanges({ type: 'delete', values: [id] }); - }), + this.#unsubscribers.push( + websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })), + websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })), + websocketEvents.on('on_asset_update', (asset) => this.#addPendingChanges({ type: 'update', values: [asset] })), + websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })), ); } disconnect() { - for (const unsubscribe of this.unsubscribers) { + for (const unsubscribe of this.#unsubscribers) { unsubscribe(); } - this.unsubscribers = []; + this.#unsubscribers = []; } - private getPendingChangeBatches() { - const batches: PendingChange[] = []; - let batch: PendingChange | undefined; - - for (const { type, values: _values } of this.pendingChanges) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const values = _values as any[]; - - if (batch && batch.type !== type) { - batches.push(batch); - batch = undefined; - } - - if (batch) { - batch.values.push(...values); - } else { - batch = { type, values }; - } - } - - if (batch) { - batches.push(batch); - } - - return batches; - } - - processPendingChanges = throttle(() => { - for (const { type, values } of this.getPendingChangeBatches()) { + #getPendingChangeBatches() { + const batch: { + add: AssetResponseDto[]; + update: AssetResponseDto[]; + remove: string[]; + } = { + add: [], + update: [], + remove: [], + }; + for (const { type, values } of this.#pendingChanges) { switch (type) { case 'add': { - this.addAssets(values); + batch.add.push(...values); + break; } - case 'update': { - this.updateAssets(values); + batch.update.push(...values); + break; } - + case 'delete': case 'trash': { - if (!this.options.isTrashed) { - this.removeAssets(values); - } - break; - } + batch.remove.push(...values); - case 'delete': { - this.removeAssets(values); break; } + // No default } } + return batch; + } - this.pendingChanges = []; - // this.emit(true); + // todo: this should probably be a method isteat + #findBucketForAsset(id: string) { + for (const bucket of this.buckets) { + if (bucket.containsAssetId(id)) { + return bucket; + } + } + } + + updateSlidingWindow(scrollTop: number) { + this.#scrollTop = scrollTop; + this.updateIntersections(); + } + + updateIntersections() { + if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { + return; + } + let topIntersectingBucket = undefined; + for (const bucket of this.buckets) { + this.#updateIntersection(bucket); + if (!topIntersectingBucket && bucket.intersecting) { + topIntersectingBucket = bucket; + } + } + if (this.topIntersectingBucket !== topIntersectingBucket) { + this.topIntersectingBucket = topIntersectingBucket; + } + } + + #updateIntersection(bucket: AssetBucket) { + const bucketTop = bucket.top; + const bucketBottom = bucketTop + bucket.bucketHeight; + const topWindow = this.visibleWindow.top - INTERSECTION_EXPAND_TOP; + const bottomWindow = this.visibleWindow.bottom + INTERSECTION_EXPAND_BOTTOM; + + // a bucket intersections if + // 1) bucket's bottom is in the visible range -or- + // 2) bucket's bottom is in the visible range -or- + // 3) bucket's top is above visible range and bottom is below visible range + bucket.intersecting = + (bucketTop >= topWindow && bucketTop < bottomWindow) || + (bucketBottom >= topWindow && bucketBottom < bottomWindow) || + (bucketTop < topWindow && bucketBottom >= bottomWindow); + } + + #processPendingChanges = throttle(() => { + const { add, update, remove } = this.#getPendingChangeBatches(); + if (add.length > 0) { + this.addAssets(add); + } + if (update.length > 0) { + this.updateAssets(update); + } + if (remove.length > 0) { + this.removeAssets(remove); + } + this.#pendingChanges = []; }, 2500); - 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 (!getJustifiedLayoutFromAssets) { - const module = await import('$lib/utils/layout-utils'); - getJustifiedLayoutFromAssets = module.getJustifiedLayoutFromAssets; - } - - if (bucketListener) { - this.addListener(bucketListener); - } - await this.initialiazeTimeBuckets(); + setCompensateScrollCallback(compensateScrollCallback?: (delta: number) => void) { + this.compensateScrollCallback = compensateScrollCallback; } - async initialiazeTimeBuckets() { - this.timelineHeight = 0; - this.buckets = []; - this.albumAssets.clear(); - + async #initialiazeTimeBuckets() { const timebuckets = await getTimeBuckets({ - ...this.options, + ...this.#options, + size: TimeBucketSize.Month, key: getKey(), }); - this.buckets = timebuckets.map( - (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, initialCount: bucket.count }), - ); - this.initializedSignal(); - this.initialized = true; + this.buckets = timebuckets.map((bucket) => { + const utcDate = DateTime.fromISO(bucket.timeBucket).toUTC(); + return new AssetBucket(this, utcDate, bucket.count); + }); + this.albumAssets.clear(); + this.#updateViewportGeometry(false); } + /** + * If the timeline query options change (i.e. albumId, isArchived, isFavorite, etc) + * call this method to recreate all buckets based on the new options. + * + * @param options The query options for time bucket queries. + */ async updateOptions(options: AssetStoreOptions) { - // Make sure to re-initialize if the personId changes - const needsReinitializing = this.options.personId !== options.personId; - if (!this.initialized && !needsReinitializing) { - this.setOptions(options); + if (options.deferInit) { return; } - - // Make sure to re-initialize if the tagId changes - if (this.options.tagId === options.tagId) { - this.setOptions(options); + if (this.#options !== AssetStore.#INIT_OPTIONS && isEqual(this.#options, options)) { return; } - // TODO: don't call updateObjects frequently after - // init - cancelation of the initialize tasks isn't - // performed right now, and will cause issues if - // multiple updateOptions() calls are interleved. - await this.complete; - this.taskManager.destroy(); - this.taskManager = new AssetGridTaskManager(this); - this.initialized = false; - this.viewId = generateId(); - this.createInitializationSignal(); - this.setOptions(options); - await this.initialiazeTimeBuckets(); - // this.emit(true); - await this.initialLayout(true); + await this.initTask.reset(); + await this.#init(options); + this.#updateViewportGeometry(false); } + async #init(options: AssetStoreOptions) { + // doing the following outside of the task reduces flickr + this.isInitialized = false; + this.buckets = []; + this.albumAssets.clear(); + await this.initTask.execute(async () => { + this.#options = options; + await this.#initialiazeTimeBuckets(); + }, true); + } public destroy() { - this.taskManager.destroy(); - this.listeners = []; - this.initialized = false; + this.disconnect(); + this.isInitialized = false; } - async updateViewport(viewport: Viewport, force?: boolean) { + async updateViewport(viewport: Viewport) { if (viewport.height === 0 && viewport.width === 0) { return; } - if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) { + + if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) { return; } - await this.complete; - // 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 }; - await this.initialLayout(changedWidth); - } - private async initialLayout(changedWidth: boolean) { - for (const bucket of this.buckets) { - this.updateGeometry(bucket, changedWidth); - } - this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); - - const loaders = []; - let height = 0; - for (const bucket of this.buckets) { - if (height >= this.viewport.height) { - break; - } - height += bucket.bucketHeight; - loaders.push(this.loadBucket(bucket.bucketDate)); - } - await Promise.all(loaders); - this.notifyListeners({ type: 'viewport' }); - } - - private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { - if (invalidateHeight) { - bucket.isBucketHeightActual = false; - bucket.measured = false; - for (const assetGroup of bucket.dateGroups) { - assetGroup.heightActual = false; + // special case updateViewport before or soon after call to updateOptions + if (!this.initTask.executed) { + // eslint-disable-next-line unicorn/prefer-ternary + if (this.initTask.loading) { + await this.initTask.waitUntilCompletion(); + } else { + // not executed and not loaded means we should init now, and init will + // also update geometry so just return after + await this.#init(this.#options); } } - const viewportWidth = this.viewport.width; - if (!bucket.isBucketHeightActual) { - const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); - const rows = Math.ceil(unwrappedWidth / viewportWidth); - const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT; - this.setBucketHeight(bucket, height, false); + // changing width affects the actual height, and needs to re-layout + const changedWidth = viewport.width !== this.viewportWidth; + this.viewportHeight = viewport.height; + this.viewportWidth = viewport.width; + this.#updateViewportGeometry(changedWidth); + } + + #updateViewportGeometry(changedWidth: boolean) { + if (!this.isInitialized) { + return; } - const layoutOptions = { + if (this.viewportWidth === 0 || this.viewportHeight === 0) { + return; + } + for (const bucket of this.buckets) { + this.#updateGeometry(bucket, changedWidth); + } + this.updateIntersections(); + this.#createScrubBuckets(); + } + + #createScrubBuckets() { + this.scrubberBuckets = this.buckets.map((bucket) => ({ + assetCount: bucket.bucketCount, + bucketDate: bucket.bucketDate, + bucketDateFormattted: bucket.bucketDateFormatted, + bucketHeight: bucket.bucketHeight, + })); + this.scrubberTimelineHeight = this.timelineHeight; + } + + createLayoutOptions() { + const viewportWidth = this.viewportWidth; + return { spacing: 2, heightTolerance: 0.15, rowHeight: 235, rowWidth: Math.floor(viewportWidth), }; - 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; - } - - assetGroup.geometry = getJustifiedLayoutFromAssets(assetGroup.assets, layoutOptions); + } + #updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { + if (invalidateHeight) { + bucket.isBucketHeightActual = false; } + if (!bucket.isLoaded) { + // optimize - if bucket already has data, no need to create estimates + const viewportWidth = this.viewportWidth; + if (!bucket.isBucketHeightActual) { + const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / viewportWidth); + const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT; + bucket.bucketHeight = height; + } + return; + } + this.#layoutBucket(bucket); } - async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise<void> { - const bucket = this.getBucketByDate(bucketDate); + #layoutBucket(bucket: AssetBucket) { + // these are top offsets, for each row + let cummulativeHeight = 0; + // these are left offsets of each group, for each row + let cummulativeWidth = 0; + let lastRowHeight = 0; + let lastRow = 0; + + let dateGroupRow = 0; + let dateGroupCol = 0; + + const rowSpaceRemaining: number[] = Array.from({ length: bucket.dateGroups.length }); + rowSpaceRemaining.fill(this.viewportWidth, 0, bucket.dateGroups.length); + const options = this.createLayoutOptions(); + for (const assetGroup of bucket.dateGroups) { + assetGroup.layout(options); + rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1; + if (dateGroupCol > 0) { + rowSpaceRemaining[dateGroupRow] -= GAP; + } + if (rowSpaceRemaining[dateGroupRow] >= 0) { + assetGroup.row = dateGroupRow; + assetGroup.col = dateGroupCol; + assetGroup.left = cummulativeWidth; + assetGroup.top = cummulativeHeight; + + dateGroupCol++; + + cummulativeWidth += assetGroup.width + GAP; + } else { + // starting a new row, we need to update the last col of the previous row + cummulativeWidth = 0; + dateGroupRow++; + dateGroupCol = 0; + assetGroup.row = dateGroupRow; + assetGroup.col = dateGroupCol; + assetGroup.left = cummulativeWidth; + + rowSpaceRemaining[dateGroupRow] -= assetGroup.width; + dateGroupCol++; + cummulativeHeight += lastRowHeight; + assetGroup.top = cummulativeHeight; + cummulativeWidth += assetGroup.width + GAP; + lastRow = assetGroup.row - 1; + } + lastRowHeight = assetGroup.height + HEADER; + } + if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) { + cummulativeHeight += lastRowHeight; + } + + bucket.bucketHeight = cummulativeHeight; + bucket.isBucketHeightActual = true; + } + + async loadBucket(bucketDate: string, options?: { cancelable: boolean }): Promise<void> { + let cancelable = true; + if (options) { + cancelable = options.cancelable; + } + + const date = DateTime.fromISO(bucketDate).toUTC(); + const year = date.get('year'); + const month = date.get('month'); + const bucket = this.getBucketByDate(year, month); if (!bucket) { return; } - if (bucket.isLoaded) { - // already loaded + + if (bucket.loader?.executed) { return; } - 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 result = await bucket.loader?.execute(async (signal: AbortSignal) => { const assets = await getTimeBucket( { - ...this.options, + ...this.#options, timeBucket: bucketDate, + size: TimeBucketSize.Month, key: getKey(), }, - { signal: cancelToken.signal }, + { signal }, ); - - if (cancelToken.signal.aborted) { - this.notifyListeners({ type: 'cancel', bucket }); - return; - } - - if (this.albumId) { - const albumAssets = await getTimeBucket( - { - albumId: this.albumId, - timeBucket: bucketDate, - size: this.options.size, - key: getKey(), - }, - { signal: cancelToken.signal }, - ); - if (cancelToken.signal.aborted) { - this.notifyListeners({ type: 'cancel', bucket }); - return; + if (assets) { + if (this.#options.timelineAlbumId) { + const albumAssets = await getTimeBucket( + { + albumId: this.#options.timelineAlbumId, + timeBucket: bucketDate, + size: TimeBucketSize.Month, + key: getKey(), + }, + { signal }, + ); + for (const asset of albumAssets) { + this.albumAssets.add(asset.id); + } } - for (const asset of albumAssets) { - this.albumAssets.add(asset.id); + + const unprocessed = bucket.addAssets(assets); + if (unprocessed.length > 0) { + console.error( + `Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`, + ); } + this.#layoutBucket(bucket); } - - 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; - } - const _$t = get(t); - handleError(error, _$t('errors.failed_to_load_assets')); - bucket.errored(); - } finally { - bucket.cancelToken = undefined; + }, cancelable); + if (result === 'LOADED') { + this.#updateIntersection(bucket); } } - setBucketHeight(bucket: AssetBucket, newHeight: number, isActualHeight: boolean) { - const delta = newHeight - bucket.bucketHeight; - bucket.isBucketHeightActual = isActualHeight; - bucket.bucketHeight = newHeight; - this.timelineHeight += delta; - this.notifyListeners({ type: 'bucket-height', bucket, delta }); - } - - updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) { - const bucket = this.getBucketByDate(bucketDate); - if (!bucket) { - return {}; - } - const delta = 0; - if ('height' in properties) { - this.setBucketHeight(bucket, properties.height!, true); - } - if ('intersecting' in properties) { - bucket.intersecting = properties.intersecting!; - } - if ('measured' in properties) { - if (properties.measured) { - bucket.measuredSignal?.(); - } - bucket.measured = properties.measured!; - } - return { delta }; - } - - 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 }); - } - } - return { delta }; - } - addAssets(assets: AssetResponseDto[]) { const assetsToUpdate: AssetResponseDto[] = []; - const assetsToAdd: AssetResponseDto[] = []; for (const asset of assets) { - if ( - this.assetToBucket[asset.id] || - this.options.userId || - this.options.personId || - this.options.albumId || - this.isExcluded(asset) - ) { - // If asset is already in the bucket we don't need to recalculate - // asset store containers - assetsToUpdate.push(asset); - } else { - assetsToAdd.push(asset); + if (this.isExcluded(asset)) { + continue; } + assetsToUpdate.push(asset); } - this.updateAssets(assetsToUpdate); - this.addAssetsToBuckets(assetsToAdd); + const notUpdated = this.updateAssets(assetsToUpdate); + this.#addAssetsToBuckets([...notUpdated]); } - private addAssetsToBuckets(assets: AssetResponseDto[]) { + #addAssetsToBuckets(assets: AssetResponseDto[]) { if (assets.length === 0) { return; } const updatedBuckets = new Set<AssetBucket>(); + const updatedDateGroups = new Set<AssetDateGroup>(); for (const asset of assets) { - const timeBucket = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month').toString(); - let bucket = this.getBucketByDate(timeBucket); + const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); + const year = utc.get('year'); + const month = utc.get('month'); + let bucket = this.getBucketByDate(year, month); if (!bucket) { - bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT }); + bucket = new AssetBucket(this, utc, 1); this.buckets.push(bucket); } - - bucket.assets.push(asset); + bucket.addAssets([asset]); updatedBuckets.add(bucket); } - this.buckets = this.buckets.sort((a, b) => { - const aDate = DateTime.fromISO(a.bucketDate).toUTC(); - const bDate = DateTime.fromISO(b.bucketDate).toUTC(); - return bDate.diff(aDate).milliseconds; + this.buckets.sort((a, b) => { + return a.year === b.year ? b.month - a.month : b.year - a.year; }); - for (const bucket of updatedBuckets) { - bucket.assets.sort((a, b) => { - const aDate = DateTime.fromISO(a.fileCreatedAt).toUTC(); - const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); - return bDate.diff(aDate).milliseconds; - }); - bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); - this.updateGeometry(bucket, true); + for (const dateGroup of updatedDateGroups) { + dateGroup.sortAssets(); } + for (const bucket of updatedBuckets) { + bucket.sortDateGroups(); + this.#updateGeometry(bucket, true); + } + this.updateIntersections(); } - getBucketByDate(bucketDate: string): AssetBucket | null { - return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; + getBucketByDate(year: number, month: number): AssetBucket | undefined { + return this.buckets.find((bucket) => bucket.year === year && bucket.month === month); } - async findAndLoadBucketAsPending(id: string) { - const bucketInfo = this.assetToBucket[id]; - let bucket: AssetBucket | null = bucketInfo?.bucket ?? null; + async findBucketForAsset(id: string) { + await this.initTask.waitUntilCompletion(); + let bucket = this.#findBucketForAsset(id); if (!bucket) { const asset = await getAssetInfo({ id }); if (!asset || this.isExcluded(asset)) { return; } - bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true }); + bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false }); } - if (bucket && bucket.assets.some((a) => a.id === id)) { - this.pendingScrollBucket = bucket; - this.pendingScrollAssetId = id; + if (bucket && bucket?.containsAssetId(id)) { 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 }) { + async #loadBucketAtTime(localDateTime: string, options?: { cancelable: 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 }); - } + // Only support TimeBucketSize.Month + date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); const iso = date.toISO()!; + const year = date.get('year'); + const month = date.get('month'); await this.loadBucket(iso, options); - return this.getBucketByDate(iso); + return this.getBucketByDate(year, month); } - private async getBucketInfoForAsset( - { id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>, - options: { preventCancel?: boolean; pending?: boolean } = {}, - ) { - const bucketInfo = this.assetToBucket[id]; + async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) { + const bucketInfo = this.#findBucketForAsset(asset.id); if (bucketInfo) { return bucketInfo; } - await this.loadBucketAtTime(localDateTime, options); - return this.assetToBucket[id] || null; + await this.#loadBucketAtTime(asset.localDateTime, options); + return this.#findBucketForAsset(asset.id); } getBucketIndexByAssetId(assetId: string) { - return this.assetToBucket[assetId]?.bucketIndex ?? null; + return this.#findBucketForAsset(assetId); } - async getRandomAsset(): Promise<AssetResponseDto | null> { - let index = Math.floor( - Math.random() * this.buckets.reduce((accumulator, bucket) => accumulator + bucket.bucketCount, 0), - ); - for (const bucket of this.buckets) { - if (index < bucket.bucketCount) { - await this.loadBucket(bucket.bucketDate); - return bucket.assets[index] || null; - } + async getRandomBucket() { + const random = Math.floor(Math.random() * this.buckets.length); + const bucket = this.buckets[random]; + await this.loadBucket(bucket.bucketDate, { cancelable: false }); + return bucket; + } - index -= bucket.bucketCount; + async getRandomAsset() { + const bucket = await this.getRandomBucket(); + return bucket?.getRandomAsset(); + } + + // runs op on assets, returns unprocessed + #runAssetOperation(ids: Set<string>, operation: AssetOperation) { + if (ids.size === 0) { + return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false }; } - return null; + const changedBuckets = new Set<AssetBucket>(); + let idsToProcess = new Set(ids); + const idsProcessed = new Set<string>(); + const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = []; + for (const bucket of this.buckets) { + if (idsToProcess.size > 0) { + const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation); + if (moveAssets.length > 0) { + combinedMoveAssets.push(moveAssets); + } + idsToProcess = idsToProcess.difference(processedIds); + for (const id of processedIds) { + idsProcessed.add(id); + } + if (changedGeometry) { + changedBuckets.add(bucket); + break; + } + } + } + if (combinedMoveAssets.length > 0) { + this.#addAssetsToBuckets(combinedMoveAssets.flat().map((a) => a.asset)); + } + const changedGeometry = changedBuckets.size > 0; + for (const bucket of changedBuckets) { + this.#updateGeometry(bucket, true); + } + if (changedGeometry) { + this.updateIntersections(); + } + return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry }; + } + + /** + * Runs a callback on a list of asset ids. The assets in the AssetStore are reactive - + * any change to the asset (i.e. changing isFavorite, isArchived, etc) will automatically + * cause the UI to update with no further actions needed. Changing the date of an asset + * will automatically move it to another bucket if needed. Removing the asset will remove + * it from any view that is showing it. + * + * @param ids to run the operation on + * @param operation callback to update the specified asset ids + */ + updateAssetOperation(ids: string[], operation: AssetOperation) { + this.#runAssetOperation(new Set(ids), operation); } updateAssets(assets: AssetResponseDto[]) { - if (assets.length === 0) { - return; - } - const assetsToRecalculate: AssetResponseDto[] = []; - - for (const _asset of assets) { - const asset = this.assets.find((asset) => asset.id === _asset.id); - if (!asset) { - continue; - } - - const recalculate = asset.localDateTime !== _asset.localDateTime; - Object.assign(asset, _asset); - - if (recalculate) { - assetsToRecalculate.push(asset); - } - } - - this.removeAssets(assetsToRecalculate.map((asset) => asset.id)); - this.addAssetsToBuckets(assetsToRecalculate); + const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset])); + const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => { + updateObject(asset, lookup.get(asset.id)); + return { remove: false }; + }); + return unprocessedIds.values().map((id) => lookup.get(id)!); } removeAssets(ids: string[]) { - const idSet = new Set(ids); + const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => { + return { remove: true }; + }); + return [...unprocessedIds]; + } - // 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)) { - continue; - } + refreshLayout() { + for (const bucket of this.buckets) { + this.#updateGeometry(bucket, true); + } + this.updateIntersections(); + } - 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); + getFirstAsset(): AssetResponseDto | undefined { + return this.buckets[0]?.getFirstAsset(); + } + + async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { + let bucket = await this.#getBucketInfoForAsset(asset); + if (!bucket) { + return; + } + + for (const group of bucket.dateGroups) { + const index = group.intersetingAssets.findIndex((ia) => ia.id === asset.id); + if (index > 0) { + return group.intersetingAssets[index - 1].asset; } } + + let bucketIndex = this.buckets.indexOf(bucket) - 1; + while (bucketIndex >= 0) { + bucket = this.buckets[bucketIndex]; + if (!bucket) { + return; + } + await this.loadBucket(bucket.bucketDate); + const previous = bucket.lastDateGroup?.intersetingAssets.at(-1)?.asset; + if (previous) { + return previous; + } + bucketIndex--; + } } - getFirstAsset(): AssetResponseDto | null { - if (this.buckets.length > 0 && this.buckets[0].assets.length > 0) { - return this.buckets[0].assets[0]; + async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { + let bucket = await this.#getBucketInfoForAsset(asset); + if (!bucket) { + return; + } + + for (const group of bucket.dateGroups) { + const index = group.intersetingAssets.findIndex((ia) => ia.id === asset.id); + if (index !== -1 && index < group.intersetingAssets.length - 1) { + return group.intersetingAssets[index + 1].asset; + } + } + + let bucketIndex = this.buckets.indexOf(bucket) + 1; + while (bucketIndex < this.buckets.length - 1) { + bucket = this.buckets[bucketIndex]; + await this.loadBucket(bucket.bucketDate); + const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset; + if (next) { + return next; + } + bucketIndex++; } - return null; } - async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> { - const info = await this.getBucketInfoForAsset(asset); - if (!info) { - return null; - } - - const { bucket, assetIndex, bucketIndex } = info; - - if (assetIndex !== 0) { - return bucket.assets[assetIndex - 1]; - } - - if (bucketIndex === 0) { - return null; - } - - const previousBucket = this.buckets[bucketIndex - 1]; - await this.loadBucket(previousBucket.bucketDate); - return previousBucket.assets.at(-1) || null; - } - - async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> { - const info = await this.getBucketInfoForAsset(asset); - if (!info) { - return null; - } - - const { bucket, assetIndex, bucketIndex } = info; - - if (assetIndex !== bucket.assets.length - 1) { - return bucket.assets[assetIndex + 1]; - } - - if (bucketIndex === this.buckets.length - 1) { - return null; - } - - const nextBucket = this.buckets[bucketIndex + 1]; - await this.loadBucket(nextBucket.bucketDate); - return nextBucket.assets[0] || null; - } - - private isExcluded(asset: AssetResponseDto) { + isExcluded(asset: AssetResponseDto) { return ( - isMismatched(this.options.isArchived ?? false, asset.isArchived) || - isMismatched(this.options.isFavorite, asset.isFavorite) || - isMismatched(this.options.isTrashed ?? false, asset.isTrashed) + isMismatched(this.#options.isArchived, asset.isArchived) || + isMismatched(this.#options.isFavorite, asset.isFavorite) || + isMismatched(this.#options.isTrashed, asset.isTrashed) ); } } diff --git a/web/src/lib/stores/timeline.store.ts b/web/src/lib/stores/timeline.store.ts deleted file mode 100644 index b86a171765..0000000000 --- a/web/src/lib/stores/timeline.store.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { writable } from 'svelte/store'; - -export const isTimelineScrolling = writable(false); diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts deleted file mode 100644 index e9a9e459fe..0000000000 --- a/web/src/lib/utils/asset-store-task-manager.ts +++ /dev/null @@ -1,465 +0,0 @@ -import type { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte'; -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); - } - - separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) { - const bucketTask = this.getOrCreateBucketTask(bucket); - bucketTask.scheduleSeparated(componentId, separated); - } - - intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { - const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); - bucketTask.intersectedDateGroup(componentId, dateGroup, intersected); - } - - separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) { - const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); - bucketTask.separatedDateGroup(componentId, dateGroup, separated); - } - - 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); - } - - separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) { - const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); - const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); - dateGroupTask.separatedThumbnail(componentId, asset, separated); - } -} - -class IntersectionTask { - internalTaskManager: InternalTaskManager; - separatedKey; - intersectedKey; - priority; - - intersected: Task | undefined; - separated: Task | undefined; - - constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) { - this.internalTaskManager = internalTaskManager; - this.separatedKey = 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 }; - } - - trackSeparatedTask(componentId: string, task: Task) { - const execTask = () => { - if (this.intersected) { - return; - } - task?.(); - }; - this.separated = execTask; - const cleanup = () => { - this.separated = undefined; - this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey); - }; - return { task: execTask, cleanup }; - } - - removePendingSeparated() { - if (this.separated) { - this.internalTaskManager.removeSeparateTask(this.separatedKey); - } - } - 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, - priority: this.priority, - taskId: this.intersectedKey, - }); - } - - scheduleSeparated(componentId: string, separated: Task) { - this.removePendingIntersected(); - - if (this.separated) { - return; - } - - const { task, cleanup } = this.trackSeparatedTask(componentId, separated); - this.internalTaskManager.queueSeparateTask({ - task, - cleanup, - componentId, - taskId: this.separatedKey, - }); - } -} -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, separated: Task) { - const thumbnailTask = this.getOrCreateThumbnailTask(asset); - thumbnailTask.scheduleSeparated(componentId, separated); - } -} -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 b802bbe0a3..82112b6dbf 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -476,7 +476,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: if (!get(isSelectingAllAssets)) { break; // Cancelled } - assetInteraction.selectAssets(bucket.assets); + assetInteraction.selectAssets(bucket.getAssets().map((a) => $state.snapshot(a))); // We use setTimeout to allow the UI to update. Otherwise, this may // cause a long delay between the start of 'select all' and the diff --git a/web/src/lib/utils/cancellable-task.ts b/web/src/lib/utils/cancellable-task.ts new file mode 100644 index 0000000000..412639af4b --- /dev/null +++ b/web/src/lib/utils/cancellable-task.ts @@ -0,0 +1,135 @@ +export class CancellableTask { + cancelToken: AbortController | null = null; + cancellable: boolean = true; + /** + * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. + */ + complete!: Promise<unknown>; + executed: boolean = false; + + private loadedSignal: (() => void) | undefined; + private canceledSignal: (() => void) | undefined; + + constructor( + private loadedCallback?: () => void, + private canceledCallback?: () => void, + private errorCallback?: (error: unknown) => void, + ) { + this.complete = new Promise<void>((resolve, reject) => { + this.loadedSignal = resolve; + this.canceledSignal = reject; + }).catch( + () => + // if no-one waits on complete its rejected a uncaught rejection message is logged. + // prevent this message with an empty reject handler, since waiting on a bucket is optional. + void 0, + ); + } + + get loading() { + return !!this.cancelToken; + } + + async waitUntilCompletion() { + if (this.executed) { + return 'DONE'; + } + // if there is a cancel token, task is currently executing, so wait on the promise. If it + // isn't, then the task is in new state, it hasn't been loaded, nor has it been executed. + // in either case, we wait on the promise. + await this.complete; + return 'WAITED'; + } + + async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) { + if (this.executed) { + return 'DONE'; + } + + // if promise is pending, wait on previous request instead. + if (this.cancelToken) { + // if promise is pending, and preventCancel is requested, + // do not allow transition from prevent cancel to allow cancel. + if (this.cancellable && !cancellable) { + this.cancellable = cancellable; + } + await this.complete; + return 'WAITED'; + } + this.cancellable = cancellable; + const cancelToken = (this.cancelToken = new AbortController()); + + try { + await f(cancelToken.signal); + this.#transitionToExecuted(); + return 'LOADED'; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((error as any).name === 'AbortError') { + // abort error is not treated as an error, but as a cancelation. + return 'CANCELED'; + } + this.#transitionToErrored(error); + return 'ERRORED'; + } finally { + this.cancelToken = null; + } + } + + private init() { + this.cancelToken = null; + this.executed = false; + // 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<void>((resolve, reject) => { + this.loadedSignal = resolve; + this.canceledSignal = reject; + }).catch( + () => + // if no-one waits on complete its rejected a uncaught rejection message is logged. + // prevent this message with an empty reject handler, since waiting on a bucket is optional. + void 0, + ); + } + + // will reset this job back to the initial state (isLoaded=false, no errors, etc) + async reset() { + this.#transitionToCancelled(); + if (this.cancelToken) { + await this.waitUntilCompletion(); + } + this.init(); + } + + cancel() { + this.#transitionToCancelled(); + } + + #transitionToCancelled() { + if (this.executed) { + return; + } + if (!this.cancellable) { + return; + } + this.cancelToken?.abort(); + this.canceledSignal?.(); + this.init(); + this.canceledCallback?.(); + } + + #transitionToExecuted() { + this.executed = true; + this.loadedSignal?.(); + this.loadedCallback?.(); + } + + #transitionToErrored(error: unknown) { + this.cancelToken = null; + this.canceledSignal?.(); + this.init(); + this.errorCallback?.(error); + } +} diff --git a/web/src/lib/utils/idle-callback-support.ts b/web/src/lib/utils/idle-callback-support.ts deleted file mode 100644 index dde595379f..0000000000 --- a/web/src/lib/utils/idle-callback-support.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 = globalThis.requestIdleCallback || fake_requestIdleCallback; -export const cancelIdleCB = globalThis.cancelIdleCallback || fake_cancelIdleCallback; diff --git a/web/src/lib/utils/keyed-priority-queue.ts b/web/src/lib/utils/keyed-priority-queue.ts deleted file mode 100644 index b78ecdf353..0000000000 --- a/web/src/lib/utils/keyed-priority-queue.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 !== -1) { - 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/layout-utils.ts b/web/src/lib/utils/layout-utils.ts index 6f14ed9825..d55976cb4f 100644 --- a/web/src/lib/utils/layout-utils.ts +++ b/web/src/lib/utils/layout-utils.ts @@ -49,18 +49,21 @@ export function getJustifiedLayoutFromAssets( type Geometry = ReturnType<typeof createJustifiedLayout>; class Adapter { result; + width; constructor(result: Geometry) { this.result = result; + this.width = 0; + for (const box of this.result.boxes) { + if (box.top < 100) { + this.width = box.left + box.width; + } else { + break; + } + } } get containerWidth() { - let width = 0; - for (const box of this.result.boxes) { - if (box.top < 100) { - width = box.left + box.width; - } - } - return width; + return this.width; } get containerHeight() { @@ -84,12 +87,6 @@ class Adapter { } } -export const emptyGeometry = new Adapter({ - containerHeight: 0, - widowCount: 0, - boxes: [], -}); - export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) { const adapter = { targetRowHeight: options.rowHeight, @@ -104,3 +101,26 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou ); return new Adapter(result); } + +export const emptyGeometry = () => + new Adapter({ + containerHeight: 0, + widowCount: 0, + boxes: [], + }); + +export type CommonPosition = { + top: number; + left: number; + width: number; + height: number; +}; + +export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition { + const top = geometry.getTop(boxIdx); + const left = geometry.getLeft(boxIdx); + const width = geometry.getWidth(boxIdx); + const height = geometry.getHeight(boxIdx); + + return { top, left, width, height }; +} diff --git a/web/src/lib/utils/priority-queue.ts b/web/src/lib/utils/priority-queue.ts deleted file mode 100644 index 6b08ffe7ad..0000000000 --- a/web/src/lib/utils/priority-queue.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 d017fb0c9e..f40e2bc3eb 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,21 +1,24 @@ import type { AssetBucket } from '$lib/stores/assets-store.svelte'; import { locale } from '$lib/stores/preferences.store'; -import { emptyGeometry, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; +import { type CommonJustifiedLayout } from '$lib/utils/layout-utils'; import type { AssetResponseDto } from '@immich/sdk'; -import { groupBy, memoize, sortBy } from 'lodash-es'; +import { memoize } from 'lodash-es'; import { DateTime, type LocaleOptions } from 'luxon'; import { get } from 'svelte/store'; export type DateGroup = { + bucket: AssetBucket; + index: number; + row: number; + col: number; date: DateTime; groupTitle: string; assets: AssetResponseDto[]; + assetsIntersecting: boolean[]; height: number; - heightActual: boolean; intersecting: boolean; geometry: CommonJustifiedLayout; - bucket: AssetBucket; }; export type ScrubberListener = ( bucketDate: string | undefined, @@ -40,6 +43,31 @@ export const fromLocalDateTime = (localDateTime: string) => export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => DateTime.fromISO(dateTimeOriginal, { zone: timeZone }); +export type LayoutBox = { + aspectRatio: number; + top: number; + width: number; + height: number; + left: number; + forcedAspectRatio?: boolean; +}; + +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; +} + +export const groupDateFormat: Intl.DateTimeFormatOptions = { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', +}; + export function formatGroupTitle(_date: DateTime): string { if (!_date.isValid) { return _date.toString(); @@ -73,56 +101,7 @@ export function formatGroupTitle(_date: DateTime): string { return getDateLocaleString(date); } - export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); -const formatDateGroupTitle = memoize(formatGroupTitle); - -export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] { - const grouped = groupBy(bucket.assets, (asset) => - getDateLocaleString(fromLocalDateTime(asset.localDateTime), { locale }), - ); - 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, - }; - }); -} - -export type LayoutBox = { - aspectRatio: number; - top: number; - width: number; - height: number; - left: number; - forcedAspectRatio?: boolean; -}; - -export function calculateWidth(boxes: LayoutBox[]): number { - let width = 0; - for (const box of boxes) { - if (box.top < 100) { - 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; -} +export const formatDateGroupTitle = memoize(formatGroupTitle); diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts index 2f425b847d..3e2ed4e5c3 100644 --- a/web/src/lib/utils/tunables.ts +++ b/web/src/lib/utils/tunables.ts @@ -10,56 +10,17 @@ function getNumber(string: string | null, fallback: number) { } return Number.parseInt(string); } -function getFloat(string: string | null, fallback: number) { - if (string === null) { - return fallback; - } - return Number.parseFloat(string); -} export const TUNABLES = { LAYOUT: { WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false), }, - 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), + TIMELINE: { + INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500), + INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500), }, 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)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 763050df82..603250aecc 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 @@ -100,7 +100,7 @@ let oldAt: AssetGridRouteSearchParams | null | undefined = $state(); let backUrl: string = $state(AppRoute.ALBUMS); - let viewMode = $state(AlbumPageViewMode.VIEW); + let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW); let isCreatingSharedAlbum = $state(false); let isShowActivity = $state(false); let isLiked: ActivityResponseDto | null = $state(null); @@ -203,7 +203,9 @@ const handleStartSlideshow = async () => { const asset = - $slideshowNavigation === SlideshowNavigation.Shuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; + $slideshowNavigation === SlideshowNavigation.Shuffle + ? await assetStore.getRandomAsset() + : assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset; if (asset) { setAsset(asset); $slideshowState = SlideshowState.PlaySlideshow; @@ -211,6 +213,7 @@ }; const handleEscape = async () => { + assetStore.suspendTransitions = true; if (viewMode === AlbumPageViewMode.SELECT_USERS) { viewMode = AlbumPageViewMode.VIEW; return; @@ -270,11 +273,8 @@ }; const setModeToView = async () => { + assetStore.suspendTransitions = true; viewMode = AlbumPageViewMode.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 }, @@ -394,14 +394,8 @@ } }); - onDestroy(() => { - assetStore.destroy(); - timelineStore.destroy(); - }); - let album = $state(data.album); let albumId = $derived(album.id); - let albumKey = $derived(`${albumId}_${albumOrder}`); $effect(() => { if (!album.isActivityEnabled && $numberOfComments === 0) { @@ -409,8 +403,18 @@ } }); - let assetStore = $derived(new AssetStore({ albumId, order: albumOrder })); - let timelineStore = $derived(new AssetStore({ isArchived: false, withPartners: true }, albumId)); + let assetStore = new AssetStore(); + $effect(() => { + if (viewMode === AlbumPageViewMode.VIEW) { + void assetStore.updateOptions({ albumId, order: albumOrder }); + } else if (viewMode === AlbumPageViewMode.SELECT_ASSETS) { + void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }); + } + }); + onDestroy(() => assetStore.destroy()); + // let timelineStore = new AssetStore(); + // $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId })); + // onDestroy(() => timelineStore.destroy()); let isOwned = $derived($user.id == album.ownerId); @@ -429,6 +433,22 @@ handlePromiseError(getNumberOfComments()); } }); + const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0); + const isSelectionMode = $derived( + viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL, + ); + const singleSelect = $derived( + viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : viewMode === AlbumPageViewMode.SELECT_THUMBNAIL, + ); + const showArchiveIcon = $derived(viewMode !== AlbumPageViewMode.SELECT_ASSETS); + const onSelect = ({ id }: { id: string }) => { + if (viewMode !== AlbumPageViewMode.SELECT_ASSETS) { + void handleUpdateThumbnail(id); + } + }; + const currentAssetIntersection = $derived( + viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineInteraction : assetInteraction, + ); </script> <div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}> @@ -445,7 +465,14 @@ <AddToAlbum shared /> </ButtonContextMenu> {#if assetInteraction.isAllUserOwned} - <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> + <FavoriteAction + removeFavorite={assetInteraction.isAllFavorite} + onFavorite={(ids, isFavorite) => + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + ></FavoriteAction> {/if} <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <DownloadAction menuItem filename="{album.albumName}.zip" /> @@ -482,6 +509,7 @@ <CircleIconButton title={$t('add_photos')} onclick={async () => { + assetStore.suspendTransitions = true; viewMode = AlbumPageViewMode.SELECT_ASSETS; oldAt = { at: $gridScrollTarget?.at }; await navigate( @@ -576,127 +604,117 @@ {/if} <main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"> - <!-- Use key because AssetGrid can't deal with changing stores --> - {#key albumKey} - {#if viewMode === AlbumPageViewMode.SELECT_ASSETS} - <AssetGrid - enableRouting={false} - assetStore={timelineStore} - assetInteraction={timelineInteraction} - isSelectionMode={true} - /> - {:else} - <AssetGrid - enableRouting={true} - {album} - {assetStore} - {assetInteraction} - isShared={album.albumUsers.length > 0} - isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} - singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} - showArchiveIcon - onSelect={({ id }) => handleUpdateThumbnail(id)} - onEscape={handleEscape} - > - {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL} - <!-- ALBUM TITLE --> - <section class="pt-8 md:pt-24"> - <AlbumTitle - id={album.id} - albumName={album.albumName} - {isOwned} - onUpdate={(albumName) => (album.albumName = albumName)} - /> + <AssetGrid + enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true} + {album} + {assetStore} + assetInteraction={currentAssetIntersection} + {isShared} + {isSelectionMode} + {singleSelect} + {showArchiveIcon} + {onSelect} + onEscape={handleEscape} + > + {#if viewMode !== AlbumPageViewMode.SELECT_ASSETS} + {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL} + <!-- ALBUM TITLE --> + <section class="pt-8 md:pt-24"> + <AlbumTitle + id={album.id} + albumName={album.albumName} + {isOwned} + onUpdate={(albumName) => (album.albumName = albumName)} + /> - {#if album.assetCount > 0} - <AlbumSummary {album} /> - {/if} + {#if album.assetCount > 0} + <AlbumSummary {album} /> + {/if} - <!-- ALBUM SHARING --> - {#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)} - <div class="my-3 flex gap-x-1"> - <!-- link --> - {#if album.hasSharedLink && isOwned} - <CircleIconButton - title={$t('create_link_to_share')} - color="gray" - size="20" - icon={mdiLink} - onclick={() => (viewMode = AlbumPageViewMode.LINK_SHARING)} - /> - {/if} + <!-- ALBUM SHARING --> + {#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)} + <div class="my-3 flex gap-x-1"> + <!-- link --> + {#if album.hasSharedLink && isOwned} + <CircleIconButton + title={$t('create_link_to_share')} + color="gray" + size="20" + icon={mdiLink} + onclick={() => (viewMode = AlbumPageViewMode.LINK_SHARING)} + /> + {/if} - <!-- owner --> - <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}> - <UserAvatar user={album.owner} size="md" /> - </button> - - <!-- users with write access (collaborators) --> - {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)} - <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}> - <UserAvatar {user} size="md" /> - </button> - {/each} - - <!-- display ellipsis if there are readonly users too --> - {#if albumHasViewers} - <CircleIconButton - title={$t('view_all_users')} - color="gray" - size="20" - icon={mdiDotsVertical} - onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)} - /> - {/if} - - {#if isOwned} - <CircleIconButton - color="gray" - size="20" - icon={mdiPlus} - onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)} - title={$t('add_more_users')} - /> - {/if} - </div> - {/if} - <!-- ALBUM DESCRIPTION --> - <AlbumDescription id={album.id} bind:description={album.description} {isOwned} /> - </section> - {/if} - - {#if album.assetCount === 0} - <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center"> - <div class="w-[300px]"> - <p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p> - <button - type="button" - onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)} - class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" - > - <span class="text-text-immich-primary dark:text-immich-dark-primary" - ><Icon path={mdiPlus} size="24" /> - </span> - <span class="text-lg">{$t('select_photos')}</span> + <!-- owner --> + <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}> + <UserAvatar user={album.owner} size="md" /> </button> - </div> - </section> - {/if} - </AssetGrid> - {/if} - {#if showActivityStatus} - <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end"> - <ActivityStatus - disabled={!album.isActivityEnabled} - {isLiked} - numberOfComments={$numberOfComments} - onFavorite={handleFavorite} - onOpenActivityTab={handleOpenAndCloseActivityTab} - /> - </div> + <!-- users with write access (collaborators) --> + {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)} + <button type="button" onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)}> + <UserAvatar {user} size="md" /> + </button> + {/each} + + <!-- display ellipsis if there are readonly users too --> + {#if albumHasViewers} + <CircleIconButton + title={$t('view_all_users')} + color="gray" + size="20" + icon={mdiDotsVertical} + onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)} + /> + {/if} + + {#if isOwned} + <CircleIconButton + color="gray" + size="20" + icon={mdiPlus} + onclick={() => (viewMode = AlbumPageViewMode.SELECT_USERS)} + title={$t('add_more_users')} + /> + {/if} + </div> + {/if} + <!-- ALBUM DESCRIPTION --> + <AlbumDescription id={album.id} bind:description={album.description} {isOwned} /> + </section> + {/if} + + {#if album.assetCount === 0} + <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center"> + <div class="w-[300px]"> + <p class="text-xs dark:text-immich-dark-fg">{$t('add_photos').toUpperCase()}</p> + <button + type="button" + onclick={() => (viewMode = AlbumPageViewMode.SELECT_ASSETS)} + class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" + > + <span class="text-text-immich-primary dark:text-immich-dark-primary" + ><Icon path={mdiPlus} size="24" /> + </span> + <span class="text-lg">{$t('select_photos')}</span> + </button> + </div> + </section> + {/if} {/if} - {/key} + </AssetGrid> + + {#if showActivityStatus} + <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end"> + <ActivityStatus + disabled={!album.isActivityEnabled} + {isLiked} + numberOfComments={$numberOfComments} + onFavorite={handleFavorite} + onOpenActivityTab={handleOpenAndCloseActivityTab} + /> + </div> + {/if} </main> </div> {#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer} 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 c8b239218a..86cfefff77 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 @@ -12,20 +12,23 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; - import { AssetStore } from '$lib/stores/assets-store.svelte'; + import type { PageData } from './$types'; import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { AssetStore } from '$lib/stores/assets-store.svelte'; interface Props { data: PageData; } let { data }: Props = $props(); + const assetStore = new AssetStore(); + void assetStore.updateOptions({ isArchived: true }); + onDestroy(() => assetStore.destroy()); - const assetStore = new AssetStore({ isArchived: true }); const assetInteraction = new AssetInteraction(); const handleEscape = () => { @@ -34,10 +37,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - }); </script> {#if assetInteraction.selectionActive} @@ -45,14 +44,28 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > - <ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> + <ArchiveAction + unarchive + onArchive={(ids, isArchived) => + assetStore.updateAssetOperation(ids, (asset) => { + asset.isArchived = isArchived; + return { remove: false }; + })} + /> <CreateSharedLink /> <SelectAllAssets {assetStore} {assetInteraction} /> <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}> <AddToAlbum /> <AddToAlbum shared /> </ButtonContextMenu> - <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> + <FavoriteAction + removeFavorite={assetInteraction.isAllFavorite} + onFavorite={(ids, isFavorite) => + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + /> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <DownloadAction menuItem /> <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> 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 02cac3644d..120281b07e 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 @@ -29,7 +29,10 @@ let { data }: Props = $props(); - const assetStore = new AssetStore({ isFavorite: true }); + const assetStore = new AssetStore(); + void assetStore.updateOptions({ isFavorite: true }); + onDestroy(() => assetStore.destroy()); + const assetInteraction = new AssetInteraction(); const handleEscape = () => { @@ -38,10 +41,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - }); </script> <!-- Multiselection mode app bar --> diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index c412aa8ea2..160c236049 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -98,7 +98,19 @@ <AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} /> <AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared /> </ButtonContextMenu> - <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} /> + <FavoriteAction + removeFavorite={assetInteraction.isAllFavorite} + onFavorite={(ids, isFavorite) => { + if (data.pathAssets && data.pathAssets.length > 0) { + for (const id of ids) { + const asset = data.pathAssets.find((asset) => asset.id === id); + if (asset) { + asset.isFavorite = isFavorite; + } + } + } + }} + /> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <DownloadAction menuItem /> 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 2239a21cd5..0a71f35ff2 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 @@ -123,7 +123,7 @@ async function navigateRandom() { if (viewingAssets.length <= 0) { - return null; + return undefined; } const index = Math.floor(Math.random() * viewingAssets.length); const asset = await setAssetId(viewingAssets[index]); 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 7885086b44..22a0d82cf4 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 @@ -21,7 +21,9 @@ let { data }: Props = $props(); - const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true }); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true })); + onDestroy(() => assetStore.destroy()); const assetInteraction = new AssetInteraction(); const handleEscape = () => { @@ -30,10 +32,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - }); </script> <main class="grid h-screen bg-immich-bg pt-18 dark:bg-immich-dark-bg"> diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index f5b7d13112..45e18fd398 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -456,10 +456,10 @@ </UserPageLayout> {#if selectHidden} - <div + <dialog + open transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }} class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" - role="dialog" aria-modal="true" aria-labelledby="manage-visibility-title" use:focusTrap @@ -471,5 +471,5 @@ onClose={() => (selectHidden = false)} {loadNextPage} /> - </div> + </dialog> {/if} 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 6cad217377..c41ae7d85c 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 @@ -74,14 +74,9 @@ let numberOfAssets = $state(data.statistics.assets); let { isViewing: showAssetViewer } = assetViewingStore; - const assetStoreOptions = { isArchived: false, personId: data.person.id }; - const assetStore = new AssetStore(assetStoreOptions); - - $effect(() => { - // Check to trigger rebuild the timeline when navigating between people from the info panel - assetStoreOptions.personId = data.person.id; - handlePromiseError(assetStore.updateOptions(assetStoreOptions)); - }); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id })); + onDestroy(() => assetStore.destroy()); const assetInteraction = new AssetInteraction(); @@ -360,9 +355,6 @@ await updateAssetCount(); }; - onDestroy(() => { - assetStore.destroy(); - }); let person = $derived(data.person); let thumbnailData = $derived(getPeopleThumbnailUrl(person)); @@ -418,7 +410,14 @@ <AddToAlbum /> <AddToAlbum shared /> </ButtonContextMenu> - <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> + <FavoriteAction + removeFavorite={assetInteraction.isAllFavorite} + onFavorite={(ids, isFavorite) => + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + /> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <DownloadAction menuItem filename="{person.name || 'immich'}.zip" /> <MenuOption diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 1b6ff3071a..131df3126d 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -33,7 +33,10 @@ import { t } from 'svelte-i18n'; let { isViewing: showAssetViewer } = assetViewingStore; - const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); + const assetStore = new AssetStore(); + void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true }); + onDestroy(() => assetStore.destroy()); + const assetInteraction = new AssetInteraction(); let selectedAssets = $derived(assetInteraction.selectedAssetsArray); @@ -67,10 +70,6 @@ assetStore.updateAssets([still]); }; - onDestroy(() => { - assetStore.destroy(); - }); - beforeNavigate(() => { isFaceEditMode.value = false; }); @@ -88,7 +87,14 @@ <AddToAlbum /> <AddToAlbum shared /> </ButtonContextMenu> - <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} /> + <FavoriteAction + removeFavorite={assetInteraction.isAllFavorite} + onFavorite={(ids, isFavorite) => + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + ></FavoriteAction> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <DownloadAction menuItem /> {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} 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 a35a30c1c4..c7f62cba0b 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 @@ -136,13 +136,17 @@ nextPage = 1; searchResultAssets = []; searchResultAlbums = []; - await loadNextPage(); + await loadNextPage(true); } - const loadNextPage = async () => { + // eslint-disable-next-line svelte/valid-prop-names-in-kit-pages + export const loadNextPage = async (force?: boolean) => { if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) { return; } + if (isLoading && !force) { + return; + } isLoading = true; const searchDto: SearchTerms = { @@ -232,9 +236,6 @@ return tagNames.join(', '); } - // eslint-disable-next-line no-self-assign - const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); - const onAddToAlbum = (assetIds: string[]) => { if (terms.isNotInAlbum.toString() == 'true') { const assetIdSet = new Set(assetIds); @@ -262,13 +263,23 @@ <AddToAlbum {onAddToAlbum} /> <AddToAlbum shared {onAddToAlbum} /> </ButtonContextMenu> - <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={triggerAssetUpdate} /> + <FavoriteAction + removeFavorite={assetInteraction.isAllFavorite} + onFavorite={(ids, isFavorite) => { + for (const id of ids) { + const asset = searchResultAssets.find((asset) => asset.id === id); + if (asset) { + asset.isFavorite = isFavorite; + } + } + }} + /> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <DownloadAction menuItem /> <ChangeDate menuItem /> <ChangeLocation menuItem /> - <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={triggerAssetUpdate} /> + <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} /> {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} <TagAction menuItem /> {/if} @@ -281,6 +292,10 @@ {:else} <div class="fixed z-[100] top-0 left-0 w-full"> <ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}> + <div + class="-z-[1] bg-immich-bg dark:bg-immich-dark-bg" + style="position:absolute;top:0;left:0;right:0;bottom:0;" + ></div> <div class="w-full flex-1 pl-4"> <SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} /> </div> @@ -329,45 +344,43 @@ {/if} <section - class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4" + class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} > - <section class="immich-scrollbar relative overflow-y-auto"> - {#if searchResultAlbums.length > 0} - <section> - <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div> - <AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount /> + {#if searchResultAlbums.length > 0} + <section> + <div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums').toUpperCase()}</div> + <AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount /> - <div class="m-6 text-4xl font-medium text-black/70 dark:text-white/80"> - {$t('photos_and_videos').toUpperCase()} - </div> - </section> - {/if} - <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg"> - {#if searchResultAssets.length > 0} - <GalleryViewer - assets={searchResultAssets} - {assetInteraction} - onIntersected={loadNextPage} - showArchiveIcon={true} - {viewport} - /> - {:else if !isLoading} - <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white"> - <div class="flex flex-col content-center items-center text-center"> - <Icon path={mdiImageOffOutline} size="3.5em" /> - <p class="mt-5 text-3xl font-medium">{$t('no_results')}</p> - <p class="text-base font-normal">{$t('no_results_description')}</p> - </div> - </div> - {/if} - - {#if isLoading} - <div class="flex justify-center py-16 items-center"> - <LoadingSpinner size="48" /> - </div> - {/if} + <div class="m-6 text-4xl font-medium text-black/70 dark:text-white/80"> + {$t('photos_and_videos').toUpperCase()} + </div> </section> + {/if} + <section id="search-content"> + {#if searchResultAssets.length > 0} + <GalleryViewer + assets={searchResultAssets} + {assetInteraction} + onIntersected={loadNextPage} + showArchiveIcon={true} + {viewport} + /> + {:else if !isLoading} + <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white"> + <div class="flex flex-col content-center items-center text-center"> + <Icon path={mdiImageOffOutline} size="3.5em" /> + <p class="mt-5 text-3xl font-medium">{$t('no_results')}</p> + <p class="text-base font-normal">{$t('no_results_description')}</p> + </div> + </div> + {/if} + + {#if isLoading} + <div class="flex justify-center py-16 items-center"> + <LoadingSpinner size="48" /> + </div> + {/if} </section> </section> diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 96bebe34c1..a89da7ad6b 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -24,6 +24,7 @@ import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import { onDestroy } from 'svelte'; interface Props { data: PageData; @@ -39,8 +40,9 @@ const buildMap = (tags: TagResponseDto[]) => { return Object.fromEntries(tags.map((tag) => [tag.value, tag])); }; - - const assetStore = new AssetStore({}); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId })); + onDestroy(() => assetStore.destroy()); let tags = $state<TagResponseDto[]>([]); $effect(() => { @@ -52,10 +54,6 @@ let tagId = $derived(tag?.id); let tree = $derived(buildTree(tags.map((tag) => tag.value))); - $effect.pre(() => { - void assetStore.updateOptions({ tagId }); - }); - const handleNavigation = async (tag: string) => { await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`)); }; 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 e31929f2c5..209f75a302 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 @@ -36,8 +36,10 @@ handlePromiseError(goto(AppRoute.PHOTOS)); } - const options = { isTrashed: true }; - const assetStore = new AssetStore(options); + const assetStore = new AssetStore(); + void assetStore.updateOptions({ isTrashed: true }); + onDestroy(() => assetStore.destroy()); + const assetInteraction = new AssetInteraction(); const handleEmptyTrash = async () => { @@ -56,9 +58,6 @@ message: $t('assets_permanently_deleted_count', { values: { count } }), type: NotificationType.Info, }); - - // reset asset grid (TODO fix in asset store that it should reset when it is empty) - await assetStore.updateOptions(options); } catch (error) { handleError(error, $t('errors.unable_to_empty_trash')); } @@ -80,7 +79,10 @@ }); // reset asset grid (TODO fix in asset store that it should reset when it is empty) - await assetStore.updateOptions(options); + // note - this is still a problem, but updateOptions with the same value will not + // do anything, so need to flip it for it to reload/reinit + // await assetStore.updateOptions({ deferInit: true, isTrashed: true }); + // await assetStore.updateOptions({ deferInit: false, isTrashed: true }); } catch (error) { handleError(error, $t('errors.unable_to_restore_trash')); } @@ -92,10 +94,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - }); </script> {#if assetInteraction.selectionActive} diff --git a/web/tsconfig.json b/web/tsconfig.json index 31aef23e31..c7bc16f52b 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -4,7 +4,7 @@ "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "module": "es2020", + "module": "es2022", "moduleResolution": "bundler", "resolveJsonModule": true, "skipLibCheck": true,