feat(web): Scroll to asset in gridview; increase gridview perf; reduce memory; scrollbar ticks in fixed position (#10646)

* Squashed

* Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation

* Reduce jank on scroll, delay DOM updates until after scroll

* css opt, log measure time

* Trickle out queue while scrolling, flush when stopped

* yay

* Cleanup cleanup...

* everybody...

* everywhere...

* Clean up cleanup!

* Everybody do their share

* CLEANUP!

* package-lock ?

* dynamic measure, todo

* Fix web test

* type lint

* fix e2e

* e2e test

* Better scrollbar

* Tuning, and more tunables

* Tunable tweaks, more tunables

* Scrollbar dots and viewport events

* lint

* Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes

* New tunables, and don't update url by default

* Bug fixes

* Bug fix, with debug

* Fix flickr, fix graybox bug, reduced debug

* Refactor/cleanup

* Fix

* naming

* Final cleanup

* review comment

* Forgot to update this after naming change

* scrubber works, with debug

* cleanup

* Rename scrollbar to scrubber

* rename  to

* left over rename and change to previous album bar

* bugfix addassets, comments

* missing destroy(), cleanup

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2024-08-21 22:15:21 -04:00 committed by GitHub
parent 07538299cf
commit 837b1e4929
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 2947 additions and 843 deletions

View File

@ -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();

View File

@ -1,4 +1,7 @@
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
if (!textarea) {
return;
}
textarea.style.height = height;
textarea.style.height = `${textarea.scrollHeight}px`;
};

View File

@ -0,0 +1,152 @@
type Config = IntersectionObserverActionProperties & {
observer?: IntersectionObserver;
};
type TrackedProperties = {
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
};
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
type OnSeperateCallback = (element: HTMLElement) => unknown;
type IntersectionObserverActionProperties = {
key?: string;
onSeparate?: OnSeperateCallback;
onIntersect?: OnIntersectCallback;
root?: Element | Document | null;
threshold?: number | number[];
top?: string;
right?: string;
bottom?: string;
left?: string;
disabled?: boolean;
};
type TaskKey = HTMLElement | string;
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
return (
a?.bottom === b?.bottom &&
a?.top === b?.top &&
a?.left === b?.left &&
a?.right == b?.right &&
a?.threshold === b?.threshold &&
a?.root === b?.root
);
}
const elementToConfig = new Map<TaskKey, Config>();
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
if (!target.isConnected) {
elementToConfig.get(key)?.observer?.unobserve(target);
return;
}
const {
root,
threshold,
top = '0px',
right = '0px',
bottom = '0px',
left = '0px',
onSeparate,
onIntersect,
} = properties;
const rootMargin = `${top} ${right} ${bottom} ${left}`;
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
// This IntersectionObserver is limited to observing a single element, the one the
// action is attached to. If there are multiple entries, it means that this
// observer is being notified of multiple events that have occured quickly together,
// and the latest element is the one we are interested in.
entries.sort((a, b) => a.time - b.time);
const latestEntry = entries.pop();
if (latestEntry?.isIntersecting) {
onIntersect?.(latestEntry);
} else {
onSeparate?.(target);
}
},
{
rootMargin,
threshold,
root,
},
);
observer.observe(target);
elementToConfig.set(key, { ...properties, observer });
};
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
elementToConfig.set(key, properties);
observe(key, element, properties);
}
function _intersectionObserver(
key: HTMLElement | string,
element: HTMLElement,
properties: IntersectionObserverActionProperties,
) {
if (properties.disabled) {
properties.onIntersect?.(element);
} else {
configure(key, element, properties);
}
return {
update(properties: IntersectionObserverActionProperties) {
const config = elementToConfig.get(key);
if (!config) {
return;
}
if (isEquivalent(config, properties)) {
return;
}
configure(key, element, properties);
},
destroy: () => {
if (properties.disabled) {
properties.onSeparate?.(element);
} else {
const config = elementToConfig.get(key);
const { observer, onSeparate } = config || {};
observer?.unobserve(element);
elementToConfig.delete(key);
if (onSeparate) {
onSeparate?.(element);
}
}
},
};
}
export function intersectionObserver(
element: HTMLElement,
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
) {
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
// so accept an array when multiple configurations are needed.
if (Array.isArray(properties)) {
if (!properties.every((p) => p.key)) {
throw new Error('Multiple configurations must specify key');
}
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
return {
update: (properties: IntersectionObserverActionProperties[]) => {
for (const [i, props] of properties.entries()) {
observers[i].update(props);
}
},
destroy: () => {
for (const observer of observers) {
observer.destroy();
}
},
};
}
return _intersectionObserver(element, element, properties);
}

View File

@ -0,0 +1,43 @@
type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
let observer: ResizeObserver;
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
/**
* Installs a resizeObserver on the given element - when the element changes
* size, invokes a callback function with the width/height. Intended as a
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
* an iframe to measure the size of the element, which can be bad for
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
* bind:clientWidth to use an internal resize observer.
*
* TODO: When svelte5 is ready, go back to bind:clientWidth and
* bind:clientHeight.
*/
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
if (!observer) {
callbacks = new WeakMap();
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const onResize = callbacks.get(entry.target as HTMLElement);
if (onResize) {
onResize({
target: entry.target as HTMLElement,
width: entry.borderBoxSize[0].inlineSize,
height: entry.borderBoxSize[0].blockSize,
});
}
}
});
}
callbacks.set(element, onResize);
observer.observe(element);
return {
destroy: () => {
callbacks.delete(element);
observer.unobserve(element);
},
};
}

View File

@ -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);
}
}

View File

@ -19,6 +19,7 @@
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let sharedLink: SharedLinkResponseDto;
export let user: UserResponseDto | undefined = undefined;
@ -38,6 +39,9 @@
dragAndDropFilesStore.set({ isDragging: false, files: [] });
}
});
onDestroy(() => {
assetStore.destroy();
});
</script>
<svelte:window
@ -94,7 +98,7 @@
</header>
<main class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
<AssetGrid {album} {assetStore} {assetInteractionStore}>
<AssetGrid enableRouting={true} {album} {assetStore} {assetInteractionStore}>
<section class="pt-8 md:pt-24">
<!-- ALBUM TITLE -->
<h1

View File

@ -15,7 +15,6 @@
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import {
AssetJobName,
@ -70,7 +69,8 @@
} = slideshowStore;
const dispatch = createEventDispatcher<{
close: void;
action: { type: AssetAction; asset: AssetResponseDto };
close: { asset: AssetResponseDto };
next: void;
previous: void;
}>();
@ -201,7 +201,6 @@
websocketEvents.on('on_asset_update', onAssetUpdate),
);
await navigate({ targetRoute: 'current', assetId: asset.id });
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@ -268,9 +267,8 @@
$isShowDetail = !$isShowDetail;
};
const closeViewer = async () => {
dispatch('close');
await navigate({ targetRoute: 'current', assetId: null });
const closeViewer = () => {
dispatch('close', { asset });
};
const closeEditor = () => {
@ -378,9 +376,7 @@
}
};
const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => {
const { isMouseOver } = e.detail;
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
@ -392,8 +388,7 @@
}
case AssetAction.UNSTACK: {
await closeViewer();
break;
closeViewer();
}
}
@ -585,12 +580,11 @@
? 'bg-transparent border-2 border-white'
: 'bg-gray-700/40'} inline-block hover:bg-transparent"
asset={stackedAsset}
onClick={(stackedAsset, event) => {
event.preventDefault();
onClick={(stackedAsset) => {
asset = stackedAsset;
preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
}}
on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly
thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
showStackedIcon={false}

View File

@ -212,7 +212,6 @@
title={person.name}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>

View File

@ -1,82 +0,0 @@
<script lang="ts">
import { BucketPosition } from '$lib/stores/assets.store';
import { createEventDispatcher, onMount } from 'svelte';
export let once = false;
export let top = 0;
export let bottom = 0;
export let left = 0;
export let right = 0;
export let root: HTMLElement | null = null;
export let intersecting = false;
let container: HTMLDivElement;
const dispatch = createEventDispatcher<{
hidden: HTMLDivElement;
intersected: {
container: HTMLDivElement;
position: BucketPosition;
};
}>();
onMount(() => {
if (typeof IntersectionObserver !== 'undefined') {
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
const observer = new IntersectionObserver(
(entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
if (!intersecting) {
dispatch('hidden', container);
}
if (intersecting && once) {
observer.unobserve(container);
}
if (intersecting) {
let position: BucketPosition = BucketPosition.Visible;
if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
position = BucketPosition.Below;
} else if (entries[0].boundingClientRect.bottom < 0) {
position = BucketPosition.Above;
}
dispatch('intersected', {
container,
position,
});
}
},
{
rootMargin,
root,
},
);
observer.observe(container);
return () => observer.unobserve(container);
}
// The following is a fallback for older browsers
function handler() {
const bcr = container.getBoundingClientRect();
intersecting =
bcr.bottom + bottom > 0 &&
bcr.right + right > 0 &&
bcr.top - top < window.innerHeight &&
bcr.left - left < window.innerWidth;
if (intersecting && once) {
window.removeEventListener('scroll', handler);
}
}
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
});
</script>
<div bind:this={container}>
<slot {intersecting} />
</div>

View File

@ -12,7 +12,7 @@
import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard';
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
@ -33,6 +33,7 @@
let imageLoaded: boolean = false;
let imageError: boolean = false;
let forceUseOriginal: boolean = false;
let loader: HTMLImageElement;
$: isWebCompatible = isWebCompatibleImage(asset);
$: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
@ -108,6 +109,25 @@
event.preventDefault();
handlePromiseError(copyImage());
};
onMount(() => {
const onload = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
};
const onerror = () => {
imageError = imageLoaded = true;
};
if (loader.complete) {
onload();
}
loader.addEventListener('load', onload);
loader.addEventListener('error', onerror);
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
};
});
</script>
<svelte:window
@ -119,6 +139,8 @@
{#if imageError}
<div class="h-full flex items-center justify-center">{$t('error_loading_image')}</div>
{/if}
<!-- svelte-ignore a11y-missing-attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<div bind:this={element} class="relative h-full select-none">
<img
style="display:none"
@ -128,7 +150,7 @@
on:error={() => (imageError = imageLoaded = true)}
/>
{#if !imageLoaded}
<div class="flex h-full items-center justify-center">
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
@ -159,3 +181,15 @@
</div>
{/if}
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

View File

@ -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();
});
});

View File

