diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index e40c20388b..fe7da0b2c0 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -44,7 +44,7 @@ test.describe('Shared Links', () => { test('download from a shared link', async ({ page }) => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); - await page.locator('.group > div').first().hover(); + await page.locator('.group').first().hover(); await page.waitForSelector('#asset-group-by-date svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); diff --git a/web/src/lib/actions/autogrow.ts b/web/src/lib/actions/autogrow.ts index b79671afc8..ff80454ef3 100644 --- a/web/src/lib/actions/autogrow.ts +++ b/web/src/lib/actions/autogrow.ts @@ -1,4 +1,7 @@ export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => { + if (!textarea) { + return; + } textarea.style.height = height; textarea.style.height = `${textarea.scrollHeight}px`; }; diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts new file mode 100644 index 0000000000..222f76be63 --- /dev/null +++ b/web/src/lib/actions/intersection-observer.ts @@ -0,0 +1,152 @@ +type Config = IntersectionObserverActionProperties & { + observer?: IntersectionObserver; +}; +type TrackedProperties = { + root?: Element | Document | null; + threshold?: number | number[]; + top?: string; + right?: string; + bottom?: string; + left?: string; +}; +type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown; +type OnSeperateCallback = (element: HTMLElement) => unknown; +type IntersectionObserverActionProperties = { + key?: string; + onSeparate?: OnSeperateCallback; + onIntersect?: OnIntersectCallback; + + root?: Element | Document | null; + threshold?: number | number[]; + top?: string; + right?: string; + bottom?: string; + left?: string; + + disabled?: boolean; +}; +type TaskKey = HTMLElement | string; + +function isEquivalent(a: TrackedProperties, b: TrackedProperties) { + return ( + a?.bottom === b?.bottom && + a?.top === b?.top && + a?.left === b?.left && + a?.right == b?.right && + a?.threshold === b?.threshold && + a?.root === b?.root + ); +} + +const elementToConfig = new Map(); + +const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => { + if (!target.isConnected) { + elementToConfig.get(key)?.observer?.unobserve(target); + return; + } + const { + root, + threshold, + top = '0px', + right = '0px', + bottom = '0px', + left = '0px', + onSeparate, + onIntersect, + } = properties; + const rootMargin = `${top} ${right} ${bottom} ${left}`; + const observer = new IntersectionObserver( + (entries: IntersectionObserverEntry[]) => { + // This IntersectionObserver is limited to observing a single element, the one the + // action is attached to. If there are multiple entries, it means that this + // observer is being notified of multiple events that have occured quickly together, + // and the latest element is the one we are interested in. + + entries.sort((a, b) => a.time - b.time); + + const latestEntry = entries.pop(); + if (latestEntry?.isIntersecting) { + onIntersect?.(latestEntry); + } else { + onSeparate?.(target); + } + }, + { + rootMargin, + threshold, + root, + }, + ); + observer.observe(target); + elementToConfig.set(key, { ...properties, observer }); +}; + +function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) { + elementToConfig.set(key, properties); + observe(key, element, properties); +} + +function _intersectionObserver( + key: HTMLElement | string, + element: HTMLElement, + properties: IntersectionObserverActionProperties, +) { + if (properties.disabled) { + properties.onIntersect?.(element); + } else { + configure(key, element, properties); + } + return { + update(properties: IntersectionObserverActionProperties) { + const config = elementToConfig.get(key); + if (!config) { + return; + } + if (isEquivalent(config, properties)) { + return; + } + configure(key, element, properties); + }, + destroy: () => { + if (properties.disabled) { + properties.onSeparate?.(element); + } else { + const config = elementToConfig.get(key); + const { observer, onSeparate } = config || {}; + observer?.unobserve(element); + elementToConfig.delete(key); + if (onSeparate) { + onSeparate?.(element); + } + } + }, + }; +} + +export function intersectionObserver( + element: HTMLElement, + properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[], +) { + // svelte doesn't allow multiple use:action directives of the same kind on the same element, + // so accept an array when multiple configurations are needed. + if (Array.isArray(properties)) { + if (!properties.every((p) => p.key)) { + throw new Error('Multiple configurations must specify key'); + } + const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p)); + return { + update: (properties: IntersectionObserverActionProperties[]) => { + for (const [i, props] of properties.entries()) { + observers[i].update(props); + } + }, + destroy: () => { + for (const observer of observers) { + observer.destroy(); + } + }, + }; + } + return _intersectionObserver(element, element, properties); +} diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts new file mode 100644 index 0000000000..9f3adc44b0 --- /dev/null +++ b/web/src/lib/actions/resize-observer.ts @@ -0,0 +1,43 @@ +type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; + +let observer: ResizeObserver; +let callbacks: WeakMap; + +/** + * Installs a resizeObserver on the given element - when the element changes + * size, invokes a callback function with the width/height. Intended as a + * replacement for bind:clientWidth and bind:clientHeight in svelte4 which use + * an iframe to measure the size of the element, which can be bad for + * performance and memory usage. In svelte5, they adapted bind:clientHeight and + * bind:clientWidth to use an internal resize observer. + * + * TODO: When svelte5 is ready, go back to bind:clientWidth and + * bind:clientHeight. + */ +export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) { + if (!observer) { + callbacks = new WeakMap(); + observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const onResize = callbacks.get(entry.target as HTMLElement); + if (onResize) { + onResize({ + target: entry.target as HTMLElement, + width: entry.borderBoxSize[0].inlineSize, + height: entry.borderBoxSize[0].blockSize, + }); + } + } + }); + } + + callbacks.set(element, onResize); + observer.observe(element); + + return { + destroy: () => { + callbacks.delete(element); + observer.unobserve(element); + }, + }; +} diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts new file mode 100644 index 0000000000..ab9d28ffc9 --- /dev/null +++ b/web/src/lib/actions/thumbhash.ts @@ -0,0 +1,14 @@ +import { decodeBase64 } from '$lib/utils'; +import { thumbHashToRGBA } from 'thumbhash'; + +export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { + const ctx = canvas.getContext('2d'); + if (ctx) { + const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); + const pixels = ctx.createImageData(w, h); + canvas.width = w; + canvas.height = h; + pixels.data.set(rgba); + ctx.putImageData(pixels, 0, 0); + } +} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 7a88aa740b..2256c79eb0 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -19,6 +19,7 @@ import { handlePromiseError } from '$lib/utils'; import AlbumSummary from './album-summary.svelte'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let sharedLink: SharedLinkResponseDto; export let user: UserResponseDto | undefined = undefined; @@ -38,6 +39,9 @@ dragAndDropFilesStore.set({ isDragging: false, files: [] }); } }); + onDestroy(() => { + assetStore.destroy(); + });
- +

(); @@ -201,7 +201,6 @@ websocketEvents.on('on_asset_update', onAssetUpdate), ); - await navigate({ targetRoute: 'current', assetId: asset.id }); slideshowStateUnsubscribe = slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset(); @@ -268,9 +267,8 @@ $isShowDetail = !$isShowDetail; }; - const closeViewer = async () => { - dispatch('close'); - await navigate({ targetRoute: 'current', assetId: null }); + const closeViewer = () => { + dispatch('close', { asset }); }; const closeEditor = () => { @@ -378,9 +376,7 @@ } }; - const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => { - const { isMouseOver } = e.detail; - + const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { previewStackedAsset = isMouseOver ? asset : undefined; }; @@ -392,8 +388,7 @@ } case AssetAction.UNSTACK: { - await closeViewer(); - break; + closeViewer(); } } @@ -585,12 +580,11 @@ ? 'bg-transparent border-2 border-white' : 'bg-gray-700/40'} inline-block hover:bg-transparent" asset={stackedAsset} - onClick={(stackedAsset, event) => { - event.preventDefault(); + onClick={(stackedAsset) => { asset = stackedAsset; preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]]; }} - on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)} + onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} readonly thumbnailSize={stackedAsset.id == asset.id ? 65 : 60} showStackedIcon={false} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 4ff2084b9a..88417f248f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -212,7 +212,6 @@ title={person.name} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} /> diff --git a/web/src/lib/components/asset-viewer/intersection-observer.svelte b/web/src/lib/components/asset-viewer/intersection-observer.svelte deleted file mode 100644 index df89a2ed7d..0000000000 --- a/web/src/lib/components/asset-viewer/intersection-observer.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -
- -
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 966f382838..3919033e4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -12,7 +12,7 @@ import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard'; - import { onDestroy } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; @@ -33,6 +33,7 @@ let imageLoaded: boolean = false; let imageError: boolean = false; let forceUseOriginal: boolean = false; + let loader: HTMLImageElement; $: isWebCompatible = isWebCompatibleImage(asset); $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile; @@ -108,6 +109,25 @@ event.preventDefault(); handlePromiseError(copyImage()); }; + + onMount(() => { + const onload = () => { + imageLoaded = true; + assetFileUrl = imageLoaderUrl; + }; + const onerror = () => { + imageError = imageLoaded = true; + }; + if (loader.complete) { + onload(); + } + loader.addEventListener('load', onload); + loader.addEventListener('error', onerror); + return () => { + loader?.removeEventListener('load', onload); + loader?.removeEventListener('error', onerror); + }; + }); {$t('error_loading_image')} {/if} + +
(imageError = imageLoaded = true)} /> {#if !imageLoaded} -
+
{:else if !imageError} @@ -159,3 +181,15 @@
{/if}
+ + diff --git a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts index 91ea7d3ab1..2525b86160 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/image-thumbnail.spec.ts @@ -3,8 +3,8 @@ import { render } from '@testing-library/svelte'; describe('ImageThumbnail component', () => { beforeAll(() => { - Object.defineProperty(HTMLImageElement.prototype, 'decode', { - value: vi.fn(), + Object.defineProperty(HTMLImageElement.prototype, 'complete', { + value: true, }); }); @@ -12,13 +12,11 @@ describe('ImageThumbnail component', () => { const sut = render(ImageThumbnail, { url: 'http://localhost/img.png', altText: 'test', - thumbhash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', + base64ThumbHash: '1QcSHQRnh493V4dIh4eXh1h4kJUI', widthStyle: '250px', }); - const [_, thumbhash] = sut.getAllByRole('img'); - expect(thumbhash.getAttribute('src')).toContain( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAgCAYAAAD5VeO1AAAMRklEQVR4AQBdAKL/', // truncated - ); + const thumbhash = sut.getByTestId('thumbhash'); + expect(thumbhash).not.toBeFalsy(); }); }); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 8e391ecb59..e03dd35653 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,17 +1,19 @@ -{altText} +{#if errored} +
+ +
+{:else} + {loaded +{/if} {#if hidden}
@@ -57,18 +80,18 @@
{/if} -{#if thumbhash && !complete} - {altText} {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 6b0bd2ee75..c9fbf133c8 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -1,5 +1,5 @@ - - - {#if intersecting} +
+ {#if !loaded && asset.thumbhash} + + {/if} + + {#if display} + +
{ + if (evt.key === 'Enter') { + callClickHandlers(); + } + }} + tabindex={0} + on:click={handleClick} + role="link" + > + {#if mouseOver} + + evt.preventDefault()} + tabindex={0} + > + + {/if}
{#if !readonly && (mouseOver || selected || selectionCandidate)} @@ -189,11 +303,11 @@ altText={$getAltText(asset)} widthStyle="{width}px" heightStyle="{height}px" - thumbhash={asset.thumbhash} curve={selected} + onComplete={() => (loaded = true)} /> {:else} -
+
{/if} @@ -201,6 +315,7 @@ {#if asset.type === AssetTypeEnum.Video}
{/if} - {/if} - - +
+ {/if} +
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 5c4196e54b..5cac0b1945 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -3,7 +3,11 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import { AssetStore } from '$lib/stores/assets.store'; + import { generateId } from '$lib/utils/generate-id'; + import { onDestroy } from 'svelte'; + export let assetStore: AssetStore | undefined = undefined; export let url: string; export let durationInSeconds = 0; export let enablePlayback = false; @@ -13,6 +17,7 @@ export let playIcon = mdiPlayCircleOutline; export let pauseIcon = mdiPauseCircleOutline; + const componentId = generateId(); let remainingSeconds = durationInSeconds; let loading = true; let error = false; @@ -27,6 +32,43 @@ player.src = ''; } } + const onMouseEnter = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = true; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = true; + } + } + }; + + const onMouseLeave = () => { + if (assetStore) { + assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + if (playbackOnIconHover) { + enablePlayback = false; + } + }, + }); + } else { + if (playbackOnIconHover) { + enablePlayback = false; + } + } + }; + + onDestroy(() => { + assetStore?.taskManager.removeAllTasksForComponent(componentId); + });
@@ -37,19 +79,7 @@ {/if} - { - if (playbackOnIconHover) { - enablePlayback = true; - } - }} - on:mouseleave={() => { - if (playbackOnIconHover) { - enablePlayback = false; - } - }} - > + {#if enablePlayback} {#if loading} diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 0dd4251dab..eba26e6e61 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -113,7 +113,6 @@ title={$getPersonNameWithHiddenValue(person.name, person.isHidden)} widthStyle="90px" heightStyle="90px" - thumbhash={null} hidden={person.isHidden} />
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 712100763c..fd4fbdf964 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -265,8 +265,6 @@ title={$t('face_unassigned')} widthStyle="90px" heightStyle="90px" - thumbhash={null} - hidden={false} /> {:then data}
- (galleryInView = true)} - on:hidden={() => (galleryInView = false)} - bottom={-200} +

{/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index dd57160fb4..5ca29967fe 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -1,84 +1,69 @@ -
- {#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)} - {@const asset = groupAssets[0]} - {@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))} - +
+ {#each dateGroups as dateGroup, groupIndex (dateGroup.date)} + {@const display = + dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)} -
{ - isMouseOverGroup = true; - assetMouseEventHandler(groupTitle, null); - }} - on:mouseleave={() => { - isMouseOverGroup = false; - assetMouseEventHandler(groupTitle, null); + id="date-group" + use:intersectionObserver={{ + onIntersect: () => { + $assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), + ); + }, + onSeparate: () => { + $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () => + assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), + ); + }, + top: INTERSECTION_ROOT_TOP, + bottom: INTERSECTION_ROOT_BOTTOM, + root: assetGridElement, + disabled: INTERSECTION_DISABLED, }} + data-display={display} + data-date-group={dateGroup.date} + style:height={dateGroup.height + 'px'} + style:width={dateGroup.geometry.containerWidth + 'px'} + style:overflow={'clip'} > - -
- {#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))} + {#if !display} + + {/if} + {#if display} + + +
+ $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = true; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + })} + on:mouseleave={() => { + $assetStore.taskManager.queueScrollSensitiveTask({ + componentId, + task: () => { + isMouseOverGroup = false; + assetMouseEventHandler(dateGroup.groupTitle, null); + }, + }); + }} + > +
handleSelectGroup(groupTitle, groupAssets)} - on:keydown={() => handleSelectGroup(groupTitle, groupAssets)} + class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" + style:width={dateGroup.geometry.containerWidth + 'px'} > - {#if $selectedGroup.has(groupTitle)} - - {:else} - + {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))} +
handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} + > + {#if $selectedGroup.has(dateGroup.groupTitle)} + + {:else} + + {/if} +
{/if} + + + {dateGroup.groupTitle} +
- {/if} - - {groupTitle} - -
- - -
- {#each groupAssets as asset, index (asset.id)} - {@const box = geometry[groupIndex].boxes[index]} +
- { - if (isSelectionMode || $isMultiSelectState) { - event.preventDefault(); - assetSelectHandler(asset, groupAssets, groupTitle); - return; - } - - assetViewingStore.setAsset(asset); - }} - on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)} - on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)} - selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} - selectionCandidate={$assetSelectionCandidates.has(asset)} - disabled={$assetStore.albumAssets.has(asset.id)} - thumbnailWidth={box.width} - thumbnailHeight={box.height} - /> + {#each dateGroup.assets as asset, index (asset.id)} + {@const box = dateGroup.geometry.boxes[index]} + +
onAssetInGrid?.(asset), + top: `-${TITLE_HEIGHT}px`, + bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`, + right: `-${viewport.width - 1}px`, + root: assetGridElement, + }} + data-asset-id={asset.id} + class="absolute" + style:width={box.width + 'px'} + style:height={box.height + 'px'} + style:top={box.top + 'px'} + style:left={box.left + 'px'} + > + onRetrieveElement(dateGroup, asset, element)} + showStackedIcon={withStacked} + {showArchiveIcon} + {asset} + {groupIndex} + onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} + onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} + onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} + selected={$selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} + selectionCandidate={$assetSelectionCandidates.has(asset)} + disabled={$assetStore.albumAssets.has(asset.id)} + thumbnailWidth={box.width} + thumbnailHeight={box.height} + /> +
+ {/each}
- {/each} -
+
+ {/if}
{/each}
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3e0935d938..db030ed14c 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -1,11 +1,17 @@ @@ -427,78 +763,97 @@ (showShortcuts = !showShortcuts)} /> {/if} - (element.scrollTop = detail)} + height={safeViewport.height} + timelineTopOffset={topSectionHeight} + timelineBottomOffset={bottomSectionHeight} + {leadout} + {scrubOverallPercent} + {scrubBucketPercent} + {scrubBucket} + {onScrub} + {stopScrub} />
((viewport.width = width), (viewport.height = height))} bind:this={element} - on:scroll={handleTimelineScroll} + on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} > - - {#if showSkeleton} -
-
-
- {#each Array.from({ length: 100 }) as _} -
- {/each} -
-
- {/if} - - {#if element} +
((topSectionHeight = height), (topSectionOffset = target.offsetTop))} + class:invisible={showSkeleton} + > - - {#if isEmpty} + {/if} -
- {#each $assetStore.buckets as bucket (bucket.bucketDate)} - assetStore.cancelBucket(bucket)} - let:intersecting - top={750} - bottom={750} - root={element} - > -
- {#if intersecting} - handleGroupSelect(group.title, group.assets)} - on:shift={handleScrollTimeline} - on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} - on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} - assets={bucket.assets} - bucketDate={bucket.bucketDate} - bucketHeight={bucket.bucketHeight} - {viewport} - /> - {/if} -
-
- {/each} -
- {/if} +
+ +
+ {#each $assetStore.buckets as bucket (bucket.bucketDate)} + {@const isPremeasure = preMeasure.includes(bucket)} + {@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure} +
intersectedHandler(bucket), + onSeparate: () => seperatedHandler(bucket), + top: BUCKET_INTERSECTION_ROOT_TOP, + bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, + root: element, + }} + data-bucket-display={bucket.intersecting} + data-bucket-date={bucket.bucketDate} + style:height={bucket.bucketHeight + 'px'} + > + {#if display && !bucket.measured} + (preMeasure = preMeasure.filter((b) => b !== bucket))} + > + {/if} + + {#if !display || !bucket.measured} + + {/if} + {#if display && bucket.measured} + handleGroupSelect(group.title, group.assets)} + on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} + on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} + /> + {/if} +
+ {/each} +
+
@@ -522,7 +877,7 @@ diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte new file mode 100644 index 0000000000..98e423ae94 --- /dev/null +++ b/web/src/lib/components/photos-page/measure-date-group.svelte @@ -0,0 +1,89 @@ + + + + +
+ {#each bucket.dateGroups as dateGroup} +
+
$assetStore.updateBucketDateGroup(bucket, dateGroup, { height: height })} + > +
+ + {dateGroup.groupTitle} + +
+ +
+
+
+ {/each} +
diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 43c2958944..5bc55796ae 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -1,4 +1,5 @@ + +
+ {#if title} +
+ {title} +
+ {/if} +
+
+ + diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index f977d91a99..c7b49f6012 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -4,25 +4,25 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { BucketPosition, Viewport } from '$lib/stores/assets.store'; + import type { Viewport } from '$lib/stores/assets.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; import { calculateWidth } from '$lib/utils/timeline-util'; import { type AssetResponseDto } from '@immich/sdk'; import justifiedLayout from 'justified-layout'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import Portal from '../portal/portal.svelte'; - - const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>(); + import { handlePromiseError } from '$lib/utils'; export let assets: AssetResponseDto[]; export let selectedAssets: Set = new Set(); export let disableAssetSelect = false; export let showArchiveIcon = false; export let viewport: Viewport; + export let onIntersected: (() => void) | undefined = undefined; export let showAssetName = false; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; @@ -127,18 +127,15 @@ { - e.preventDefault(); - + onClick={(asset) => { if (isMultiSelectionMode) { selectAssetHandler(asset); return; } - await viewAssetHandler(asset); + void viewAssetHandler(asset); }} - on:select={(e) => selectAssetHandler(e.detail.asset)} - on:intersected={(event) => - i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined} + onSelect={(asset) => selectAssetHandler(asset)} + onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} selected={selectedAssets.has(asset)} {showArchiveIcon} thumbnailWidth={geometry.boxes[i].width} @@ -159,6 +156,15 @@ {#if $isViewerOpen} - + { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} + /> {/if} diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte deleted file mode 100644 index 9282c760c2..0000000000 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ /dev/null @@ -1,183 +0,0 @@ - - - (isDragging || isHover) && handleMouseEvent({ clientY })} - on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} - on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} -/> - - - -{#if $assetStore.timelineHeight > height} -
(isHover = true)} - on:mouseleave={() => (isHover = false)} - > - {#if isHover || isDragging} -
- {hoverLabel} -
- {/if} - - - {#if !isDragging} -
- {/if} - - {#each segments as segment} -
- {#if segment.hasLabel} -
- {segment.date.year} -
- {:else if segment.height > 5} -
- {/if} -
- {/each} -
-{/if} - - diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte new file mode 100644 index 0000000000..e2cc638650 --- /dev/null +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -0,0 +1,281 @@ + + + (isDragging || isHover) && handleMouseEvent({ clientY })} + on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} + on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} +/> + + + +
(isHover = true)} + on:mouseleave={() => (isHover = false)} +> + {#if hoverLabel && (isHover || isDragging)} +
+ {hoverLabel} +
+ {/if} + + {#if !isDragging} +
+ {/if} +
+ {#if relativeTopOffset > 6} +
+ {/if} +
+ + {#each segments as segment} +
+ {#if segment.hasLabel} +
+ {segment.date.year} +
+ {/if} + {#if segment.hasDot} +
+ {/if} +
+ {/each} +
+
+ + diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index fcf68fdb91..2f1efc487c 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -4,7 +4,8 @@ import Portal from '$lib/components/shared-components/portal/portal.svelte'; import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { suggestDuplicateByFileSize } from '$lib/utils'; + import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils'; + import { navigate } from '$lib/utils/navigation'; import { shortcuts } from '$lib/actions/shortcut'; import { type AssetResponseDto } from '@immich/sdk'; import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js'; @@ -158,7 +159,10 @@ const index = getAssetIndex($viewingAsset.id) - 1 + assets.length; setAsset(assets[index % assets.length]); }} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} /> {/await} diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index cabe2e85a1..2e6e44511d 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -1,4 +1,5 @@ import { getKey } from '$lib/utils'; +import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { readonly, writable } from 'svelte/store'; @@ -6,6 +7,7 @@ function createAssetViewingStore() { const viewingAssetStoreState = writable(); const preloadAssets = writable([]); const viewState = writable(false); + const gridScrollTarget = writable(); const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => { preloadAssets.set(assetsToPreload); @@ -26,6 +28,7 @@ function createAssetViewingStore() { asset: readonly(viewingAssetStoreState), preloadAssets: readonly(preloadAssets), isViewing: viewState, + gridScrollTarget, setAsset, setAssetId, showAssetViewer, diff --git a/web/src/lib/stores/asset.store.spec.ts b/web/src/lib/stores/asset.store.spec.ts index 3fd9e1e981..7787bf794d 100644 --- a/web/src/lib/stores/asset.store.spec.ts +++ b/web/src/lib/stores/asset.store.spec.ts @@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { AbortError } from '$lib/utils'; import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; import { assetFactory } from '@test-data/factories/asset-factory'; -import { AssetStore, BucketPosition } from './assets.store'; +import { AssetStore } from './assets.store'; describe('AssetStore', () => { beforeEach(() => { @@ -26,7 +26,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('should load buckets in viewport', () => { @@ -38,15 +39,15 @@ describe('AssetStore', () => { it('calculates bucket height', () => { expect(assetStore.buckets).toEqual( expect.arrayContaining([ - expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }), - expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }), - expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }), + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 286 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3811 }), + expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(4230); + expect(assetStore.timelineHeight).toBe(4383); }); }); @@ -72,35 +73,28 @@ describe('AssetStore', () => { return bucketAssets[timeBucket]; }); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('loads a bucket', async () => { expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3); }); it('ignores invalid buckets', async () => { - await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2023-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(0); }); - it('only updates the position of loaded buckets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown); - - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible); - }); - it('cancels bucket loading', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); const abortSpy = vi.spyOn(bucket!.cancelToken!, 'abort'); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); expect(abortSpy).toBeCalledTimes(1); await loadPromise; @@ -109,24 +103,24 @@ describe('AssetStore', () => { it('prevents loading buckets multiple times', async () => { await Promise.all([ - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), - assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), + assetStore.loadBucket('2024-01-01T00:00:00.000Z'), ]); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); }); it('allows loading a canceled bucket', async () => { const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); - const loadPromise = assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + const loadPromise = assetStore.loadBucket(bucket!.bucketDate); - assetStore.cancelBucket(bucket!); + bucket?.cancel(); await loadPromise; expect(bucket?.assets.length).toEqual(0); - await assetStore.loadBucket(bucket!.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket!.bucketDate); expect(bucket!.assets.length).toEqual(3); }); }); @@ -137,7 +131,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('is empty initially', () => { @@ -219,7 +214,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores non-existing assets', () => { @@ -263,7 +259,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 1588, height: 1000 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('ignores invalid IDs', () => { @@ -312,7 +309,8 @@ describe('AssetStore', () => { ]); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid assetId', async () => { @@ -321,15 +319,15 @@ describe('AssetStore', () => { }); it('returns previous assetId', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]); }); it('returns previous assetId spanning multiple buckets', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z'); @@ -337,7 +335,7 @@ describe('AssetStore', () => { }); it('loads previous bucket', async () => { - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket'); const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z'); @@ -347,9 +345,9 @@ describe('AssetStore', () => { }); it('skips removed assets', async () => { - await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible); - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-01-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-02-01T00:00:00.000Z'); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); const [assetOne, assetTwo, assetThree] = assetStore.assets; assetStore.removeAssets([assetTwo.id]); @@ -357,7 +355,7 @@ describe('AssetStore', () => { }); it('returns null when no more assets', async () => { - await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible); + await assetStore.loadBucket('2024-03-01T00:00:00.000Z'); expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull(); }); }); @@ -368,7 +366,8 @@ describe('AssetStore', () => { beforeEach(async () => { assetStore = new AssetStore({}); sdkMock.getTimeBuckets.mockResolvedValue([]); - await assetStore.init({ width: 0, height: 0 }); + await assetStore.init(); + await assetStore.updateViewport({ width: 0, height: 0 }); }); it('returns null for invalid buckets', () => { diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 1022729e91..7fd82b4c3a 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -1,6 +1,11 @@ +import { locale } from '$lib/stores/preferences.store'; import { getKey } from '$lib/utils'; -import { fromLocalDateTime } from '$lib/utils/timeline-util'; -import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager'; +import { getAssetRatio } from '$lib/utils/asset-utils'; +import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; +import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util'; +import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; +import createJustifiedLayout from 'justified-layout'; import { throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; @@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; import { websocketEvents } from './websocket'; -export enum BucketPosition { - Above = 'above', - Below = 'below', - Visible = 'visible', - Unknown = 'unknown', -} type AssetApiGetTimeBucketsRequest = Parameters[0]; export type AssetStoreOptions = Omit; +const LAYOUT_OPTIONS = { + boxSpacing: 2, + containerPadding: 0, + targetRowHeightTolerance: 0.15, + targetRowHeight: 235, +}; + export interface Viewport { width: number; height: number; } +export type ViewportXY = Viewport & { + x: number; + y: number; +}; interface AssetLookup { bucket: AssetBucket; @@ -29,16 +39,89 @@ interface AssetLookup { } export class AssetBucket { + store!: AssetStore; + bucketDate!: string; /** * The DOM height of the bucket in pixel * This value is first estimated by the number of asset and later is corrected as the user scroll */ - bucketHeight!: number; - bucketDate!: string; - bucketCount!: number; - assets!: AssetResponseDto[]; - cancelToken!: AbortController | null; - position!: BucketPosition; + bucketHeight: number = 0; + isBucketHeightActual: boolean = false; + bucketDateFormattted!: string; + bucketCount: number = 0; + assets: AssetResponseDto[] = []; + dateGroups: DateGroup[] = []; + cancelToken: AbortController | undefined; + /** + * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset. + */ + isPreventCancel: boolean = false; + /** + * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. + */ + complete!: Promise; + loading: boolean = false; + isLoaded: boolean = false; + intersecting: boolean = false; + measured: boolean = false; + measuredPromise!: Promise; + + constructor(props: Partial & { store: AssetStore; bucketDate: string }) { + Object.assign(this, props); + this.init(); + } + + private init() { + // create a promise, and store its resolve/reject callbacks. The loadedSignal callback + // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal + // callback will be called if the bucket is canceled before it was loaded, rejecting the + // promise. + this.complete = new Promise((resolve, reject) => { + this.loadedSignal = resolve; + this.canceledSignal = reject; + }); + // if no-one waits on complete, and its rejected a uncaught rejection message is logged. + // We this message with an empty reject handler, since waiting on a bucket is optional. + this.complete.catch(() => void 0); + this.measuredPromise = new Promise((resolve) => { + this.measuredSignal = resolve; + }); + + this.bucketDateFormattted = fromLocalDateTime(this.bucketDate) + .startOf('month') + .toJSDate() + .toLocaleString(get(locale), { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + } + + private loadedSignal: (() => void) | undefined; + private canceledSignal: (() => void) | undefined; + measuredSignal: (() => void) | undefined; + + cancel() { + if (this.isLoaded) { + return; + } + if (this.isPreventCancel) { + return; + } + this.cancelToken?.abort(); + this.canceledSignal?.(); + this.init(); + } + + loaded() { + this.loadedSignal?.(); + this.isLoaded = true; + } + + errored() { + this.canceledSignal?.(); + this.init(); + } } const isMismatched = (option: boolean | undefined, value: boolean): boolean => @@ -65,34 +148,101 @@ interface TrashAssets { type: 'trash'; values: string[]; } +interface UpdateStackAssets { + type: 'update_stack_assets'; + values: string[]; +} export const photoViewer = writable(null); -type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets; +type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets; + +export type BucketListener = ( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, +) => void; + +type ViewPortEvent = { + type: 'viewport'; +}; +type BucketLoadEvent = { + type: 'load'; + bucket: AssetBucket; +}; +type BucketLoadedEvent = { + type: 'loaded'; + bucket: AssetBucket; +}; +type BucketCancelEvent = { + type: 'cancel'; + bucket: AssetBucket; +}; +type BucketHeightEvent = { + type: 'bucket-height'; + bucket: AssetBucket; + delta: number; +}; +type DateGroupIntersecting = { + type: 'intersecting'; + bucket: AssetBucket; + dateGroup: DateGroup; +}; +type DateGroupHeightEvent = { + type: 'height'; + bucket: AssetBucket; + dateGroup: DateGroup; + delta: number; + height: number; +}; export class AssetStore { - private store$ = writable(this); private assetToBucket: Record = {}; private pendingChanges: PendingChange[] = []; private unsubscribers: Unsubscriber[] = []; private options: AssetApiGetTimeBucketsRequest; + private viewport: Viewport = { + height: 0, + width: 0, + }; + private initializedSignal!: () => void; + private store$ = writable(this); + lastScrollTime: number = 0; + subscribe = this.store$.subscribe; + /** + * A promise that resolves once the store is initialized. + */ + taskManager = new AssetGridTaskManager(this); + complete!: Promise; initialized = false; timelineHeight = 0; buckets: AssetBucket[] = []; assets: AssetResponseDto[] = []; albumAssets: Set = new Set(); + pendingScrollBucket: AssetBucket | undefined; + pendingScrollAssetId: string | undefined; + + listeners: BucketListener[] = []; constructor( options: AssetStoreOptions, private albumId?: string, ) { this.options = { ...options, size: TimeBucketSize.Month }; + // create a promise, and store its resolve callbacks. The initializedSignal callback + // will be invoked when a the assetstore is initialized. + this.complete = new Promise((resolve) => { + this.initializedSignal = resolve; + }); this.store$.set(this); } - subscribe = this.store$.subscribe; - private addPendingChanges(...changes: PendingChange[]) { // prevent websocket events from happening before local client events setTimeout(() => { @@ -182,8 +332,35 @@ export class AssetStore { this.emit(true); }, 2500); - async init(viewport: Viewport) { - this.initialized = false; + addListener(bucketListener: BucketListener) { + this.listeners.push(bucketListener); + } + removeListener(bucketListener: BucketListener) { + this.listeners = this.listeners.filter((l) => l != bucketListener); + } + private notifyListeners( + event: + | ViewPortEvent + | BucketLoadEvent + | BucketLoadedEvent + | BucketCancelEvent + | BucketHeightEvent + | DateGroupIntersecting + | DateGroupHeightEvent, + ) { + for (const fn of this.listeners) { + fn(event); + } + } + async init({ bucketListener }: { bucketListener?: BucketListener } = {}) { + if (this.initialized) { + throw 'Can only init once'; + } + if (bucketListener) { + this.addListener(bucketListener); + } + // uncaught rejection go away + this.complete.catch(() => void 0); this.timelineHeight = 0; this.buckets = []; this.assets = []; @@ -194,65 +371,118 @@ export class AssetStore { ...this.options, key: getKey(), }); - + this.buckets = timebuckets.map( + (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }), + ); + this.initializedSignal(); this.initialized = true; - - this.buckets = timebuckets.map((bucket) => ({ - bucketDate: bucket.timeBucket, - bucketHeight: 0, - bucketCount: bucket.count, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - })); - - // if loading an asset, the grid-view may be hidden, which means - // it has 0 width and height. No need to update bucket or timeline - // heights in this case. Later, updateViewport will be called to - // update the heights. - if (viewport.height !== 0 && viewport.width !== 0) { - await this.updateViewport(viewport); - } } - async updateViewport(viewport: Viewport) { + public destroy() { + this.taskManager.destroy(); + this.listeners = []; + this.initialized = false; + } + + async updateViewport(viewport: Viewport, force?: boolean) { + if (!this.initialized) { + return; + } + if (viewport.height === 0 && viewport.width === 0) { + return; + } + + if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) { + return; + } + + // changing width invalidates the actual height, and needs to be remeasured, since width changes causes + // layout reflows. + const changedWidth = this.viewport.width != viewport.width; + this.viewport = { ...viewport }; + for (const bucket of this.buckets) { - const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); - const rows = Math.ceil(unwrappedWidth / viewport.width); - const height = rows * THUMBNAIL_HEIGHT; - bucket.bucketHeight = height; + this.updateGeometry(bucket, changedWidth); } this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); - let height = 0; const loaders = []; + let height = 0; for (const bucket of this.buckets) { - if (height < viewport.height) { - height += bucket.bucketHeight; - loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible)); - continue; + if (height >= viewport.height) { + break; } - break; + height += bucket.bucketHeight; + loaders.push(this.loadBucket(bucket.bucketDate)); } await Promise.all(loaders); + this.notifyListeners({ type: 'viewport' }); this.emit(false); } - async loadBucket(bucketDate: string, position: BucketPosition): Promise { + private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { + if (invalidateHeight) { + bucket.isBucketHeightActual = false; + bucket.measured = false; + for (const assetGroup of bucket.dateGroups) { + assetGroup.heightActual = false; + } + } + if (!bucket.isBucketHeightActual) { + const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = 51 + rows * THUMBNAIL_HEIGHT; + bucket.bucketHeight = height; + } + + for (const assetGroup of bucket.dateGroups) { + if (!assetGroup.heightActual) { + const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10); + const rows = Math.ceil(unwrappedWidth / this.viewport.width); + const height = rows * THUMBNAIL_HEIGHT; + assetGroup.height = height; + } + + const layoutResult = createJustifiedLayout( + assetGroup.assets.map((g) => getAssetRatio(g)), + { + ...LAYOUT_OPTIONS, + containerWidth: Math.floor(this.viewport.width), + }, + ); + assetGroup.geometry = { + ...layoutResult, + containerWidth: calculateWidth(layoutResult.boxes), + }; + } + } + + async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { return; } - - bucket.position = position; - - if (bucket.cancelToken || bucket.assets.length > 0) { - this.emit(false); + if (bucket.bucketCount === bucket.assets.length) { + // already loaded return; } - bucket.cancelToken = new AbortController(); + if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) { + // if promise is pending, and preventCancel is requested, then don't overwrite it + if (!bucket.isPreventCancel && options.preventCancel) { + bucket.isPreventCancel = options.preventCancel; + } + await bucket.complete; + return; + } + if (options.pending) { + this.pendingScrollBucket = bucket; + } + this.notifyListeners({ type: 'load', bucket }); + bucket.isPreventCancel = !!options.preventCancel; + + const cancelToken = (bucket.cancelToken = new AbortController()); try { const assets = await getTimeBucket( { @@ -260,9 +490,14 @@ export class AssetStore { timeBucket: bucketDate, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } + if (this.albumId) { const albumAssets = await getTimeBucket( { @@ -271,50 +506,87 @@ export class AssetStore { size: this.options.size, key: getKey(), }, - { signal: bucket.cancelToken.signal }, + { signal: cancelToken.signal }, ); - + if (cancelToken.signal.aborted) { + this.notifyListeners({ type: 'cancel', bucket }); + return; + } for (const asset of albumAssets) { this.albumAssets.add(asset.id); } } - if (bucket.cancelToken.signal.aborted) { + bucket.assets = assets; + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); + bucket.loaded(); + this.notifyListeners({ type: 'loaded', bucket }); + } catch (error) { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + if ((error as any).name === 'AbortError') { return; } - - bucket.assets = assets; - - this.emit(true); - } catch (error) { const $t = get(t); handleError(error, $t('errors.failed_to_load_assets')); + bucket.errored(); } finally { - bucket.cancelToken = null; + bucket.cancelToken = undefined; + this.emit(true); } } - cancelBucket(bucket: AssetBucket) { - bucket.cancelToken?.abort(); - } - - updateBucket(bucketDate: string, height: number) { + updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { - return 0; + return {}; + } + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + delta = height - bucket.bucketHeight; + bucket.isBucketHeightActual = true; + bucket.bucketHeight = height; + this.timelineHeight += delta; + this.notifyListeners({ type: 'bucket-height', bucket, delta }); + } + if ('intersecting' in properties) { + bucket.intersecting = properties.intersecting!; + } + if ('measured' in properties) { + if (properties.measured) { + bucket.measuredSignal?.(); + } + bucket.measured = properties.measured!; } - - const delta = height - bucket.bucketHeight; - const scrollTimeline = bucket.position == BucketPosition.Above; - - bucket.bucketHeight = height; - bucket.position = BucketPosition.Unknown; - - this.timelineHeight += delta; - this.emit(false); + return { delta }; + } - return scrollTimeline ? delta : 0; + updateBucketDateGroup( + bucket: AssetBucket, + dateGroup: DateGroup, + properties: { height?: number; intersecting?: boolean }, + ) { + let delta = 0; + if ('height' in properties) { + const height = properties.height!; + if (height > 0) { + delta = height - dateGroup.height; + dateGroup.heightActual = true; + dateGroup.height = height; + this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height }); + } + } + if ('intersecting' in properties) { + dateGroup.intersecting = properties.intersecting!; + if (dateGroup.intersecting) { + this.notifyListeners({ type: 'intersecting', bucket, dateGroup }); + } + } + this.emit(false); + return { delta }; } addAssets(assets: AssetResponseDto[]) { @@ -354,15 +626,7 @@ export class AssetStore { let bucket = this.getBucketByDate(timeBucket); if (!bucket) { - bucket = { - bucketDate: timeBucket, - bucketHeight: THUMBNAIL_HEIGHT, - bucketCount: 0, - assets: [], - cancelToken: null, - position: BucketPosition.Unknown, - }; - + bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT }); this.buckets.push(bucket); } @@ -383,6 +647,8 @@ export class AssetStore { const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); return bDate.diff(aDate).milliseconds; }); + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); } this.emit(true); @@ -392,18 +658,73 @@ export class AssetStore { return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; } - async getBucketInfoForAssetId({ id, localDateTime }: Pick) { + async findAndLoadBucketAsPending(id: string) { const bucketInfo = this.assetToBucket[id]; if (bucketInfo) { - return bucketInfo; + const bucket = bucketInfo.bucket; + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = id; + this.emit(false); + return bucket; } + const asset = await getAssetInfo({ id }); + if (asset) { + if (this.options.isArchived !== asset.isArchived) { + return; + } + const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true }); + if (bucket) { + this.pendingScrollBucket = bucket; + this.pendingScrollAssetId = asset.id; + this.emit(false); + } + return bucket; + } + } + + /* Must be paired with matching clearPendingScroll() call */ + async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) { + try { + const { at: assetId } = scrollTarget; + if (assetId) { + await this.complete; + const bucket = await this.findAndLoadBucketAsPending(assetId); + if (bucket) { + return; + } + } + } catch { + // failure + } + onFailure(); + } + + clearPendingScroll() { + this.pendingScrollBucket = undefined; + this.pendingScrollAssetId = undefined; + } + + private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) { let date = fromLocalDateTime(localDateTime); if (this.options.size == TimeBucketSize.Month) { date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); } else if (this.options.size == TimeBucketSize.Day) { date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); } - await this.loadBucket(date.toISO()!, BucketPosition.Unknown); + const iso = date.toISO()!; + await this.loadBucket(iso, options); + return this.getBucketByDate(iso); + } + + private async getBucketInfoForAsset( + { id, localDateTime }: Pick, + options: { preventCancel?: boolean; pending?: boolean } = {}, + ) { + const bucketInfo = this.assetToBucket[id]; + if (bucketInfo) { + return bucketInfo; + } + await this.loadBucketAtTime(localDateTime, options); return this.assetToBucket[id] || null; } @@ -417,7 +738,7 @@ export class AssetStore { ); for (const bucket of this.buckets) { if (index < bucket.bucketCount) { - await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(bucket.bucketDate); return bucket.assets[index] || null; } @@ -458,6 +779,7 @@ export class AssetStore { // Iterate in reverse to allow array splicing. for (let index = this.buckets.length - 1; index >= 0; index--) { const bucket = this.buckets[index]; + let changed = false; for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) { const asset = bucket.assets[index_]; if (!idSet.has(asset.id)) { @@ -465,17 +787,22 @@ export class AssetStore { } bucket.assets.splice(index_, 1); + changed = true; if (bucket.assets.length === 0) { this.buckets.splice(index, 1); } } + if (changed) { + bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); + this.updateGeometry(bucket, true); + } } this.emit(true); } async getPreviousAsset(asset: AssetResponseDto): Promise { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -491,12 +818,12 @@ export class AssetStore { } const previousBucket = this.buckets[bucketIndex - 1]; - await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(previousBucket.bucketDate); return previousBucket.assets.at(-1) || null; } async getNextAsset(asset: AssetResponseDto): Promise { - const info = await this.getBucketInfoForAssetId(asset); + const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } @@ -512,7 +839,7 @@ export class AssetStore { } const nextBucket = this.buckets[bucketIndex + 1]; - await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown); + await this.loadBucket(nextBucket.bucketDate); return nextBucket.assets[0] || null; } @@ -537,8 +864,7 @@ export class AssetStore { } this.assetToBucket = assetToBucket; } - - this.store$.update(() => this); + this.store$.set(this); } } diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts new file mode 100644 index 0000000000..6ece1327c4 --- /dev/null +++ b/web/src/lib/utils/asset-store-task-manager.ts @@ -0,0 +1,465 @@ +import type { AssetBucket, AssetStore } from '$lib/stores/assets.store'; +import { generateId } from '$lib/utils/generate-id'; +import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support'; +import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue'; +import { type DateGroup } from '$lib/utils/timeline-util'; +import { TUNABLES } from '$lib/utils/tunables'; +import { type AssetResponseDto } from '@immich/sdk'; +import { clamp } from 'lodash-es'; + +type Task = () => void; + +class InternalTaskManager { + assetStore: AssetStore; + componentTasks = new Map>(); + priorityQueue = new KeyedPriorityQueue(); + idleQueue = new Map(); + taskCleaners = new Map(); + + queueTimer: ReturnType | 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(); + 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 = new Map(); + + queueScrollSensitiveTask({ + task, + cleanup, + componentId, + priority = 10, + taskId = generateId(), + }: { + task: Task; + cleanup?: Task; + componentId: string; + priority?: number; + taskId?: string; + }) { + return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId }); + } + + removeAllTasksForComponent(componentId: string) { + return this.internalManager.removeAllTasksForComponent(componentId); + } + + destroy() { + return this.internalManager.destroy(); + } + + private getOrCreateBucketTask(bucket: AssetBucket) { + let bucketTask = this.tasks.get(bucket); + if (!bucketTask) { + bucketTask = this.createBucketTask(bucket); + } + return bucketTask; + } + + private createBucketTask(bucket: AssetBucket) { + const bucketTask = new BucketTask(this.internalManager, this, bucket); + this.tasks.set(bucket, bucketTask); + return bucketTask; + } + + intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleIntersected(componentId, task); + } + + seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(bucket); + bucketTask.scheduleSeparated(componentId, seperated); + } + + intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.intersectedDateGroup(componentId, dateGroup, intersected); + } + + seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + bucketTask.separatedDateGroup(componentId, dateGroup, seperated); + } + + intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.intersectedThumbnail(componentId, asset, intersected); + } + + seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) { + const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); + const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); + dateGroupTask.separatedThumbnail(componentId, asset, seperated); + } +} + +class IntersectionTask { + internalTaskManager: InternalTaskManager; + seperatedKey; + intersectedKey; + priority; + + intersected: Task | undefined; + separated: Task | undefined; + + constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) { + this.internalTaskManager = internalTaskManager; + this.seperatedKey = keyPrefix + ':s:' + key; + this.intersectedKey = keyPrefix + ':i:' + key; + this.priority = priority; + } + + trackIntersectedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.separated) { + return; + } + task?.(); + }; + this.intersected = execTask; + const cleanup = () => { + this.intersected = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey); + }; + return { task: execTask, cleanup }; + } + + trackSeperatedTask(componentId: string, task: Task) { + const execTask = () => { + if (this.intersected) { + return; + } + task?.(); + }; + this.separated = execTask; + const cleanup = () => { + this.separated = undefined; + this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey); + }; + return { task: execTask, cleanup }; + } + + removePendingSeparated() { + if (this.separated) { + this.internalTaskManager.removeSeparateTask(this.seperatedKey); + } + } + removePendingIntersected() { + if (this.intersected) { + this.internalTaskManager.removeIntersectedTask(this.intersectedKey); + } + } + + scheduleIntersected(componentId: string, intersected: Task) { + this.removePendingSeparated(); + if (this.intersected) { + return; + } + const { task, cleanup } = this.trackIntersectedTask(componentId, intersected); + this.internalTaskManager.queueScrollSensitiveTask({ + task, + cleanup, + componentId: componentId, + priority: this.priority, + taskId: this.intersectedKey, + }); + } + + scheduleSeparated(componentId: string, separated: Task) { + this.removePendingIntersected(); + + if (this.separated) { + return; + } + + const { task, cleanup } = this.trackSeperatedTask(componentId, separated); + this.internalTaskManager.queueSeparateTask({ + task, + cleanup, + componentId: componentId, + taskId: this.seperatedKey, + }); + } +} +class BucketTask extends IntersectionTask { + assetBucket: AssetBucket; + assetGridTaskManager: AssetGridTaskManager; + // indexed by dateGroup's date + dateTasks: Map = 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 = new Map(); + + constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) { + super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY); + this.dateGroup = dateGroup; + this.bucketTask = parent; + } + + removePendingSeparated() { + super.removePendingSeparated(); + for (const thumbnailTask of this.thumbnailTasks.values()) { + thumbnailTask.removePendingSeparated(); + } + } + + getOrCreateThumbnailTask(asset: AssetResponseDto) { + let thumbnailTask = this.thumbnailTasks.get(asset); + if (!thumbnailTask) { + thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset); + this.thumbnailTasks.set(asset, thumbnailTask); + } + return thumbnailTask; + } + + intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleIntersected(componentId, intersected); + } + + separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) { + const thumbnailTask = this.getOrCreateThumbnailTask(asset); + thumbnailTask.scheduleSeparated(componentId, seperated); + } +} +class ThumbnailTask extends IntersectionTask { + asset: AssetResponseDto; + dateGroupTask: DateGroupTask; + + constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) { + super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY); + this.asset = asset; + this.dateGroupTask = parent; + } +} diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 2722745317..576b14b201 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -4,7 +4,7 @@ import { NotificationType, notificationController } from '$lib/components/shared import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; -import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; +import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; @@ -403,7 +403,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt try { for (const bucket of assetStore.buckets) { - await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + await assetStore.loadBucket(bucket.bucketDate); if (!get(isSelectingAllAssets)) { break; // Cancelled diff --git a/web/src/lib/utils/idle-callback-support.ts b/web/src/lib/utils/idle-callback-support.ts new file mode 100644 index 0000000000..0f7f060084 --- /dev/null +++ b/web/src/lib/utils/idle-callback-support.ts @@ -0,0 +1,20 @@ +interface RequestIdleCallback { + didTimeout?: boolean; + timeRemaining?(): DOMHighResTimeStamp; +} +interface RequestIdleCallbackOptions { + timeout?: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) { + const start = Date.now(); + return setTimeout(cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }), 100); +} + +function fake_cancelIdleCallback(id: number) { + return clearTimeout(id); +} + +export const idleCB = window.requestIdleCallback || fake_requestIdleCallback; +export const cancelIdleCB = window.cancelIdleCallback || fake_cancelIdleCallback; diff --git a/web/src/lib/utils/keyed-priority-queue.ts b/web/src/lib/utils/keyed-priority-queue.ts new file mode 100644 index 0000000000..2483b22c6d --- /dev/null +++ b/web/src/lib/utils/keyed-priority-queue.ts @@ -0,0 +1,50 @@ +export class KeyedPriorityQueue { + private items: { key: K; value: T; priority: number }[] = []; + private set: Set = new Set(); + + clear() { + this.items = []; + this.set.clear(); + } + + remove(key: K) { + const removed = this.set.delete(key); + if (removed) { + const idx = this.items.findIndex((i) => i.key === key); + if (idx >= 0) { + this.items.splice(idx, 1); + } + } + return removed; + } + + push(key: K, value: T, priority: number) { + if (this.set.has(key)) { + return this.length; + } + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.set.add(key); + this.items.splice(i, 0, { key, value, priority }); + return this.length; + } + } + this.set.add(key); + return this.items.push({ key, value, priority }); + } + + shift() { + let item = this.items.shift(); + while (item) { + if (this.set.has(item.key)) { + this.set.delete(item.key); + return item; + } + item = this.items.shift(); + } + } + + get length() { + return this.set.size; + } +} diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 4d5660f173..304376b347 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk'; import type { NavigationTarget } from '@sveltejs/kit'; import { get } from 'svelte/store'; +export type AssetGridRouteSearchParams = { + at: string | null | undefined; +}; export const isExternalUrl = (url: string): boolean => { return new URL(url, window.location.href).origin !== window.location.origin; }; @@ -33,17 +36,38 @@ function currentUrlWithoutAsset() { export function currentUrlReplaceAssetId(assetId: string) { const $page = get(page); + const params = new URLSearchParams($page.url.search); + // always remove the assetGridScrollTargetParams + params.delete('at'); + const searchparams = params.size > 0 ? '?' + params.toString() : ''; // this contains special casing for the /photos/:assetId photos route, which hangs directly // off / instead of a subpath, unlike every other asset-containing route. return isPhotosRoute($page.route.id) - ? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}` - : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`; + ? `${AppRoute.PHOTOS}/${assetId}${searchparams}` + : `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`; +} + +function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) { + const $page = get(page); + const parsed = new URL(url, $page.url); + + const { at: assetId } = searchParams || { at: null }; + + if (!assetId) { + return parsed.pathname; + } + + const params = new URLSearchParams($page.url.search); + if (assetId) { + params.set('at', assetId); + } + return parsed.pathname + '?' + params.toString(); } function currentUrl() { const $page = get(page); const current = $page.url; - return current.pathname + current.search; + return current.pathname + current.search + current.hash; } interface Route { @@ -55,24 +79,58 @@ interface Route { interface AssetRoute extends Route { targetRoute: 'current'; - assetId: string | null; + assetId: string | null | undefined; } +interface AssetGridRoute extends Route { + targetRoute: 'current'; + assetId: string | null | undefined; + assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined; +} + +type ImmichRoute = AssetRoute | AssetGridRoute; + +type NavOptions = { + /* navigate even if url is the same */ + forceNavigate?: boolean | undefined; + replaceState?: boolean | undefined; + noScroll?: boolean | undefined; + keepFocus?: boolean | undefined; + invalidateAll?: boolean | undefined; + state?: App.PageState | undefined; +}; function isAssetRoute(route: Route): route is AssetRoute { return route.targetRoute === 'current' && 'assetId' in route; } -async function navigateAssetRoute(route: AssetRoute) { +function isAssetGridRoute(route: Route): route is AssetGridRoute { + return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route; +} + +async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) { const { assetId } = route; const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); - if (next !== currentUrl()) { - await goto(next, { replaceState: false }); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); } } -export function navigate(change: T): Promise { - if (isAssetRoute(change)) { - return navigateAssetRoute(change); +async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) { + const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route; + const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset(); + const next = replaceScrollTarget(assetUrl, assetGridScrollTarget); + const current = currentUrl(); + if (next !== current || options?.forceNavigate) { + await goto(next, options); + } +} + +export function navigate(change: ImmichRoute, options?: NavOptions): Promise { + if (isAssetGridRoute(change)) { + return navigateAssetGridRoute(change, options); + } else if (isAssetRoute(change)) { + return navigateAssetRoute(change, options); } // future navigation requests here throw `Invalid navigation: ${JSON.stringify(change)}`; diff --git a/web/src/lib/utils/priority-queue.ts b/web/src/lib/utils/priority-queue.ts new file mode 100644 index 0000000000..6b08ffe7ad --- /dev/null +++ b/web/src/lib/utils/priority-queue.ts @@ -0,0 +1,21 @@ +export class PriorityQueue { + private items: { value: T; priority: number }[] = []; + + push(value: T, priority: number) { + for (let i = 0; i < this.items.length; i++) { + if (this.items[i].priority > priority) { + this.items.splice(i, 0, { value, priority }); + return this.length; + } + } + return this.items.push({ value, priority }); + } + + shift() { + return this.items.shift(); + } + + get length() { + return this.items.length; + } +} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 76a0d1b5cb..3a8f66ee08 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,9 +1,38 @@ +import type { AssetBucket } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; import type { AssetResponseDto } from '@immich/sdk'; -import { groupBy, sortBy } from 'lodash-es'; +import type createJustifiedLayout from 'justified-layout'; +import { groupBy, memoize, sortBy } from 'lodash-es'; import { DateTime } from 'luxon'; import { get } from 'svelte/store'; +export type DateGroup = { + date: DateTime; + groupTitle: string; + assets: AssetResponseDto[]; + height: number; + heightActual: boolean; + intersecting: boolean; + geometry: Geometry; + bucket: AssetBucket; +}; +export type ScrubberListener = ( + bucketDate: string | undefined, + overallScrollPercent: number, + bucketScrollPercent: number, +) => void | Promise; +export type ScrollTargetListener = ({ + bucket, + dateGroup, + asset, + offset, +}: { + bucket: AssetBucket; + dateGroup: DateGroup; + asset: AssetResponseDto; + offset: number; +}) => void; + export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); @@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string { return date.toLocaleString(groupDateFormat); } -export function splitBucketIntoDateGroups( - assets: AssetResponseDto[], - locale: string | undefined, -): AssetResponseDto[][] { - const grouped = groupBy(assets, (asset) => +type Geometry = ReturnType & { + containerWidth: number; +}; + +function emptyGeometry() { + return { + containerWidth: 0, + containerHeight: 0, + widowCount: 0, + boxes: [], + }; +} + +const formatDateGroupTitle = memoize(formatGroupTitle); + +export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] { + const grouped = groupBy(bucket.assets, (asset) => fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }), ); - return sortBy(grouped, (group) => assets.indexOf(group[0])); + const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0])); + return sorted.map((group) => { + const date = fromLocalDateTime(group[0].localDateTime).startOf('day'); + return { + date, + groupTitle: formatDateGroupTitle(date), + assets: group, + height: 0, + heightActual: false, + intersecting: false, + geometry: emptyGeometry(), + bucket: bucket, + }; + }); } export type LayoutBox = { + aspectRatio: number; top: number; - left: number; width: number; + height: number; + left: number; + forcedAspectRatio?: boolean; }; export function calculateWidth(boxes: LayoutBox[]): number { @@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number { width = box.left + box.width; } } - return width; } + +export function findTotalOffset(element: HTMLElement, stop: HTMLElement) { + let offset = 0; + while (element.offsetParent && element !== stop) { + offset += element.offsetTop; + element = element.offsetParent as HTMLElement; + } + return offset; +} diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts new file mode 100644 index 0000000000..e21c30de77 --- /dev/null +++ b/web/src/lib/utils/tunables.ts @@ -0,0 +1,63 @@ +function getBoolean(string: string | null, fallback: boolean) { + if (string === null) { + return fallback; + } + return 'true' === string; +} +function getNumber(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseInt(string); +} +function getFloat(string: string | null, fallback: number) { + if (string === null) { + return fallback; + } + return Number.parseFloat(string); +} +export const TUNABLES = { + SCROLL_TASK_QUEUE: { + TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25), + TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5), + TRICKLE_ACCELERATED_MIN_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'), + 8, + ), + TRICKLE_ACCELERATED_MAX_DELAY: getNumber( + localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'), + 2000, + ), + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15), + DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16), + MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200), + CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16), + }, + INTERSECTION_OBSERVER_QUEUE: { + DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15), + THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16), + THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true), + }, + ASSET_GRID: { + NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), + }, + BUCKET: { + PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2), + INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%', + }, + DATEGROUP: { + PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4), + INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false), + INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%', + }, + THUMBNAIL: { + PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8), + INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%', + INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%', + }, + IMAGE_THUMBNAIL: { + THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150), + }, +}; diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index 23f38b86f4..bf24d0e7e4 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -1,10 +1,10 @@ diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9e670f714c..ff5709df99 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -43,7 +43,13 @@ import { downloadAlbum } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; - import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation'; + import { + isAlbumsRoute, + isPeopleRoute, + isSearchRoute, + navigate, + type AssetGridRouteSearchParams, + } from '$lib/utils/navigation'; import { AlbumUserRole, AssetOrder, @@ -78,12 +84,15 @@ import type { PageData } from './$types'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; - let { isViewing: showAssetViewer, setAsset } = assetViewingStore; + let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; let { slideshowState, slideshowNavigation } = slideshowStore; + let oldAt: AssetGridRouteSearchParams | null | undefined; + $: album = data.album; $: albumId = album.id; $: albumKey = `${albumId}_${albumOrder}`; @@ -244,7 +253,7 @@ } if (viewMode === ViewMode.SELECT_ASSETS) { - handleCloseSelectAssets(); + await handleCloseSelectAssets(); return; } if (viewMode === ViewMode.LINK_SHARING) { @@ -289,20 +298,37 @@ timelineInteractionStore.clearMultiselect(); viewMode = ViewMode.VIEW; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { replaceState: true, forceNavigate: true }, + ); } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); } }; - const handleCloseSelectAssets = () => { + const setModeToView = async () => { viewMode = ViewMode.VIEW; + assetStore.destroy(); + assetStore = new AssetStore({ albumId, order: albumOrder }); + timelineStore.destroy(); + timelineStore = new AssetStore({ isArchived: false }, albumId); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } }, + { replaceState: true, forceNavigate: true }, + ); + oldAt = null; + }; + + const handleCloseSelectAssets = async () => { timelineInteractionStore.clearMultiselect(); + await setModeToView(); }; const handleSelectFromComputer = async () => { await openFileUploadDialog({ albumId: album.id }); timelineInteractionStore.clearMultiselect(); - viewMode = ViewMode.VIEW; + await setModeToView(); }; const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => { @@ -400,6 +426,11 @@ await deleteAlbum(album); } }); + + onDestroy(() => { + assetStore.destroy(); + timelineStore.destroy(); + });
@@ -444,7 +475,14 @@ {#if isEditor} (viewMode = ViewMode.SELECT_ASSETS)} + on:click={async () => { + viewMode = ViewMode.SELECT_ASSETS; + oldAt = { at: $gridScrollTarget?.at }; + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } }, + { replaceState: true }, + ); + }} icon={mdiImagePlusOutline} /> {/if} @@ -530,12 +568,14 @@ {#key albumKey} {#if viewMode === ViewMode.SELECT_ASSETS} {:else} asset.isFavorite); + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -45,7 +50,7 @@ {/if} - + diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49af165ac9..13e70c9161 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,6 +19,7 @@ import type { PageData } from './$types'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -27,6 +28,10 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; $: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived); + + onDestroy(() => { + assetStore.destroy(); + }); @@ -50,7 +55,7 @@ {/if} - + diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3eb65ca1bd..0ea0ed18bb 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -124,7 +124,10 @@ showNavigation={viewingAssets.length > 1} on:next={navigateNext} on:previous={navigatePrevious} - on:close={() => assetViewingStore.showAssetViewer(false)} + on:close={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} isShared={false} /> {/await} diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83e2ba3c1f..b580c4faa5 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -23,6 +23,7 @@ onDestroy(() => { assetInteractionStore.clearMultiselect(); + assetStore.destroy(); }); @@ -45,5 +46,5 @@ {/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 02afe7f610..26e803deb6 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -52,7 +52,7 @@ mdiEyeOutline, mdiPlus, } from '@mdi/js'; - import { onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import type { PageData } from './$types'; import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; @@ -155,6 +155,7 @@ } if (previousPersonId !== data.person.id) { handlePromiseError(updateAssetCount()); + assetStore.destroy(); assetStore = new AssetStore({ isArchived: false, personId: data.person.id, @@ -344,6 +345,10 @@ await goto($page.url); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if viewMode === ViewMode.UNASSIGN_ASSETS} @@ -442,6 +447,7 @@
{#key refreshAssetGrid} { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -84,6 +89,7 @@ diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5ebb0e294c..f4fac282ba 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,8 +11,13 @@ import type { PageData } from './$types'; import { setSharedLink } from '$lib/utils'; import { t } from 'svelte-i18n'; + import { navigate } from '$lib/utils/navigation'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { tick } from 'svelte'; export let data: PageData; + + let { gridScrollTarget } = assetViewingStore; let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = data; let { title, description } = meta; let isOwned = $user ? $user.id === sharedLink?.userId : false; @@ -29,6 +34,11 @@ description = sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } }); + await tick(); + await navigate( + { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, + { forceNavigate: true, replaceState: true }, + ); } catch (error) { handleError(error, $t('errors.unable_to_get_shared_link')); } diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2907a542b3..27ad5bb3f0 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -25,6 +25,7 @@ import { handlePromiseError } from '$lib/utils'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; + import { onDestroy } from 'svelte'; export let data: PageData; @@ -84,6 +85,10 @@ handleError(error, $t('errors.unable_to_restore_trash')); } }; + + onDestroy(() => { + assetStore.destroy(); + }); {#if $isMultiSelectState} @@ -111,7 +116,7 @@ - +

{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}

diff --git a/web/static/dark_skeleton.png b/web/static/dark_skeleton.png new file mode 100644 index 0000000000..2a115a8496 Binary files /dev/null and b/web/static/dark_skeleton.png differ diff --git a/web/static/light_skeleton.png b/web/static/light_skeleton.png new file mode 100644 index 0000000000..22c7eae754 Binary files /dev/null and b/web/static/light_skeleton.png differ