@ -1,17 +1,19 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { decodeBase64 } from '$lib/utils';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { thumbHashToDataURL } from 'thumbhash';
import { mdiEyeOffOutline } from '@mdi/js';
import { thumbhash } from '$lib/actions/thumbhash';
import Icon from '$lib/components/elements/icon.svelte';
import { TUNABLES } from '$lib/utils/tunables';
import { mdiEyeOffOutline, mdiImageBrokenVariant } from '@mdi/js';
export let url: string;
export let altText: string | undefined;
export let title: string | null = null;
export let heightStyle: string | undefined = undefined;
export let widthStyle: string;
export let thumbhash: string | null = null;
export let base64ThumbHash: string | null = null;
export let curve = false;
export let shadow = false;
export let circle = false;
@ -19,37 +21,58 @@
export let border = false;
export let preload = true;
export let hiddenIconClass = 'text-white';
export let onComplete: (() => void) | undefined = undefined;
let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
let loaded = false;
let errored = false;
let complete = false;
let img: HTMLImageElement;
onMount(async () => {
await img.decode();
await tick();
complete = true;
const setLoaded = () => {
loaded = true;
onComplete?.();
};
const setErrored = () => {
errored = true;
onComplete?.();
};
onMount(() => {
if (img.complete) {
setLoaded();
}
});
</script>
<img
{#if errored}
<div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
<Icon path={mdiImageBrokenVariant} size="48" />
</div>
{:else}
<img
bind:this={img}
on:load={setLoaded}
on:error={setErrored}
loading={preload ? 'eager' : 'lazy'}
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={altText}
alt={loaded || errored ? altText : ''}
{title}
class="object-cover transition duration-300 {border
? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
: ''}"
class="object-cover {border ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' : ''}"
class:rounded-xl={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:aspect-square={circle || !heightStyle}
class:opacity-0={!thumbhash && !complete}
class:opacity-0={!thumbhash && !loaded}
draggable="false"
/>
/>
{/if}
{#if hidden}
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
@ -57,18 +80,18 @@
</div>
{/if}
{#if thumbhash && !complete}
<img
{#if base64ThumbHash && (!loaded || errored)}
<canvas
use:thumbhash={{ base64ThumbHash }}
data-testid="thumbhash"
style:width={widthStyle}
style:height={heightStyle}
src={thumbHashToDataURL(decodeBase64(thumbhash))}
alt={altText}
{title}
class="absolute top-0 object-cover"
class:rounded-xl={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
draggable="false"
out:fade={{ duration: 300 }}
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
/>
{/if}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
@ -18,18 +18,23 @@
mdiMotionPlayOutline,
mdiRotate360,
} from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { AssetStore } from '$lib/stores/assets.store';
const dispatch = createEventDispatcher<{
select: { asset: AssetResponseDto };
'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number };
}>();
import type { DateGroup } from '$lib/utils/timeline-util';
import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte';
import { TUNABLES } from '$lib/utils/tunables';
import { thumbhash } from '$lib/actions/thumbhash';
export let asset: AssetResponseDto;
export let dateGroup: DateGroup | undefined = undefined;
export let assetStore: AssetStore | undefined = undefined;
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let thumbnailWidth: number | undefined = undefined;
@ -40,72 +45,181 @@
export let readonly = false;
export let showArchiveIcon = false;
export let showStackedIcon = true;
export let onClick: ((asset: AssetResponseDto, event: Event) => void) | undefined = undefined;
export let intersectionConfig: {
root?: HTMLElement;
bottom?: string;
top?: string;
left?: string;
priority?: number;
disabled?: boolean;
} = {};
export let retrieveElement: boolean = false;
export let onIntersected: (() => void) | undefined = undefined;
export let onClick: ((asset: AssetResponseDto) => void) | undefined = undefined;
export let onRetrieveElement: ((elment: HTMLElement) => void) | undefined = undefined;
export let onSelect: ((asset: AssetResponseDto) => void) | undefined = undefined;
export let onMouseEvent: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined =
undefined;
let className = '';
export { className as class };
let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
const componentId = generateId();
let element: HTMLElement | undefined;
let mouseOver = false;
let intersecting = false;
let lastRetrievedElement: HTMLElement | undefined;
let loaded = false;
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
$: [width, height] = ((): [number, number] => {
if (thumbnailSize) {
return [thumbnailSize, thumbnailSize];
$: if (!retrieveElement) {
lastRetrievedElement = undefined;
}
$: if (retrieveElement && element && lastRetrievedElement !== element) {
lastRetrievedElement = element;
onRetrieveElement?.(element);
}
if (thumbnailWidth && thumbnailHeight) {
return [thumbnailWidth, thumbnailHeight];
}
$: width = thumbnailSize || thumbnailWidth || 235;
$: height = thumbnailSize || thumbnailHeight || 235;
$: display = intersecting;
return [235, 235];
})();
const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
if (!disabled) {
dispatch('select', { asset });
onSelect?.(asset);
}
};
const callClickHandlers = () => {
if (selected) {
onIconClickedHandler();
return;
}
onClick?.(asset);
};
const handleClick = (e: MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
return;
}
e.stopPropagation();
e.preventDefault();
callClickHandlers();
};
if (selected) {
onIconClickedHandler(e);
return;
}
onClick?.(asset, e);
const _onMouseEnter = () => {
mouseOver = true;
onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex });
};
const onMouseEnter = () => {
mouseOver = true;
if (dateGroup && assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() });
} else {
_onMouseEnter();
}
};
const onMouseLeave = () => {
if (dateGroup && assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) });
} else {
mouseOver = false;
}
};
const _onIntersect = () => {
intersecting = true;
onIntersected?.();
};
const onIntersect = () => {
if (intersecting === true) {
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect());
} else {
void _onIntersect();
}
};
const onSeparate = () => {
if (intersecting === false) {
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
} else {
intersecting = false;
}
};
onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<IntersectionObserver once={false} on:intersected let:intersecting>
<a
href={currentUrlReplaceAssetId(asset.id)}
<div
bind:this={element}
use:intersectionObserver={{
...intersectionConfig,
onIntersect,
onSeparate,
}}
data-asset={asset.id}
data-int={intersecting}
style:width="{width}px"
style:height="{height}px"
class="group focus-visible:outline-none flex overflow-hidden {disabled
? 'bg-gray-300'
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
>
{#if !loaded && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
class="absolute object-cover z-10"
style:width="{width}px"
style:height="{height}px"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{/if}
{#if display}
<!-- svelte queries for all links on afterNavigate, leading to performance problems in asset-grid which updates
the navigation url on scroll. Replace this with button for now. -->
<div
class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled}
on:mouseenter={onMouseEnter}
on:mouseleave={onMouseLeave}
on:keypress={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
}}
tabindex={0}
on:click={handleClick}
role="link"
>
{#if intersecting}
{#if mouseOver}
<!-- lazy show the url on mouse over-->
<a
class="absolute z-30 {className} top-[41px]"
style:cursor="unset"
style:width="{width}px"
style:height="{height}px"
href={currentUrlReplaceAssetId(asset.id)}
on:click={(evt) => evt.preventDefault()}
tabindex={0}
>
</a>
{/if}
<div class="absolute z-20 {className}" style:width="{width}px" style:height="{height}px">
<!-- Select asset button -->
{#if !readonly && (mouseOver || selected || selectionCandidate)}
@ -189,11 +303,11 @@
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
thumbhash={asset.thumbhash}
curve={selected}
onComplete={() => (loaded = true)}
/>
{:else}
<div class="flex h-full w-full items-center justify-center p-4">
<div class="absolute flex h-full w-full items-center justify-center p-4 z-10">
<Icon path={mdiImageBrokenVariant} size="48" />
</div>
{/if}
@ -201,6 +315,7 @@
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
@ -213,6 +328,7 @@
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
@ -230,6 +346,6 @@
out:fade={{ duration: 100 }}
/>
{/if}
</div>
{/if}
</a>
</IntersectionObserver>
</div>

View File

@ -3,7 +3,11 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AssetStore } from '$lib/stores/assets.store';
import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte';
export let assetStore: AssetStore | undefined = undefined;
export let url: string;
export let durationInSeconds = 0;
export let enablePlayback = false;
@ -13,6 +17,7 @@
export let playIcon = mdiPlayCircleOutline;
export let pauseIcon = mdiPauseCircleOutline;
const componentId = generateId();
let remainingSeconds = durationInSeconds;
let loading = true;
let error = false;
@ -27,6 +32,43 @@
player.src = '';
}
}
const onMouseEnter = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) {
enablePlayback = true;
}
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = true;
}
}
};
const onMouseLeave = () => {
if (assetStore) {
assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
if (playbackOnIconHover) {
enablePlayback = false;
}
},
});
} else {
if (playbackOnIconHover) {
enablePlayback = false;
}
}
};
onDestroy(() => {
assetStore?.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
@ -37,19 +79,7 @@
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="pr-2 pt-2"
on:mouseenter={() => {
if (playbackOnIconHover) {
enablePlayback = true;
}
}}
on:mouseleave={() => {
if (playbackOnIconHover) {
enablePlayback = false;
}
}}
>
<span class="pr-2 pt-2" on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
{#if enablePlayback}
{#if loading}
<LoadingSpinner />

View File

@ -113,7 +113,6 @@
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>

View File

@ -265,8 +265,6 @@
title={$t('face_unassigned')}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={false}
/>
{:then data}
<ImageThumbnail
@ -277,8 +275,6 @@
title={$t('face_unassigned')}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={false}
/>
{/await}
{/if}

View File

@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { shortcuts } from '$lib/actions/shortcut';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
@ -38,6 +38,8 @@
import { tweened } from 'svelte/motion';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import { resizeObserver } from '$lib/actions/resize-observer';
import { locale } from '$lib/stores/preferences.store';
const parseIndex = (s: string | null, max: number | null) =>
@ -383,21 +385,18 @@
/>
</div>
<IntersectionObserver
once={false}
on:intersected={() => (galleryInView = true)}
on:hidden={() => (galleryInView = false)}
bottom={-200}
>
<div
id="gallery-memory"
use:intersectionObserver={{
onIntersect: () => (galleryInView = true),
onSeparate: () => (galleryInView = false),
bottom: '-200px',
}}
use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}
bind:this={memoryGallery}
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
>
<GalleryViewer assets={currentMemory.assets} {viewport} bind:selectedAssets />
</div>
</IntersectionObserver>
</section>
{/if}
</section>

View File

@ -1,84 +1,69 @@
<script lang="ts">
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { AssetStore, Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import {
calculateWidth,
formatGroupTitle,
fromLocalDateTime,
splitBucketIntoDateGroups,
} from '$lib/utils/timeline-util';
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store';
import { navigate } from '$lib/utils/navigation';
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import justifiedLayout from 'justified-layout';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { TUNABLES } from '$lib/utils/tunables';
import { generateId } from '$lib/utils/generate-id';
export let assets: AssetResponseDto[];
export let bucketDate: string;
export let bucketHeight: number;
export let element: HTMLElement | undefined = undefined;
export let isSelectionMode = false;
export let viewport: Viewport;
export let singleSelect = false;
export let withStacked = false;
export let showArchiveIcon = false;
export let assetGridElement: HTMLElement | undefined = undefined;
export let renderThumbsAtBottomMargin: string | undefined = undefined;
export let renderThumbsAtTopMargin: string | undefined = undefined;
export let assetStore: AssetStore;
export let bucket: AssetBucket;
export let assetInteractionStore: AssetInteractionStore;
export let onScrollTarget: ScrollTargetListener | undefined = undefined;
export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
const componentId = generateId();
$: bucketDate = bucket.bucketDate;
$: dateGroups = bucket.dateGroups;
const {
DATEGROUP: { INTERSECTION_DISABLED, INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
} = TUNABLES;
/* TODO figure out a way to calculate this*/
const TITLE_HEIGHT = 51;
const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore;
const dispatch = createEventDispatcher<{
select: { title: string; assets: AssetResponseDto[] };
selectAssets: AssetResponseDto;
selectAssetCandidates: AssetResponseDto | null;
shift: { heightDelta: number };
}>();
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup = '';
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
if (isSelectionMode || $isMultiSelectState) {
assetSelectHandler(asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
$: geometry = (() => {
const geometry = [];
for (let group of assetsGroupByDate) {
const justifiedLayoutResult = justifiedLayout(
group.map((assetGroup) => getAssetRatio(assetGroup)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
geometry.push({
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
});
}
return geometry;
})();
$: {
if (actualBucketHeight && actualBucketHeight !== 0 && actualBucketHeight != bucketHeight) {
const heightDelta = assetStore.updateBucket(bucketDate, actualBucketHeight);
if (heightDelta !== 0) {
scrollTimeline(heightDelta);
}
}
}
function scrollTimeline(heightDelta: number) {
dispatch('shift', {
heightDelta,
});
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
if (assetGridElement && onScrollTarget) {
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
onScrollTarget({ bucket, dateGroup, asset, offset });
}
};
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets });
@ -104,39 +89,79 @@
dispatch('selectAssetCandidates', asset);
}
};
onDestroy(() => {
$assetStore.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
{#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
{@const asset = groupAssets[0]}
{@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))}
<!-- Asset Group By Date -->
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
{@const display =
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)}
<div
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 !display}
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
{/if}
{#if display}
<!-- Asset Group By Date -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-col"
on:mouseenter={() => {
on:mouseenter={() =>
$assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = true;
assetMouseEventHandler(groupTitle, null);
}}
assetMouseEventHandler(dateGroup.groupTitle, null);
},
})}
on:mouseleave={() => {
$assetStore.taskManager.queueScrollSensitiveTask({
componentId,
task: () => {
isMouseOverGroup = false;
assetMouseEventHandler(groupTitle, null);
assetMouseEventHandler(dateGroup.groupTitle, null);
},
});
}}
>
<!-- Date group title -->
<div
class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
style="width: {geometry[groupIndex].containerWidth}px"
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 !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))}
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroup.groupTitle))}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block px-2 hover:cursor-pointer"
on:click={() => handleSelectGroup(groupTitle, groupAssets)}
on:keydown={() => handleSelectGroup(groupTitle, groupAssets)}
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
>
{#if $selectedGroup.has(groupTitle)}
{#if $selectedGroup.has(dateGroup.groupTitle)}
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
{:else}
<Icon path={mdiCircleOutline} size="24" color="#757575" />
@ -144,38 +169,52 @@
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={groupTitle}>
{groupTitle}
<span class="w-full truncate first-letter:capitalize" title={dateGroup.groupTitle}>
{dateGroup.groupTitle}
</span>
</div>
<!-- Image grid -->
<div
class="relative"
style="height: {geometry[groupIndex].containerHeight}px;width: {geometry[groupIndex].containerWidth}px"
class="relative overflow-clip"
style:height={dateGroup.geometry.containerHeight + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
>
{#each groupAssets as asset, index (asset.id)}
{@const box = geometry[groupIndex].boxes[index]}
{#each dateGroup.assets as asset, index (asset.id)}
{@const box = dateGroup.geometry.boxes[index]}
<!-- update ASSET_GRID_PADDING-->
<div
use:intersectionObserver={{
onIntersect: () => onAssetInGrid?.(asset),
top: `-${TITLE_HEIGHT}px`,
bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`,
right: `-${viewport.width - 1}px`,
root: assetGridElement,
}}
data-asset-id={asset.id}
class="absolute"
style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
style:width={box.width + 'px'}
style:height={box.height + 'px'}
style:top={box.top + 'px'}
style:left={box.left + 'px'}
>
<Thumbnail
{dateGroup}
{assetStore}
intersectionConfig={{
root: assetGridElement,
bottom: renderThumbsAtBottomMargin,
top: renderThumbsAtTopMargin,
}}
retrieveElement={$assetStore.pendingScrollAssetId === asset.id}
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset, event) => {
if (isSelectionMode || $isMultiSelectState) {
event.preventDefault();
assetSelectHandler(asset, groupAssets, groupTitle);
return;
}
assetViewingStore.setAsset(asset);
}}
on:select={() => assetSelectHandler(asset, groupAssets, groupTitle)}
on:mouse-event={() => assetMouseEventHandler(groupTitle, asset)}
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)}
@ -186,11 +225,13 @@
{/each}
</div>
</div>
{/if}
</div>
{/each}
</section>
<style>
#asset-group-by-date {
contain: layout;
contain: layout paint style;
}
</style>

View File

@ -1,11 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { BucketPosition, isSelectingAllAssets, type AssetStore, type Viewport } from '$lib/stores/assets.store';
import {
AssetBucket,
AssetStore,
isSelectingAllAssets,
type BucketListener,
type ViewportXY,
} from '$lib/stores/assets.store';
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
@ -13,19 +19,38 @@
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import {
formatGroupTitle,
splitBucketIntoDateGroups,
type ScrubberListener,
type ScrollTargetListener,
} from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { throttle } from 'lodash-es';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver } from '$lib/actions/resize-observer';
import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import { generateId } from '$lib/utils/generate-id';
export let isSelectionMode = false;
export let singleSelect = false;
/** `true` if this asset grid is responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */
export let enableRouting: boolean;
export let assetStore: AssetStore;
export let assetInteractionStore: AssetInteractionStore;
export let removeAction:
@ -40,17 +65,32 @@
export let album: AlbumResponseDto | null = null;
export let isShowDeleteConfirmation = false;
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
assetInteractionStore;
const viewport: Viewport = { width: 0, height: 0 };
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets } = assetViewingStore;
const viewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
const safeViewport: ViewportXY = { width: 0, height: 0, x: 0, y: 0 };
const componentId = generateId();
let element: HTMLElement;
let timelineElement: HTMLElement;
let showShortcuts = false;
let showSkeleton = true;
let internalScroll = false;
let navigating = false;
let preMeasure: AssetBucket[] = [];
let lastIntersectedBucketDate: string | undefined;
let scrubBucketPercent = 0;
let scrubBucket: { bucketDate: string | undefined } | undefined;
let scrubOverallPercent: number = 0;
let topSectionHeight = 0;
let topSectionOffset = 0;
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
let leadout = false;
$: timelineY = element?.scrollTop || 0;
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
$: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
$: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
$: isAllArchived = [...$selectedAssets].every((asset) => asset.isArchived);
@ -59,30 +99,329 @@
assetInteractionStore.clearMultiselect();
}
}
$: {
void assetStore.updateViewport(viewport);
if (element && isViewportOrigin()) {
const rect = element.getBoundingClientRect();
viewport.height = rect.height;
viewport.width = rect.width;
viewport.x = rect.x;
viewport.y = rect.y;
}
if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
safeViewport.height = viewport.height;
safeViewport.width = viewport.width;
safeViewport.x = viewport.x;
safeViewport.y = viewport.y;
updateViewport();
}
}
const {
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
BUCKET: {
INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
},
THUMBNAIL: {
INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP,
INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM,
},
} = TUNABLES;
const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
onMount(async () => {
showSkeleton = false;
assetStore.connect();
await assetStore.init(viewport);
});
const isViewportOrigin = () => {
return viewport.height === 0 && viewport.width === 0;
};
onDestroy(() => {
if ($showAssetViewer) {
$showAssetViewer = false;
const isEqual = (a: ViewportXY, b: ViewportXY) => {
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
};
const completeNav = () => {
navigating = false;
if (internalScroll) {
internalScroll = false;
return;
}
assetStore.disconnect();
if ($gridScrollTarget?.at) {
void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
element.scrollTo({ top: 0 });
showSkeleton = false;
});
} else {
element.scrollTo({ top: 0 });
showSkeleton = false;
}
};
afterNavigate((nav) => {
const { complete, type } = nav;
if (type === 'enter') {
return;
}
complete.then(completeNav, completeNav);
});
beforeNavigate(() => {
navigating = true;
});
const hmrSupport = () => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
// preventing skeleton from showing after hmr
if (import.meta && import.meta.hot) {
const afterApdate = (payload: UpdatePayload) => {
const assetGridUpdate = payload.updates.some(
(update) => update.path.endsWith('asset-grid.svelte') || update.path.endsWith('assets-store.ts'),
);
if (assetGridUpdate) {
setTimeout(() => {
void $assetStore.updateViewport(safeViewport, true);
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
void navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} else {
element.scrollTo({ top: 0 });
showSkeleton = false;
}
}, 500);
}
};
import.meta.hot?.on('vite:afterUpdate', afterApdate);
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
if (assetGridUpdate) {
assetStore.destroy();
}
});
return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
}
return () => void 0;
};
const _updateLastIntersectedBucketDate = () => {
let elem = document.elementFromPoint(safeViewport.x + 1, safeViewport.y + 1);
while (elem != null) {
if (elem.id === 'bucket') {
break;
}
elem = elem.parentElement;
}
if (elem) {
lastIntersectedBucketDate = (elem as HTMLElement).dataset.bucketDate;
}
};
const updateLastIntersectedBucketDate = throttle(_updateLastIntersectedBucketDate, 16, {
leading: false,
trailing: true,
});
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
if (!lastIntersectedBucketDate) {
_updateLastIntersectedBucketDate();
}
if (lastIntersectedBucketDate) {
const currentIndex = $assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
const deltaIndex = $assetStore.buckets.indexOf(adjustedBucket);
if (deltaIndex < currentIndex) {
element?.scrollBy(0, delta);
}
}
};
const bucketListener: BucketListener = (event) => {
const { type } = event;
if (type === 'bucket-height') {
const { bucket, delta } = event;
scrollTolastIntersectedBucket(bucket, delta);
}
};
onMount(() => {
void $assetStore
.init({ bucketListener })
.then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport)));
if (!enableRouting) {
showSkeleton = false;
}
const dispose = hmrSupport();
return () => {
$assetStore.disconnect();
$assetStore.destroy();
dispose();
};
});
function getOffset(bucketDate: string) {
let offset = 0;
for (let a = 0; a < assetStore.buckets.length; a++) {
if (assetStore.buckets[a].bucketDate === bucketDate) {
break;
}
offset += assetStore.buckets[a].bucketHeight;
}
return offset;
}
const _updateViewport = () => void $assetStore.updateViewport(safeViewport);
const updateViewport = throttle(_updateViewport, 16);
const getMaxScrollPercent = () =>
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
const getMaxScroll = () =>
topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
const maxScrollPercent = getMaxScrollPercent();
const delta = bucket.bucketHeight * bucketScrollPercent;
const scrollTop = (topOffset + delta) * maxScrollPercent;
element.scrollTop = scrollTop;
};
const _onScrub: ScrubberListener = (
bucketDate: string | undefined,
scrollPercent: number,
bucketScrollPercent: number,
) => {
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll();
const offset = maxScroll * scrollPercent;
element.scrollTop = offset;
} else {
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
if (!bucket) {
return;
}
scrollToBucketAndOffset(bucket, bucketScrollPercent);
}
};
const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true });
const stopScrub: ScrubberListener = async (
bucketDate: string | undefined,
_scrollPercent: number,
bucketScrollPercent: number,
) => {
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
return;
}
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
if (!bucket) {
return;
}
if (bucket && !bucket.measured) {
preMeasure.push(bucket);
if (!bucket.loaded) {
await assetStore.loadBucket(bucket.bucketDate);
}
// Wait here, and collect the deltas that are above offset, which affect offset position
await bucket.measuredPromise;
scrollToBucketAndOffset(bucket, bucketScrollPercent);
}
};
const _handleTimelineScroll = () => {
leadout = false;
if ($assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
scrubBucket = undefined;
scrubBucketPercent = 0;
} else {
let top = element?.scrollTop;
if (top < topSectionHeight) {
// in the lead-in area
scrubBucket = undefined;
scrubBucketPercent = 0;
const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
return;
}
let maxScrollPercent = getMaxScrollPercent();
let found = false;
// create virtual buckets....
const vbuckets = [
{ bucketHeight: topSectionHeight, bucketDate: undefined },
...assetStore.buckets,
{ bucketHeight: bottomSectionHeight, bucketDate: undefined },
];
for (const bucket of vbuckets) {
let next = top - bucket.bucketHeight * maxScrollPercent;
if (next < 0) {
scrubBucket = bucket;
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
found = true;
break;
}
top = next;
}
if (!found) {
leadout = true;
scrubBucket = undefined;
scrubBucketPercent = 0;
scrubOverallPercent = 1;
}
}
};
const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true });
const _onAssetInGrid = async (asset: AssetResponseDto) => {
if (!enableRouting || navigating || internalScroll) {
return;
}
$gridScrollTarget = { at: asset.id };
internalScroll = true;
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
};
const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW
? throttle(_onAssetInGrid, 16, { leading: false, trailing: true })
: () => void 0;
const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
element.scrollTo({ top: offset });
if (!bucket.measured) {
preMeasure.push(bucket);
}
showSkeleton = false;
$assetStore.clearPendingScroll();
// set intersecting true manually here, to reduce flicker that happens when
// clearing pending scroll, but the intersection observer hadn't yet had time to run
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
};
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => $assetStore.removeAssets(assetIds),
idsSelectedAssets,
);
assetInteractionStore.clearMultiselect();
};
@ -107,7 +446,7 @@
const onStackAssets = async () => {
const ids = await stackAssets(Array.from($selectedAssets));
if (ids) {
assetStore.removeAssets(ids);
$assetStore.removeAssets(ids);
dispatch('escape');
}
};
@ -115,7 +454,7 @@
const toggleArchive = async () => {
const ids = await archiveAssets(Array.from($selectedAssets), !isAllArchived);
if (ids) {
assetStore.removeAssets(ids);
$assetStore.removeAssets(ids);
deselectAllAssets();
}
};
@ -135,7 +474,7 @@
{ shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
];
@ -154,29 +493,33 @@
})();
const handleSelectAsset = (asset: AssetResponseDto) => {
if (!assetStore.albumAssets.has(asset.id)) {
if (!$assetStore.albumAssets.has(asset.id)) {
assetInteractionStore.selectAsset(asset);
}
};
async function intersectedHandler(event: CustomEvent) {
const element_ = event.detail.container as HTMLElement;
const target = element_.firstChild as HTMLElement;
if (target) {
const bucketDate = target.id.split('_')[1];
await assetStore.loadBucket(bucketDate, event.detail.position);
}
function intersectedHandler(bucket: AssetBucket) {
updateLastIntersectedBucketDate();
const intersectedTask = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void $assetStore.loadBucket(bucket.bucketDate);
};
$assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
}
function handleScrollTimeline(event: CustomEvent) {
element.scrollBy(0, event.detail.heightDelta);
function seperatedHandler(bucket: AssetBucket) {
const seperatedTask = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel();
};
$assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
}
const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
const previousAsset = await $assetStore.getPreviousAsset($viewingAsset);
if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
const preloadAsset = await $assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
}
@ -185,10 +528,10 @@
};
const handleNext = async () => {
const nextAsset = await assetStore.getNextAsset($viewingAsset);
const nextAsset = await $assetStore.getNextAsset($viewingAsset);
if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset);
const preloadAsset = await $assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
}
@ -196,7 +539,12 @@
return !!nextAsset;
};
const handleClose = () => assetViewingStore.showAssetViewer(false);
const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handleAction = async (action: Action) => {
switch (action.type) {
@ -206,7 +554,7 @@
case AssetAction.DELETE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || handleClose();
(await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } }));
// delete after find the next one
assetStore.removeAssets([action.asset.id]);
@ -232,20 +580,6 @@
}
};
let animationTick = false;
const handleTimelineScroll = () => {
if (animationTick) {
return;
}
animationTick = true;
window.requestAnimationFrame(() => {
timelineY = element?.scrollTop || 0;
animationTick = false;
});
};
let lastAssetMouseEvent: AssetResponseDto | null = null;
$: if (!lastAssetMouseEvent) {
@ -355,7 +689,7 @@
// Select/deselect assets in all intermediate buckets
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
const bucket = $assetStore.buckets[bucketIndex];
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
await $assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.assets) {
if (deselect) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
@ -370,11 +704,10 @@
const bucket = $assetStore.buckets[bucketIndex];
// Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
for (const dateGroup of assetsGroupByDate) {
const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day'));
if (dateGroup.every((a) => $selectedAssets.has(a))) {
const dateGroupTitle = formatGroupTitle(dateGroup.date);
if (dateGroup.assets.every((a) => $selectedAssets.has(a))) {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
@ -411,6 +744,9 @@
e.preventDefault();
}
};
onDestroy(() => {
assetStore.taskManager.removeAllTasksForComponent(componentId);
});
</script>
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
@ -427,78 +763,97 @@
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
{/if}
<Scrollbar
<Scrubber
invisible={showSkeleton}
{assetStore}
height={viewport.height}
{timelineY}
on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
height={safeViewport.height}
timelineTopOffset={topSectionHeight}
timelineBottomOffset={bottomSectionHeight}
{leadout}
{scrubOverallPercent}
{scrubBucketPercent}
{scrubBucket}
{onScrub}
{stopScrub}
/>
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section
id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty
? 'm-0'
: 'ml-4 tall:ml-0 md:mr-[60px]'}"
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
tabindex="-1"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
bind:this={element}
on:scroll={handleTimelineScroll}
on:scroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
>
<!-- skeleton -->
{#if showSkeleton}
<div class="mt-8 animate-pulse">
<div class="mb-2 h-4 w-24 rounded-full bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
<div class="flex w-[120%] flex-wrap">
{#each Array.from({ length: 100 }) as _}
<div class="m-[1px] h-[10em] w-[16em] bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
{/each}
</div>
</div>
{/if}
{#if element}
<section
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
class:invisible={showSkeleton}
>
<slot />
<!-- (optional) empty placeholder -->
{#if isEmpty}
<!-- (optional) empty placeholder -->
<slot name="empty" />
{/if}
<section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
<IntersectionObserver
on:intersected={intersectedHandler}
on:hidden={() => assetStore.cancelBucket(bucket)}
let:intersecting
top={750}
bottom={750}
root={element}
</section>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton}
style:height={$assetStore.timelineHeight + 'px'}
>
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
{#if intersecting}
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
{@const isPremeasure = preMeasure.includes(bucket)}
{@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure}
<div
id="bucket"
use:intersectionObserver={{
onIntersect: () => intersectedHandler(bucket),
onSeparate: () => seperatedHandler(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,
}}
data-bucket-display={bucket.intersecting}
data-bucket-date={bucket.bucketDate}
style:height={bucket.bucketHeight + 'px'}
>
{#if display && !bucket.measured}
<MeasureDateGroup
{bucket}
{assetStore}
onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))}
></MeasureDateGroup>
{/if}
{#if !display || !bucket.measured}
<Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} />
{/if}
{#if display && bucket.measured}
<AssetDateGroup
assetGridElement={element}
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
{withStacked}
{showArchiveIcon}
{assetStore}
{assetInteractionStore}
{isSelectionMode}
{singleSelect}
{onScrollTarget}
{onAssetInGrid}
{bucket}
viewport={safeViewport}
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
on:shift={handleScrollTimeline}
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
{viewport}
/>
{/if}
</div>
</IntersectionObserver>
{/each}
<div class="h-[60px]"></div>
</section>
{/if}
</section>
<Portal target="body">
@ -522,7 +877,7 @@
<style>
#asset-grid {
contain: layout;
contain: strict;
scrollbar-width: none;
}
</style>

View File

@ -0,0 +1,89 @@
<script lang="ts" context="module">
const recentTimes: number[] = [];
// TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function adjustTunables(avg: number) {}
function addMeasure(time: number) {
recentTimes.push(time);
if (recentTimes.length > 10) {
recentTimes.shift();
}
const sum = recentTimes.reduce((acc: number, val: number) => {
return acc + val;
}, 0);
const avg = sum / recentTimes.length;
adjustTunables(avg);
}
</script>
<script lang="ts">
import { resizeObserver } from '$lib/actions/resize-observer';
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store';
export let assetStore: AssetStore;
export let bucket: AssetBucket;
export let onMeasured: () => void;
async function _measure(element: Element) {
try {
await bucket.complete;
const t1 = Date.now();
let heightPending = bucket.dateGroups.some((group) => !group.heightActual);
if (heightPending) {
const listener: BucketListener = (event) => {
const { type } = event;
if (type === 'height') {
const { bucket: changedBucket } = event;
if (changedBucket === bucket && type === 'height') {
heightPending = bucket.dateGroups.some((group) => !group.heightActual);
if (!heightPending) {
const height = element.getBoundingClientRect().height;
if (height !== 0) {
$assetStore.updateBucket(bucket.bucketDate, { height: height, measured: true });
}
onMeasured();
$assetStore.removeListener(listener);
const t2 = Date.now();
addMeasure((t2 - t1) / bucket.bucketCount);
}
}
}
};
assetStore.addListener(listener);
}
} catch {
// ignore if complete rejects (canceled load)
}
}
function measure(element: Element) {
void _measure(element);
}
</script>
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
{#each bucket.dateGroups as dateGroup}
<div id="date-group" data-date-group={dateGroup.date}>
<div
use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: height })}
>
<div
class="flex z-[100] sticky top-[-1px] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
style:width={dateGroup.geometry.containerWidth + 'px'}
>
<span class="w-full truncate first-letter:capitalize">
{dateGroup.groupTitle}
</span>
</div>
<div
class="relative overflow-clip"
style:height={dateGroup.geometry.containerHeight + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'}
style:visibility={'hidden'}
></div>
</div>
</div>
{/each}
</section>

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { resizeObserver } from '$lib/actions/resize-observer';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { memoryStore } from '$lib/stores/memory.store';
@ -38,7 +39,7 @@
id="memory-lane"
bind:this={memoryLaneElement}
class="relative mt-5 overflow-x-hidden whitespace-nowrap transition-all"
bind:offsetWidth
use:resizeObserver={({ width }) => (offsetWidth = width)}
on:scroll={onScroll}
>
{#if canScrollLeft || canScrollRight}
@ -67,7 +68,7 @@
{/if}
</div>
{/if}
<div class="inline-block" bind:offsetWidth={innerWidth}>
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each $memoryStore as memory, index (memory.yearsAgo)}
{#if memory.assets.length > 0}
<a

View File

@ -0,0 +1,35 @@
<script lang="ts">
export let title: string | null = null;
export let height: string | null = null;
</script>
<div class="overflow-clip" style={`height: ${height}`}>
{#if title}
<div
class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
>
<span class="w-full truncate first-letter:capitalize">{title}</span>
</div>
{/if}
<div id="skeleton" style={`height: ${height}`}></div>
</div>
<style>
#skeleton {
background-image: url('/light_skeleton.png');
background-repeat: repeat;
background-size: 235px, 235px;
}
:global(.dark) #skeleton {
background-image: url('/dark_skeleton.png');
}
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#skeleton {
visibility: hidden;
animation: 0s linear 0.1s forwards delayedVisibility;
}
</style>

View File

@ -4,25 +4,25 @@
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { BucketPosition, Viewport } from '$lib/stores/assets.store';
import type { Viewport } from '$lib/stores/assets.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { calculateWidth } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk';
import justifiedLayout from 'justified-layout';
import { createEventDispatcher, onDestroy } from 'svelte';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import Portal from '../portal/portal.svelte';
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
import { handlePromiseError } from '$lib/utils';
export let assets: AssetResponseDto[];
export let selectedAssets: Set<AssetResponseDto> = new Set();
export let disableAssetSelect = false;
export let showArchiveIcon = false;
export let viewport: Viewport;
export let onIntersected: (() => void) | undefined = undefined;
export let showAssetName = false;
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
@ -127,18 +127,15 @@
<Thumbnail
{asset}
readonly={disableAssetSelect}
onClick={async (asset, e) => {
e.preventDefault();
onClick={(asset) => {
if (isMultiSelectionMode) {
selectAssetHandler(asset);
return;
}
await viewAssetHandler(asset);
void viewAssetHandler(asset);
}}
on:select={(e) => selectAssetHandler(e.detail.asset)}
on:intersected={(event) =>
i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
onSelect={(asset) => selectAssetHandler(asset)}
onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
selected={selectedAssets.has(asset)}
{showArchiveIcon}
thumbnailWidth={geometry.boxes[i].width}
@ -159,6 +156,15 @@
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
<Portal target="body">
<AssetViewer asset={$viewingAsset} onAction={handleAction} on:previous={handlePrevious} on:next={handleNext} />
<AssetViewer
asset={$viewingAsset}
onAction={handleAction}
on:previous={handlePrevious}
on:next={handleNext}
on:close={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
</Portal>
{/if}

View File

@ -1,183 +0,0 @@
<script lang="ts">
import type { AssetStore, AssetBucket } from '$lib/stores/assets.store';
import type { DateTime } from 'luxon';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { createEventDispatcher } from 'svelte';
import { clamp } from 'lodash-es';
import { locale } from '$lib/stores/preferences.store';
export let timelineY = 0;
export let height = 0;
export let assetStore: AssetStore;
let isHover = false;
let isDragging = false;
let isAnimating = false;
let hoverLabel = '';
let hoverY = 0;
let clientY = 0;
let windowHeight = 0;
let scrollBar: HTMLElement | undefined;
const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height;
const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height);
const HOVER_DATE_HEIGHT = 30;
const MIN_YEAR_LABEL_DISTANCE = 16;
$: {
hoverY = clamp(height - windowHeight + clientY, 0, height);
if (scrollBar) {
const rect = scrollBar.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + Math.min(hoverY, height - 1);
updateLabel(x, y);
}
}
$: scrollY = toScrollY(timelineY);
class Segment {
public count = 0;
public height = 0;
public timeGroup = '';
public date!: DateTime;
public hasLabel = false;
}
const calculateSegments = (buckets: AssetBucket[]) => {
let height = 0;
let previous: Segment;
return buckets.map((bucket) => {
const segment = new Segment();
segment.count = bucket.assets.length;
segment.height = toScrollY(bucket.bucketHeight);
segment.timeGroup = bucket.bucketDate;
segment.date = fromLocalDateTime(segment.timeGroup);
if (previous?.date.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
previous.hasLabel = true;
height = 0;
}
height += segment.height;
previous = segment;
return segment;
});
};
$: segments = calculateSegments($assetStore.buckets);
const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
const updateLabel = (cursorX: number, cursorY: number) => {
const segment = document.elementsFromPoint(cursorX, cursorY).find(({ id }) => id === 'time-segment');
if (!segment) {
return;
}
const attr = (segment as HTMLElement).dataset.date;
if (!attr) {
return;
}
hoverLabel = new Date(attr).toLocaleString($locale, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
};
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
const wasDragging = isDragging;
isDragging = event.isDragging ?? isDragging;
clientY = event.clientY;
if (wasDragging === false && isDragging) {
scrollTimeline();
}
if (!isDragging || isAnimating) {
return;
}
isAnimating = true;
window.requestAnimationFrame(() => {
scrollTimeline();
isAnimating = false;
});
};
</script>
<svelte:window
bind:innerHeight={windowHeight}
on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
/>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if $assetStore.timelineHeight > height}
<div
id="immich-scrubbable-scrollbar"
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
style:width={isDragging ? '100vw' : '60px'}
style:height={height + 'px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}
draggable="false"
bind:this={scrollBar}
on:mouseenter={() => (isHover = true)}
on:mouseleave={() => (isHover = false)}
>
{#if isHover || isDragging}
<div
id="time-label"
class="pointer-events-none absolute right-0 z-[100] min-w-24 w-fit whitespace-nowrap rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
style:top="{clamp(hoverY - HOVER_DATE_HEIGHT, 0, height - HOVER_DATE_HEIGHT - 2)}px"
>
{hoverLabel}
</div>
{/if}
<!-- Scroll Position Indicator Line -->
{#if !isDragging}
<div
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY}px"
/>
{/if}
<!-- Time Segment -->
{#each segments as segment}
<div
id="time-segment"
class="relative"
data-date={segment.date}
style:height={segment.height + 'px'}
aria-label={segment.timeGroup + ' ' + segment.count}
>
{#if segment.hasLabel}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 bottom-0 z-10 pr-5 text-[12px] dark:text-immich-dark-fg font-immich-mono"
>
{segment.date.year}
</div>
{:else if segment.height > 5}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
/>
{/if}
</div>
{/each}
</div>
{/if}
<style>
#immich-scrubbable-scrollbar,
#time-segment {
contain: layout;
}
</style>

View File

@ -0,0 +1,281 @@
<script lang="ts">
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets.store';
import type { DateTime } from 'luxon';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { clamp } from 'lodash-es';
import { onMount } from 'svelte';
export let timelineTopOffset = 0;
export let timelineBottomOffset = 0;
export let height = 0;
export let assetStore: AssetStore;
export let invisible = false;
export let scrubOverallPercent: number = 0;
export let scrubBucketPercent: number = 0;
export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined;
export let leadout: boolean = false;
export let onScrub: ScrubberListener | undefined = undefined;
export let startScrub: ScrubberListener | undefined = undefined;
export let stopScrub: ScrubberListener | undefined = undefined;
let isHover = false;
let isDragging = false;
let hoverLabel: string | undefined;
let bucketDate: string | undefined;
let hoverY = 0;
let clientY = 0;
let windowHeight = 0;
let scrollBar: HTMLElement | undefined;
let segments: Segment[] = [];
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
const HOVER_DATE_HEIGHT = 31.75;
const MIN_YEAR_LABEL_DISTANCE = 16;
const MIN_DOT_DISTANCE = 8;
const toScrollFromBucketPercentage = (
scrubBucket: { bucketDate: string | undefined } | undefined,
scrubBucketPercent: number,
scrubOverallPercent: number,
) => {
if (scrubBucket) {
let offset = relativeTopOffset;
let match = false;
for (const segment of segments) {
if (segment.bucketDate === scrubBucket.bucketDate) {
offset += scrubBucketPercent * segment.height;
match = true;
break;
}
offset += segment.height;
}
if (!match) {
offset += scrubBucketPercent * relativeBottomOffset;
}
// 2px is the height of the indicator
return offset - 2;
} else if (leadout) {
let offset = relativeTopOffset;
for (const segment of segments) {
offset += segment.height;
}
offset += scrubOverallPercent * relativeBottomOffset;
return offset - 2;
} else {
// 2px is the height of the indicator
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
}
};
$: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
$: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset;
$: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight);
$: relativeBottomOffset = toScrollY(timelineBottomOffset / timelineFullHeight);
const listener: BucketListener = (event) => {
const { type } = event;
if (type === 'viewport') {
segments = calculateSegments($assetStore.buckets);
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
}
};
onMount(() => {
assetStore.addListener(listener);
return () => assetStore.removeListener(listener);
});
type Segment = {
count: number;
height: number;
dateFormatted: string;
bucketDate: string;
date: DateTime;
hasLabel: boolean;
hasDot: boolean;
};
const calculateSegments = (buckets: AssetBucket[]) => {
let height = 0;
let dotHeight = 0;
let segments: Segment[] = [];
let previousLabeledSegment: Segment | undefined;
for (const [i, bucket] of buckets.entries()) {
const scrollBarPercentage =
bucket.bucketHeight / ($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
const segment = {
count: bucket.assets.length,
height: toScrollY(scrollBarPercentage),
bucketDate: bucket.bucketDate,
date: fromLocalDateTime(bucket.bucketDate),
dateFormatted: bucket.bucketDateFormattted,
hasLabel: false,
hasDot: false,
};
if (i === 0) {
segment.hasDot = true;
segment.hasLabel = true;
previousLabeledSegment = segment;
} else {
if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
height = 0;
segment.hasLabel = true;
previousLabeledSegment = segment;
}
if (i !== 1 && segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
segment.hasDot = true;
dotHeight = 0;
}
height += segment.height;
dotHeight += segment.height;
}
segments.push(segment);
}
hoverLabel = segments[0]?.dateFormatted;
return segments;
};
const updateLabel = (segment: HTMLElement) => {
hoverLabel = segment.dataset.label;
bucketDate = segment.dataset.timeSegmentBucketDate;
};
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
const wasDragging = isDragging;
isDragging = event.isDragging ?? isDragging;
clientY = event.clientY;
if (!scrollBar) {
return;
}
const rect = scrollBar.getBoundingClientRect()!;
const lower = 0;
const upper = rect?.height - HOVER_DATE_HEIGHT * 2;
hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper);
const x = rect!.left + rect!.width / 2;
const elems = document.elementsFromPoint(x, clientY);
const segment = elems.find(({ id }) => id === 'time-segment');
let bucketPercentY = 0;
if (segment) {
updateLabel(segment as HTMLElement);
const sr = segment.getBoundingClientRect();
const sy = sr.y;
const relativeY = clientY - sy;
bucketPercentY = relativeY / sr.height;
} else {
const leadin = elems.find(({ id }) => id === 'lead-in');
if (leadin) {
updateLabel(leadin as HTMLElement);
} else {
bucketDate = undefined;
bucketPercentY = 0;
}
}
const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) {
void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
}
if (wasDragging && !isDragging) {
void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
return;
}
if (!isDragging) {
return;
}
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
};
</script>
<svelte:window
bind:innerHeight={windowHeight}
on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
/>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
id="immich-scrubbable-scrollbar"
class={`absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize`}
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
class:invisible
style:width={isDragging ? '100vw' : '60px'}
style:height={height + 'px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}
draggable="false"
bind:this={scrollBar}
on:mouseenter={() => (isHover = true)}
on:mouseleave={() => (isHover = false)}
>
{#if hoverLabel && (isHover || isDragging)}
<div
id="time-label"
class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
style:top="{hoverY + 2}px"
>
{hoverLabel}
</div>
{/if}
<!-- Scroll Position Indicator Line -->
{#if !isDragging}
<div
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
/>
{/if}
<div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}>
{#if relativeTopOffset > 6}
<div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300" />
{/if}
</div>
<!-- Time Segment -->
{#each segments as segment}
<div
id="time-segment"
class="relative"
data-time-segment-bucket-date={segment.date}
data-label={segment.dateFormatted}
style:height={segment.height + 'px'}
aria-label={segment.dateFormatted + ' ' + segment.count}
>
{#if segment.hasLabel}
<div
aria-label={segment.dateFormatted + ' ' + segment.count}
class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono"
>
{segment.date.year}
</div>
{/if}
{#if segment.hasDot}
<div
aria-label={segment.dateFormatted + ' ' + segment.count}
class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"
/>
{/if}
</div>
{/each}
<div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
</div>
<style>
#immich-scrubbable-scrollbar,
#time-segment {
contain: layout size style;
}
</style>

View File

@ -4,7 +4,8 @@
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { suggestDuplicateByFileSize } from '$lib/utils';
import { handlePromiseError, suggestDuplicateByFileSize } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
import { shortcuts } from '$lib/actions/shortcut';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
@ -158,7 +159,10 @@
const index = getAssetIndex($viewingAsset.id) - 1 + assets.length;
setAsset(assets[index % assets.length]);
}}
on:close={() => assetViewingStore.showAssetViewer(false)}
on:close={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
</Portal>
{/await}

View File

@ -1,4 +1,5 @@
import { getKey } from '$lib/utils';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { readonly, writable } from 'svelte/store';
@ -6,6 +7,7 @@ function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<AssetResponseDto[]>([]);
const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => {
preloadAssets.set(assetsToPreload);
@ -26,6 +28,7 @@ function createAssetViewingStore() {
asset: readonly(viewingAssetStoreState),
preloadAssets: readonly(preloadAssets),
isViewing: viewState,
gridScrollTarget,
setAsset,
setAssetId,
showAssetViewer,

View File

@ -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', () => {

View File

@ -1,6 +1,11 @@
import { locale } from '$lib/stores/preferences.store';
import { getKey } from '$lib/utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
import { getAssetRatio } from '$lib/utils/asset-utils';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import createJustifiedLayout from 'justified-layout';
import { throttle } from 'lodash-es';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
@ -8,19 +13,24 @@ import { get, writable, type Unsubscriber } from 'svelte/store';
import { handleError } from '../utils/handle-error';
import { websocketEvents } from './websocket';
export enum BucketPosition {
Above = 'above',
Below = 'below',
Visible = 'visible',
Unknown = 'unknown',
}
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;
const LAYOUT_OPTIONS = {
boxSpacing: 2,
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
};
export interface Viewport {
width: number;
height: number;
}
export type ViewportXY = Viewport & {
x: number;
y: number;
};
interface AssetLookup {
bucket: AssetBucket;
@ -29,16 +39,89 @@ interface AssetLookup {
}
export class AssetBucket {
store!: AssetStore;
bucketDate!: string;
/**
* The DOM height of the bucket in pixel
* This value is first estimated by the number of asset and later is corrected as the user scroll
*/
bucketHeight!: number;
bucketDate!: string;
bucketCount!: number;
assets!: AssetResponseDto[];
cancelToken!: AbortController | null;
position!: BucketPosition;
bucketHeight: number = 0;
isBucketHeightActual: boolean = false;
bucketDateFormattted!: string;
bucketCount: number = 0;
assets: AssetResponseDto[] = [];
dateGroups: DateGroup[] = [];
cancelToken: AbortController | undefined;
/**
* Prevent this asset's load from being canceled; i.e. to force load of offscreen asset.
*/
isPreventCancel: boolean = false;
/**
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
*/
complete!: Promise<void>;
loading: boolean = false;
isLoaded: boolean = false;
intersecting: boolean = false;
measured: boolean = false;
measuredPromise!: Promise<void>;
constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) {
Object.assign(this, props);
this.init();
}
private init() {
// create a promise, and store its resolve/reject callbacks. The loadedSignal callback
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal
// callback will be called if the bucket is canceled before it was loaded, rejecting the
// promise.
this.complete = new Promise((resolve, reject) => {
this.loadedSignal = resolve;
this.canceledSignal = reject;
});
// if no-one waits on complete, and its rejected a uncaught rejection message is logged.
// We this message with an empty reject handler, since waiting on a bucket is optional.
this.complete.catch(() => void 0);
this.measuredPromise = new Promise((resolve) => {
this.measuredSignal = resolve;
});
this.bucketDateFormattted = fromLocalDateTime(this.bucketDate)
.startOf('month')
.toJSDate()
.toLocaleString(get(locale), {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
private loadedSignal: (() => void) | undefined;
private canceledSignal: (() => void) | undefined;
measuredSignal: (() => void) | undefined;
cancel() {
if (this.isLoaded) {
return;
}
if (this.isPreventCancel) {
return;
}
this.cancelToken?.abort();
this.canceledSignal?.();
this.init();
}
loaded() {
this.loadedSignal?.();
this.isLoaded = true;
}
errored() {
this.canceledSignal?.();
this.init();
}
}
const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
@ -65,34 +148,101 @@ interface TrashAssets {
type: 'trash';
values: string[];
}
interface UpdateStackAssets {
type: 'update_stack_assets';
values: string[];
}
export const photoViewer = writable<HTMLImageElement | null>(null);
type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets;
type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
export type BucketListener = (
event:
| ViewPortEvent
| BucketLoadEvent
| BucketLoadedEvent
| BucketCancelEvent
| BucketHeightEvent
| DateGroupIntersecting
| DateGroupHeightEvent,
) => void;
type ViewPortEvent = {
type: 'viewport';
};
type BucketLoadEvent = {
type: 'load';
bucket: AssetBucket;
};
type BucketLoadedEvent = {
type: 'loaded';
bucket: AssetBucket;
};
type BucketCancelEvent = {
type: 'cancel';
bucket: AssetBucket;
};
type BucketHeightEvent = {
type: 'bucket-height';
bucket: AssetBucket;
delta: number;
};
type DateGroupIntersecting = {
type: 'intersecting';
bucket: AssetBucket;
dateGroup: DateGroup;
};
type DateGroupHeightEvent = {
type: 'height';
bucket: AssetBucket;
dateGroup: DateGroup;
delta: number;
height: number;
};
export class AssetStore {
private store$ = writable(this);
private assetToBucket: Record<string, AssetLookup> = {};
private pendingChanges: PendingChange[] = [];
private unsubscribers: Unsubscriber[] = [];
private options: AssetApiGetTimeBucketsRequest;
private viewport: Viewport = {
height: 0,
width: 0,
};
private initializedSignal!: () => void;
private store$ = writable(this);
lastScrollTime: number = 0;
subscribe = this.store$.subscribe;
/**
* A promise that resolves once the store is initialized.
*/
taskManager = new AssetGridTaskManager(this);
complete!: Promise<void>;
initialized = false;
timelineHeight = 0;
buckets: AssetBucket[] = [];
assets: AssetResponseDto[] = [];
albumAssets: Set<string> = new Set();
pendingScrollBucket: AssetBucket | undefined;
pendingScrollAssetId: string | undefined;
listeners: BucketListener[] = [];
constructor(
options: AssetStoreOptions,
private albumId?: string,
) {
this.options = { ...options, size: TimeBucketSize.Month };
// create a promise, and store its resolve callbacks. The initializedSignal callback
// will be invoked when a the assetstore is initialized.
this.complete = new Promise((resolve) => {
this.initializedSignal = resolve;
});
this.store$.set(this);
}
subscribe = this.store$.subscribe;
private addPendingChanges(...changes: PendingChange[]) {
// prevent websocket events from happening before local client events
setTimeout(() => {
@ -182,8 +332,35 @@ export class AssetStore {
this.emit(true);
}, 2500);
async init(viewport: Viewport) {
this.initialized = false;
addListener(bucketListener: BucketListener) {
this.listeners.push(bucketListener);
}
removeListener(bucketListener: BucketListener) {
this.listeners = this.listeners.filter((l) => l != bucketListener);
}
private notifyListeners(
event:
| ViewPortEvent
| BucketLoadEvent
| BucketLoadedEvent
| BucketCancelEvent
| BucketHeightEvent
| DateGroupIntersecting
| DateGroupHeightEvent,
) {
for (const fn of this.listeners) {
fn(event);
}
}
async init({ bucketListener }: { bucketListener?: BucketListener } = {}) {
if (this.initialized) {
throw 'Can only init once';
}
if (bucketListener) {
this.addListener(bucketListener);
}
// uncaught rejection go away
this.complete.catch(() => void 0);
this.timelineHeight = 0;
this.buckets = [];
this.assets = [];
@ -194,65 +371,118 @@ export class AssetStore {
...this.options,
key: getKey(),
});
this.buckets = timebuckets.map(
(bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }),
);
this.initializedSignal();
this.initialized = true;
this.buckets = timebuckets.map((bucket) => ({
bucketDate: bucket.timeBucket,
bucketHeight: 0,
bucketCount: bucket.count,
assets: [],
cancelToken: null,
position: BucketPosition.Unknown,
}));
// if loading an asset, the grid-view may be hidden, which means
// it has 0 width and height. No need to update bucket or timeline
// heights in this case. Later, updateViewport will be called to
// update the heights.
if (viewport.height !== 0 && viewport.width !== 0) {
await this.updateViewport(viewport);
}
}
async updateViewport(viewport: Viewport) {
public destroy() {
this.taskManager.destroy();
this.listeners = [];
this.initialized = false;
}
async updateViewport(viewport: Viewport, force?: boolean) {
if (!this.initialized) {
return;
}
if (viewport.height === 0 && viewport.width === 0) {
return;
}
if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) {
return;
}
// changing width invalidates the actual height, and needs to be remeasured, since width changes causes
// layout reflows.
const changedWidth = this.viewport.width != viewport.width;
this.viewport = { ...viewport };
for (const bucket of this.buckets) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewport.width);
const height = rows * THUMBNAIL_HEIGHT;
bucket.bucketHeight = height;
this.updateGeometry(bucket, changedWidth);
}
this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
let height = 0;
const loaders = [];
let height = 0;
for (const bucket of this.buckets) {
if (height < viewport.height) {
height += bucket.bucketHeight;
loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible));
continue;
}
if (height >= viewport.height) {
break;
}
height += bucket.bucketHeight;
loaders.push(this.loadBucket(bucket.bucketDate));
}
await Promise.all(loaders);
this.notifyListeners({ type: 'viewport' });
this.emit(false);
}
async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> {
private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
if (invalidateHeight) {
bucket.isBucketHeightActual = false;
bucket.measured = false;
for (const assetGroup of bucket.dateGroups) {
assetGroup.heightActual = false;
}
}
if (!bucket.isBucketHeightActual) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
const rows = Math.ceil(unwrappedWidth / this.viewport.width);
const height = 51 + rows * THUMBNAIL_HEIGHT;
bucket.bucketHeight = height;
}
for (const assetGroup of bucket.dateGroups) {
if (!assetGroup.heightActual) {
const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10);
const rows = Math.ceil(unwrappedWidth / this.viewport.width);
const height = rows * THUMBNAIL_HEIGHT;
assetGroup.height = height;
}
const layoutResult = createJustifiedLayout(
assetGroup.assets.map((g) => getAssetRatio(g)),
{
...LAYOUT_OPTIONS,
containerWidth: Math.floor(this.viewport.width),
},
);
assetGroup.geometry = {
...layoutResult,
containerWidth: calculateWidth(layoutResult.boxes),
};
}
}
async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise<void> {
const bucket = this.getBucketByDate(bucketDate);
if (!bucket) {
return;
}
bucket.position = position;
if (bucket.cancelToken || bucket.assets.length > 0) {
this.emit(false);
if (bucket.bucketCount === bucket.assets.length) {
// already loaded
return;
}
bucket.cancelToken = new AbortController();
if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) {
// if promise is pending, and preventCancel is requested, then don't overwrite it
if (!bucket.isPreventCancel && options.preventCancel) {
bucket.isPreventCancel = options.preventCancel;
}
await bucket.complete;
return;
}
if (options.pending) {
this.pendingScrollBucket = bucket;
}
this.notifyListeners({ type: 'load', bucket });
bucket.isPreventCancel = !!options.preventCancel;
const cancelToken = (bucket.cancelToken = new AbortController());
try {
const assets = await getTimeBucket(
{
@ -260,9 +490,14 @@ export class AssetStore {
timeBucket: bucketDate,
key: getKey(),
},
{ signal: bucket.cancelToken.signal },
{ signal: cancelToken.signal },
);
if (cancelToken.signal.aborted) {
this.notifyListeners({ type: 'cancel', bucket });
return;
}
if (this.albumId) {
const albumAssets = await getTimeBucket(
{
@ -271,50 +506,87 @@ export class AssetStore {
size: this.options.size,
key: getKey(),
},
{ signal: bucket.cancelToken.signal },
{ signal: cancelToken.signal },
);
if (cancelToken.signal.aborted) {
this.notifyListeners({ type: 'cancel', bucket });
return;
}
for (const asset of albumAssets) {
this.albumAssets.add(asset.id);
}
}
if (bucket.cancelToken.signal.aborted) {
bucket.assets = assets;
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
this.updateGeometry(bucket, true);
this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
bucket.loaded();
this.notifyListeners({ type: 'loaded', bucket });
} catch (error) {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
if ((error as any).name === 'AbortError') {
return;
}
bucket.assets = assets;
this.emit(true);
} catch (error) {
const $t = get(t);
handleError(error, $t('errors.failed_to_load_assets'));
bucket.errored();
} finally {
bucket.cancelToken = null;
bucket.cancelToken = undefined;
this.emit(true);
}
}
cancelBucket(bucket: AssetBucket) {
bucket.cancelToken?.abort();
}
updateBucket(bucketDate: string, height: number) {
updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
const bucket = this.getBucketByDate(bucketDate);
if (!bucket) {
return 0;
return {};
}
let delta = 0;
if ('height' in properties) {
const height = properties.height!;
delta = height - bucket.bucketHeight;
bucket.isBucketHeightActual = true;
bucket.bucketHeight = height;
this.timelineHeight += delta;
this.notifyListeners({ type: 'bucket-height', bucket, delta });
}
if ('intersecting' in properties) {
bucket.intersecting = properties.intersecting!;
}
if ('measured' in properties) {
if (properties.measured) {
bucket.measuredSignal?.();
}
bucket.measured = properties.measured!;
}
this.emit(false);
return { delta };
}
const delta = height - bucket.bucketHeight;
const scrollTimeline = bucket.position == BucketPosition.Above;
bucket.bucketHeight = height;
bucket.position = BucketPosition.Unknown;
this.timelineHeight += delta;
updateBucketDateGroup(
bucket: AssetBucket,
dateGroup: DateGroup,
properties: { height?: number; intersecting?: boolean },
) {
let delta = 0;
if ('height' in properties) {
const height = properties.height!;
if (height > 0) {
delta = height - dateGroup.height;
dateGroup.heightActual = true;
dateGroup.height = height;
this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height });
}
}
if ('intersecting' in properties) {
dateGroup.intersecting = properties.intersecting!;
if (dateGroup.intersecting) {
this.notifyListeners({ type: 'intersecting', bucket, dateGroup });
}
}
this.emit(false);
return scrollTimeline ? delta : 0;
return { delta };
}
addAssets(assets: AssetResponseDto[]) {
@ -354,15 +626,7 @@ export class AssetStore {
let bucket = this.getBucketByDate(timeBucket);
if (!bucket) {
bucket = {
bucketDate: timeBucket,
bucketHeight: THUMBNAIL_HEIGHT,
bucketCount: 0,
assets: [],
cancelToken: null,
position: BucketPosition.Unknown,
};
bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT });
this.buckets.push(bucket);
}
@ -383,6 +647,8 @@ export class AssetStore {
const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC();
return bDate.diff(aDate).milliseconds;
});
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
this.updateGeometry(bucket, true);
}
this.emit(true);
@ -392,18 +658,73 @@ export class AssetStore {
return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
}
async getBucketInfoForAssetId({ id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>) {
async findAndLoadBucketAsPending(id: string) {
const bucketInfo = this.assetToBucket[id];
if (bucketInfo) {
return bucketInfo;
const bucket = bucketInfo.bucket;
this.pendingScrollBucket = bucket;
this.pendingScrollAssetId = id;
this.emit(false);
return bucket;
}
const asset = await getAssetInfo({ id });
if (asset) {
if (this.options.isArchived !== asset.isArchived) {
return;
}
const bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
if (bucket) {
this.pendingScrollBucket = bucket;
this.pendingScrollAssetId = asset.id;
this.emit(false);
}
return bucket;
}
}
/* Must be paired with matching clearPendingScroll() call */
async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) {
try {
const { at: assetId } = scrollTarget;
if (assetId) {
await this.complete;
const bucket = await this.findAndLoadBucketAsPending(assetId);
if (bucket) {
return;
}
}
} catch {
// failure
}
onFailure();
}
clearPendingScroll() {
this.pendingScrollBucket = undefined;
this.pendingScrollAssetId = undefined;
}
private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) {
let date = fromLocalDateTime(localDateTime);
if (this.options.size == TimeBucketSize.Month) {
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
} else if (this.options.size == TimeBucketSize.Day) {
date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
}
await this.loadBucket(date.toISO()!, BucketPosition.Unknown);
const iso = date.toISO()!;
await this.loadBucket(iso, options);
return this.getBucketByDate(iso);
}
private async getBucketInfoForAsset(
{ id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>,
options: { preventCancel?: boolean; pending?: boolean } = {},
) {
const bucketInfo = this.assetToBucket[id];
if (bucketInfo) {
return bucketInfo;
}
await this.loadBucketAtTime(localDateTime, options);
return this.assetToBucket[id] || null;
}
@ -417,7 +738,7 @@ export class AssetStore {
);
for (const bucket of this.buckets) {
if (index < bucket.bucketCount) {
await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
await this.loadBucket(bucket.bucketDate);
return bucket.assets[index] || null;
}
@ -458,6 +779,7 @@ export class AssetStore {
// Iterate in reverse to allow array splicing.
for (let index = this.buckets.length - 1; index >= 0; index--) {
const bucket = this.buckets[index];
let changed = false;
for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) {
const asset = bucket.assets[index_];
if (!idSet.has(asset.id)) {
@ -465,17 +787,22 @@ export class AssetStore {
}
bucket.assets.splice(index_, 1);
changed = true;
if (bucket.assets.length === 0) {
this.buckets.splice(index, 1);
}
}
if (changed) {
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
this.updateGeometry(bucket, true);
}
}
this.emit(true);
}
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
const info = await this.getBucketInfoForAssetId(asset);
const info = await this.getBucketInfoForAsset(asset);
if (!info) {
return null;
}
@ -491,12 +818,12 @@ export class AssetStore {
}
const previousBucket = this.buckets[bucketIndex - 1];
await this.loadBucket(previousBucket.bucketDate, BucketPosition.Unknown);
await this.loadBucket(previousBucket.bucketDate);
return previousBucket.assets.at(-1) || null;
}
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
const info = await this.getBucketInfoForAssetId(asset);
const info = await this.getBucketInfoForAsset(asset);
if (!info) {
return null;
}
@ -512,7 +839,7 @@ export class AssetStore {
}
const nextBucket = this.buckets[bucketIndex + 1];
await this.loadBucket(nextBucket.bucketDate, BucketPosition.Unknown);
await this.loadBucket(nextBucket.bucketDate);
return nextBucket.assets[0] || null;
}
@ -537,8 +864,7 @@ export class AssetStore {
}
this.assetToBucket = assetToBucket;
}
this.store$.update(() => this);
this.store$.set(this);
}
}

View File

@ -0,0 +1,465 @@
import type { AssetBucket, AssetStore } from '$lib/stores/assets.store';
import { generateId } from '$lib/utils/generate-id';
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
import { type DateGroup } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { type AssetResponseDto } from '@immich/sdk';
import { clamp } from 'lodash-es';
type Task = () => void;
class InternalTaskManager {
assetStore: AssetStore;
componentTasks = new Map<string, Set<string>>();
priorityQueue = new KeyedPriorityQueue<string, Task>();
idleQueue = new Map<string, Task>();
taskCleaners = new Map<string, Task>();
queueTimer: ReturnType<typeof setTimeout> | undefined;
lastIdle: number | undefined;
constructor(assetStore: AssetStore) {
this.assetStore = assetStore;
}
destroy() {
this.componentTasks.clear();
this.priorityQueue.clear();
this.idleQueue.clear();
this.taskCleaners.clear();
clearTimeout(this.queueTimer);
if (this.lastIdle) {
cancelIdleCB(this.lastIdle);
}
}
getOrCreateComponentTasks(componentId: string) {
let componentTaskSet = this.componentTasks.get(componentId);
if (!componentTaskSet) {
componentTaskSet = new Set<string>();
this.componentTasks.set(componentId, componentTaskSet);
}
return componentTaskSet;
}
deleteFromComponentTasks(componentId: string, taskId: string) {
if (this.componentTasks.has(componentId)) {
const componentTaskSet = this.componentTasks.get(componentId);
componentTaskSet?.delete(taskId);
if (componentTaskSet?.size === 0) {
this.componentTasks.delete(componentId);
}
}
}
drainIntersectedQueue() {
let count = 0;
for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) {
t.value();
if (this.taskCleaners.has(t.key)) {
this.taskCleaners.get(t.key)!();
this.taskCleaners.delete(t.key);
}
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS);
break;
}
}
}
scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) {
clearTimeout(this.queueTimer);
this.queueTimer = setTimeout(() => {
const delta = Date.now() - this.assetStore.lastScrollTime;
if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
let amount = clamp(
1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR),
1,
TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2,
);
const nextDelay = clamp(
amount > 1
? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR)
: TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS,
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY,
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY,
);
while (amount > 0) {
this.priorityQueue.shift()?.value();
amount--;
}
if (this.priorityQueue.length > 0) {
this.scheduleDrainIntersectedQueue(nextDelay);
}
} else {
this.drainIntersectedQueue();
}
}, delay);
}
removeAllTasksForComponent(componentId: string) {
if (this.componentTasks.has(componentId)) {
const tasksIds = this.componentTasks.get(componentId) || [];
for (const taskId of tasksIds) {
this.priorityQueue.remove(taskId);
this.idleQueue.delete(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
}
}
this.componentTasks.delete(componentId);
}
queueScrollSensitiveTask({
task,
cleanup,
componentId,
priority = 10,
taskId = generateId(),
}: {
task: Task;
cleanup?: Task;
componentId: string;
priority?: number;
taskId?: string;
}) {
this.priorityQueue.push(taskId, task, priority);
if (cleanup) {
this.taskCleaners.set(taskId, cleanup);
}
this.getOrCreateComponentTasks(componentId).add(taskId);
const lastTime = this.assetStore.lastScrollTime;
const delta = Date.now() - lastTime;
if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
this.scheduleDrainIntersectedQueue();
} else {
// flush the queue early
clearTimeout(this.queueTimer);
this.drainIntersectedQueue();
}
}
scheduleDrainSeparatedQueue() {
if (this.lastIdle) {
cancelIdleCB(this.lastIdle);
}
this.lastIdle = idleCB(
() => {
let count = 0;
let entry = this.idleQueue.entries().next().value;
while (entry) {
const [taskId, task] = entry;
this.idleQueue.delete(taskId);
task();
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
break;
}
entry = this.idleQueue.entries().next().value;
}
if (this.idleQueue.size > 0) {
this.scheduleDrainSeparatedQueue();
}
},
{ timeout: 1000 },
);
}
queueSeparateTask({
task,
cleanup,
componentId,
taskId,
}: {
task: Task;
cleanup: Task;
componentId: string;
taskId: string;
}) {
this.idleQueue.set(taskId, task);
this.taskCleaners.set(taskId, cleanup);
this.getOrCreateComponentTasks(componentId).add(taskId);
this.scheduleDrainSeparatedQueue();
}
removeIntersectedTask(taskId: string) {
const removed = this.priorityQueue.remove(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
return removed;
}
removeSeparateTask(taskId: string) {
const removed = this.idleQueue.delete(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
return removed;
}
}
export class AssetGridTaskManager {
private internalManager: InternalTaskManager;
constructor(assetStore: AssetStore) {
this.internalManager = new InternalTaskManager(assetStore);
}
tasks: Map<AssetBucket, BucketTask> = new Map();
queueScrollSensitiveTask({
task,
cleanup,
componentId,
priority = 10,
taskId = generateId(),
}: {
task: Task;
cleanup?: Task;
componentId: string;
priority?: number;
taskId?: string;
}) {
return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId });
}
removeAllTasksForComponent(componentId: string) {
return this.internalManager.removeAllTasksForComponent(componentId);
}
destroy() {
return this.internalManager.destroy();
}
private getOrCreateBucketTask(bucket: AssetBucket) {
let bucketTask = this.tasks.get(bucket);
if (!bucketTask) {
bucketTask = this.createBucketTask(bucket);
}
return bucketTask;
}
private createBucketTask(bucket: AssetBucket) {
const bucketTask = new BucketTask(this.internalManager, this, bucket);
this.tasks.set(bucket, bucketTask);
return bucketTask;
}
intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) {
const bucketTask = this.getOrCreateBucketTask(bucket);
bucketTask.scheduleIntersected(componentId, task);
}
seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
const bucketTask = this.getOrCreateBucketTask(bucket);
bucketTask.scheduleSeparated(componentId, seperated);
}
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
}
seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
}
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
}
seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.separatedThumbnail(componentId, asset, seperated);
}
}
class IntersectionTask {
internalTaskManager: InternalTaskManager;
seperatedKey;
intersectedKey;
priority;
intersected: Task | undefined;
separated: Task | undefined;
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
this.internalTaskManager = internalTaskManager;
this.seperatedKey = keyPrefix + ':s:' + key;
this.intersectedKey = keyPrefix + ':i:' + key;
this.priority = priority;
}
trackIntersectedTask(componentId: string, task: Task) {
const execTask = () => {
if (this.separated) {
return;
}
task?.();
};
this.intersected = execTask;
const cleanup = () => {
this.intersected = undefined;
this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey);
};
return { task: execTask, cleanup };
}
trackSeperatedTask(componentId: string, task: Task) {
const execTask = () => {
if (this.intersected) {
return;
}
task?.();
};
this.separated = execTask;
const cleanup = () => {
this.separated = undefined;
this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
};
return { task: execTask, cleanup };
}
removePendingSeparated() {
if (this.separated) {
this.internalTaskManager.removeSeparateTask(this.seperatedKey);
}
}
removePendingIntersected() {
if (this.intersected) {
this.internalTaskManager.removeIntersectedTask(this.intersectedKey);
}
}
scheduleIntersected(componentId: string, intersected: Task) {
this.removePendingSeparated();
if (this.intersected) {
return;
}
const { task, cleanup } = this.trackIntersectedTask(componentId, intersected);
this.internalTaskManager.queueScrollSensitiveTask({
task,
cleanup,
componentId: componentId,
priority: this.priority,
taskId: this.intersectedKey,
});
}
scheduleSeparated(componentId: string, separated: Task) {
this.removePendingIntersected();
if (this.separated) {
return;
}
const { task, cleanup } = this.trackSeperatedTask(componentId, separated);
this.internalTaskManager.queueSeparateTask({
task,
cleanup,
componentId: componentId,
taskId: this.seperatedKey,
});
}
}
class BucketTask extends IntersectionTask {
assetBucket: AssetBucket;
assetGridTaskManager: AssetGridTaskManager;
// indexed by dateGroup's date
dateTasks: Map<DateGroup, DateGroupTask> = new Map();
constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) {
super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY);
this.assetBucket = assetBucket;
this.assetGridTaskManager = parent;
}
getOrCreateDateGroupTask(dateGroup: DateGroup) {
let dateGroupTask = this.dateTasks.get(dateGroup);
if (!dateGroupTask) {
dateGroupTask = this.createDateGroupTask(dateGroup);
}
return dateGroupTask;
}
createDateGroupTask(dateGroup: DateGroup) {
const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup);
this.dateTasks.set(dateGroup, dateGroupTask);
return dateGroupTask;
}
removePendingSeparated() {
super.removePendingSeparated();
for (const dateGroupTask of this.dateTasks.values()) {
dateGroupTask.removePendingSeparated();
}
}
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.scheduleIntersected(componentId, intersected);
}
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.scheduleSeparated(componentId, separated);
}
}
class DateGroupTask extends IntersectionTask {
dateGroup: DateGroup;
bucketTask: BucketTask;
// indexed by thumbnail's asset
thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map();
constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) {
super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY);
this.dateGroup = dateGroup;
this.bucketTask = parent;
}
removePendingSeparated() {
super.removePendingSeparated();
for (const thumbnailTask of this.thumbnailTasks.values()) {
thumbnailTask.removePendingSeparated();
}
}
getOrCreateThumbnailTask(asset: AssetResponseDto) {
let thumbnailTask = this.thumbnailTasks.get(asset);
if (!thumbnailTask) {
thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset);
this.thumbnailTasks.set(asset, thumbnailTask);
}
return thumbnailTask;
}
intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) {
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
thumbnailTask.scheduleIntersected(componentId, intersected);
}
separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) {
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
thumbnailTask.scheduleSeparated(componentId, seperated);
}
}
class ThumbnailTask extends IntersectionTask {
asset: AssetResponseDto;
dateGroupTask: DateGroupTask;
constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) {
super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY);
this.asset = asset;
this.dateGroupTask = parent;
}
}

View File

@ -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

View File

@ -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;

View File

@ -0,0 +1,50 @@
export class KeyedPriorityQueue<K, T> {
private items: { key: K; value: T; priority: number }[] = [];
private set: Set<K> = new Set();
clear() {
this.items = [];
this.set.clear();
}
remove(key: K) {
const removed = this.set.delete(key);
if (removed) {
const idx = this.items.findIndex((i) => i.key === key);
if (idx >= 0) {
this.items.splice(idx, 1);
}
}
return removed;
}
push(key: K, value: T, priority: number) {
if (this.set.has(key)) {
return this.length;
}
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].priority > priority) {
this.set.add(key);
this.items.splice(i, 0, { key, value, priority });
return this.length;
}
}
this.set.add(key);
return this.items.push({ key, value, priority });
}
shift() {
let item = this.items.shift();
while (item) {
if (this.set.has(item.key)) {
this.set.delete(item.key);
return item;
}
item = this.items.shift();
}
}
get length() {
return this.set.size;
}
}

View File

@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { get } from 'svelte/store';
export type AssetGridRouteSearchParams = {
at: string | null | undefined;
};
export const isExternalUrl = (url: string): boolean => {
return new URL(url, window.location.href).origin !== window.location.origin;
};
@ -33,17 +36,38 @@ function currentUrlWithoutAsset() {
export function currentUrlReplaceAssetId(assetId: string) {
const $page = get(page);
const params = new URLSearchParams($page.url.search);
// always remove the assetGridScrollTargetParams
params.delete('at');
const searchparams = params.size > 0 ? '?' + params.toString() : '';
// this contains special casing for the /photos/:assetId photos route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
return isPhotosRoute($page.route.id)
? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}`
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`;
? `${AppRoute.PHOTOS}/${assetId}${searchparams}`
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`;
}
function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) {
const $page = get(page);
const parsed = new URL(url, $page.url);
const { at: assetId } = searchParams || { at: null };
if (!assetId) {
return parsed.pathname;
}
const params = new URLSearchParams($page.url.search);
if (assetId) {
params.set('at', assetId);
}
return parsed.pathname + '?' + params.toString();
}
function currentUrl() {
const $page = get(page);
const current = $page.url;
return current.pathname + current.search;
return current.pathname + current.search + current.hash;
}
interface Route {
@ -55,24 +79,58 @@ interface Route {
interface AssetRoute extends Route {
targetRoute: 'current';
assetId: string | null;
assetId: string | null | undefined;
}
interface AssetGridRoute extends Route {
targetRoute: 'current';
assetId: string | null | undefined;
assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined;
}
type ImmichRoute = AssetRoute | AssetGridRoute;
type NavOptions = {
/* navigate even if url is the same */
forceNavigate?: boolean | undefined;
replaceState?: boolean | undefined;
noScroll?: boolean | undefined;
keepFocus?: boolean | undefined;
invalidateAll?: boolean | undefined;
state?: App.PageState | undefined;
};
function isAssetRoute(route: Route): route is AssetRoute {
return route.targetRoute === 'current' && 'assetId' in route;
}
async function navigateAssetRoute(route: AssetRoute) {
function isAssetGridRoute(route: Route): route is AssetGridRoute {
return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route;
}
async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) {
const { assetId } = route;
const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
if (next !== currentUrl()) {
await goto(next, { replaceState: false });
const current = currentUrl();
if (next !== current || options?.forceNavigate) {
await goto(next, options);
}
}
export function navigate<T extends Route>(change: T): Promise<void> {
if (isAssetRoute(change)) {
return navigateAssetRoute(change);
async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) {
const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route;
const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
const next = replaceScrollTarget(assetUrl, assetGridScrollTarget);
const current = currentUrl();
if (next !== current || options?.forceNavigate) {
await goto(next, options);
}
}
export function navigate(change: ImmichRoute, options?: NavOptions): Promise<void> {
if (isAssetGridRoute(change)) {
return navigateAssetGridRoute(change, options);
} else if (isAssetRoute(change)) {
return navigateAssetRoute(change, options);
}
// future navigation requests here
throw `Invalid navigation: ${JSON.stringify(change)}`;

View File

@ -0,0 +1,21 @@
export class PriorityQueue<T> {
private items: { value: T; priority: number }[] = [];
push(value: T, priority: number) {
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].priority > priority) {
this.items.splice(i, 0, { value, priority });
return this.length;
}
}
return this.items.push({ value, priority });
}
shift() {
return this.items.shift();
}
get length() {
return this.items.length;
}
}

View File

@ -1,9 +1,38 @@
import type { AssetBucket } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto } from '@immich/sdk';
import { groupBy, sortBy } from 'lodash-es';
import type createJustifiedLayout from 'justified-layout';
import { groupBy, memoize, sortBy } from 'lodash-es';
import { DateTime } from 'luxon';
import { get } from 'svelte/store';
export type DateGroup = {
date: DateTime;
groupTitle: string;
assets: AssetResponseDto[];
height: number;
heightActual: boolean;
intersecting: boolean;
geometry: Geometry;
bucket: AssetBucket;
};
export type ScrubberListener = (
bucketDate: string | undefined,
overallScrollPercent: number,
bucketScrollPercent: number,
) => void | Promise<void>;
export type ScrollTargetListener = ({
bucket,
dateGroup,
asset,
offset,
}: {
bucket: AssetBucket;
dateGroup: DateGroup;
asset: AssetResponseDto;
offset: number;
}) => void;
export const fromLocalDateTime = (localDateTime: string) =>
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string {
return date.toLocaleString(groupDateFormat);
}
export function splitBucketIntoDateGroups(
assets: AssetResponseDto[],
locale: string | undefined,
): AssetResponseDto[][] {
const grouped = groupBy(assets, (asset) =>
type Geometry = ReturnType<typeof createJustifiedLayout> & {
containerWidth: number;
};
function emptyGeometry() {
return {
containerWidth: 0,
containerHeight: 0,
widowCount: 0,
boxes: [],
};
}
const formatDateGroupTitle = memoize(formatGroupTitle);
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
const grouped = groupBy(bucket.assets, (asset) =>
fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }),
);
return sortBy(grouped, (group) => assets.indexOf(group[0]));
const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0]));
return sorted.map((group) => {
const date = fromLocalDateTime(group[0].localDateTime).startOf('day');
return {
date,
groupTitle: formatDateGroupTitle(date),
assets: group,
height: 0,
heightActual: false,
intersecting: false,
geometry: emptyGeometry(),
bucket: bucket,
};
});
}
export type LayoutBox = {
aspectRatio: number;
top: number;
left: number;
width: number;
height: number;
left: number;
forcedAspectRatio?: boolean;
};
export function calculateWidth(boxes: LayoutBox[]): number {
@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number {
width = box.left + box.width;
}
}
return width;
}
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
let offset = 0;
while (element.offsetParent && element !== stop) {
offset += element.offsetTop;
element = element.offsetParent as HTMLElement;
}
return offset;
}

View File

@ -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),
},
};

View File

@ -1,10 +1,10 @@
<script lang="ts">
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
// This block takes care of opening the viewer.
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
// $page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
$: {
@ -13,6 +13,8 @@
} else {
$showAssetViewer = false;
}
const asset = $page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
}
</script>

View File

@ -43,7 +43,13 @@
import { downloadAlbum } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
import {
isAlbumsRoute,
isPeopleRoute,
isSearchRoute,
navigate,
type AssetGridRouteSearchParams,
} from '$lib/utils/navigation';
import {
AlbumUserRole,
AssetOrder,
@ -78,12 +84,15 @@
import type { PageData } from './$types';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let data: PageData;
let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined;
$: album = data.album;
$: albumId = album.id;
$: albumKey = `${albumId}_${albumOrder}`;
@ -244,7 +253,7 @@
}
if (viewMode === ViewMode.SELECT_ASSETS) {
handleCloseSelectAssets();
await handleCloseSelectAssets();
return;
}
if (viewMode === ViewMode.LINK_SHARING) {
@ -289,20 +298,37 @@
timelineInteractionStore.clearMultiselect();
viewMode = ViewMode.VIEW;
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} catch (error) {
handleError(error, $t('errors.error_adding_assets_to_album'));
}
};
const handleCloseSelectAssets = () => {
const setModeToView = async () => {
viewMode = ViewMode.VIEW;
assetStore.destroy();
assetStore = new AssetStore({ albumId, order: albumOrder });
timelineStore.destroy();
timelineStore = new AssetStore({ isArchived: false }, albumId);
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: oldAt?.at } },
{ replaceState: true, forceNavigate: true },
);
oldAt = null;
};
const handleCloseSelectAssets = async () => {
timelineInteractionStore.clearMultiselect();
await setModeToView();
};
const handleSelectFromComputer = async () => {
await openFileUploadDialog({ albumId: album.id });
timelineInteractionStore.clearMultiselect();
viewMode = ViewMode.VIEW;
await setModeToView();
};
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
@ -400,6 +426,11 @@
await deleteAlbum(album);
}
});
onDestroy(() => {
assetStore.destroy();
timelineStore.destroy();
});
</script>
<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
@ -444,7 +475,14 @@
{#if isEditor}
<CircleIconButton
title={$t('add_photos')}
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
on:click={async () => {
viewMode = ViewMode.SELECT_ASSETS;
oldAt = { at: $gridScrollTarget?.at };
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: { at: null } },
{ replaceState: true },
);
}}
icon={mdiImagePlusOutline}
/>
{/if}
@ -530,12 +568,14 @@
{#key albumKey}
{#if viewMode === ViewMode.SELECT_ASSETS}
<AssetGrid
enableRouting={false}
assetStore={timelineStore}
assetInteractionStore={timelineInteractionStore}
isSelectionMode={true}
/>
{:else}
<AssetGrid
enableRouting={true}
{album}
{assetStore}
{assetInteractionStore}

View File

@ -17,6 +17,7 @@
import type { PageData } from './$types';
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let data: PageData;
@ -25,6 +26,10 @@
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
onDestroy(() => {
assetStore.destroy();
});
</script>
{#if $isMultiSelectState}
@ -45,7 +50,7 @@
{/if}
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
<AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNARCHIVE}>
<EmptyPlaceholder text={$t('no_archived_assets_message')} slot="empty" />
</AssetGrid>
</UserPageLayout>

View File

@ -19,6 +19,7 @@
import type { PageData } from './$types';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let data: PageData;
@ -27,6 +28,10 @@
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: isAllArchive = [...$selectedAssets].every((asset) => asset.isArchived);
onDestroy(() => {
assetStore.destroy();
});
</script>
<!-- Multiselection mode app bar -->
@ -50,7 +55,7 @@
{/if}
<UserPageLayout hideNavbar={$isMultiSelectState} title={data.meta.title} scrollbar={false}>
<AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} removeAction={AssetAction.UNFAVORITE}>
<EmptyPlaceholder text={$t('no_favorites_message')} slot="empty" />
</AssetGrid>
</UserPageLayout>

View File

@ -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}

View File

@ -23,6 +23,7 @@
onDestroy(() => {
assetInteractionStore.clearMultiselect();
assetStore.destroy();
});
</script>
@ -45,5 +46,5 @@
</svelte:fragment>
</ControlAppBar>
{/if}
<AssetGrid {assetStore} {assetInteractionStore} />
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore} />
</main>

View File

@ -52,7 +52,7 @@
mdiEyeOutline,
mdiPlus,
} from '@mdi/js';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { PageData } from './$types';
import { listNavigation } from '$lib/actions/list-navigation';
import { t } from 'svelte-i18n';
@ -155,6 +155,7 @@
}
if (previousPersonId !== data.person.id) {
handlePromiseError(updateAssetCount());
assetStore.destroy();
assetStore = new AssetStore({
isArchived: false,
personId: data.person.id,
@ -344,6 +345,10 @@
await goto($page.url);
}
};
onDestroy(() => {
assetStore.destroy();
});
</script>
{#if viewMode === ViewMode.UNASSIGN_ASSETS}
@ -442,6 +447,7 @@
<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
{#key refreshAssetGrid}
<AssetGrid
enableRouting={true}
{assetStore}
{assetInteractionStore}
isSelectionMode={viewMode === ViewMode.SELECT_PERSON}

View File

@ -24,6 +24,7 @@
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { preferences, user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@ -48,6 +49,10 @@
return;
}
};
onDestroy(() => {
assetStore.destroy();
});
</script>
{#if $isMultiSelectState}
@ -84,6 +89,7 @@
<UserPageLayout hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
<AssetGrid
enableRouting={true}
{assetStore}
{assetInteractionStore}
removeAction={AssetAction.ARCHIVE}

View File

@ -291,7 +291,7 @@
<GalleryViewer
assets={searchResultAssets}
bind:selectedAssets
on:intersected={loadNextPage}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
/>

View File

@ -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'));
}

View File

@ -25,6 +25,7 @@
import { handlePromiseError } from '$lib/utils';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
export let data: PageData;
@ -84,6 +85,10 @@
handleError(error, $t('errors.unable_to_restore_trash'));
}
};
onDestroy(() => {
assetStore.destroy();
});
</script>
{#if $isMultiSelectState}
@ -111,7 +116,7 @@
</LinkButton>
</div>
<AssetGrid {assetStore} {assetInteractionStore}>
<AssetGrid enableRouting={true} {assetStore} {assetInteractionStore}>
<p class="font-medium text-gray-500/60 dark:text-gray-300/60 p-4">
{$t('trashed_items_will_be_permanently_deleted_after', { values: { days: $serverConfig.trashDays } })}
</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB