fix(web): fix lost scrollpos on deep link to timeline asset, scrub stop ()

* Work in progress - super quick asset store->state

* bugfix: deep linking to timeline, on scrub stop

* format, remove stale

* disable test, todo: fix test

* remove unused import

* Fix merge

* lint

* lint

* lint

* Default to non-wasm layout

* lint

* intobs fix

* fix rejected promise

* Review comments, static import wasm

* Back to dynamic

* try top-level-await

* back to the first solution, with more finesse

* comment out wasm for now

* back out the wasm/thumbhash/thumbnail changes

* lint

* Fully remove wasm

* lockfile

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-03-04 21:34:53 -05:00 committed by GitHub
parent 8b43066632
commit 56b85f7479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 362 additions and 305 deletions
web
src
lib
routes/(user)
albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]
archive/[[photos=photos]]/[[assetId=id]]
favorites/[[photos=photos]]/[[assetId=id]]
folders/[[photos=photos]]/[[assetId=id]]
partners/[userId]/[[photos=photos]]/[[assetId=id]]
people/[personId]/[[photos=photos]]/[[assetId=id]]
photos/[[assetId=id]]
search/[[photos=photos]]/[[assetId=id]]
tags/[[photos=photos]]/[[assetId=id]]
trash/[[photos=photos]]/[[assetId=id]]
vite.config.js

View file

@ -4,7 +4,7 @@
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';

View file

@ -19,7 +19,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import { photoViewerImgElement } from '$lib/stores/assets.store'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
interface Props { interface Props {

View file

@ -22,7 +22,7 @@
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { DateGroup } from '$lib/utils/timeline-util'; import type { DateGroup } from '$lib/utils/timeline-util';

View file

@ -3,7 +3,7 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';

View file

@ -6,7 +6,7 @@
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js'; import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { linear } from 'svelte/easing'; import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { photoViewerImgElement } from '$lib/stores/assets.store'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte';

View file

@ -25,7 +25,7 @@
import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { zoomImageToBase64 } from '$lib/utils/people-utils'; import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { photoViewerImgElement } from '$lib/stores/assets.store'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';

View file

@ -26,7 +26,7 @@
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type Viewport } from '$lib/stores/assets.store'; import { type Viewport } from '$lib/stores/assets-store.svelte';
import { loadMemories, memoryStore } from '$lib/stores/memory.store'; import { loadMemories, memoryStore } from '$lib/stores/memory.store';
import { locale, videoViewerMuted } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';

View file

@ -8,7 +8,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
onArchive: OnArchive; onArchive?: OnArchive;
menuItem?: boolean; menuItem?: boolean;
unarchive?: boolean; unarchive?: boolean;
} }
@ -28,7 +28,7 @@
loading = true; loading = true;
const ids = await archiveAssets(assets, isArchived); const ids = await archiveAssets(assets, isArchived);
if (ids) { if (ids) {
onArchive(ids, isArchived); onArchive?.(ids, isArchived);
clearSelect(); clearSelect();
} }
loading = false; loading = false;

View file

@ -13,7 +13,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
onFavorite: OnFavorite; onFavorite?: OnFavorite;
menuItem?: boolean; menuItem?: boolean;
removeFavorite: boolean; removeFavorite: boolean;
} }
@ -44,7 +44,7 @@
asset.isFavorite = isFavorite; asset.isFavorite = isFavorite;
} }
onFavorite(ids, isFavorite); onFavorite?.(ids, isFavorite);
notificationController.show({ notificationController.show({
message: isFavorite message: isFavorite

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets.store'; import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js'; import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';

View file

@ -2,7 +2,7 @@
import { intersectionObserver } from '$lib/actions/intersection-observer'; import { intersectionObserver } from '$lib/actions/intersection-observer';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import Skeleton from '$lib/components/photos-page/skeleton.svelte'; import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets.store'; import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
@ -89,25 +89,26 @@
}; };
onDestroy(() => { onDestroy(() => {
$assetStore.taskManager.removeAllTasksForComponent(componentId); assetStore.taskManager.removeAllTasksForComponent(componentId);
}); });
</script> </script>
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}> <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)} {#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
{@const display = {@const display =
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === $assetStore.pendingScrollAssetId)} dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)}
{@const geometry = dateGroup.geometry!}
<div <div
id="date-group" id="date-group"
use:intersectionObserver={{ use:intersectionObserver={{
onIntersect: () => { onIntersect: () => {
$assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
); );
}, },
onSeparate: () => { onSeparate: () => {
$assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () => assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
); );
}, },
@ -118,7 +119,7 @@
data-display={display} data-display={display}
data-date-group={dateGroup.date} data-date-group={dateGroup.date}
style:height={dateGroup.height + 'px'} style:height={dateGroup.height + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'} style:width={geometry.containerWidth + 'px'}
style:overflow="clip" style:overflow="clip"
> >
{#if !display} {#if !display}
@ -129,7 +130,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
on:mouseenter={() => on:mouseenter={() =>
$assetStore.taskManager.queueScrollSensitiveTask({ assetStore.taskManager.queueScrollSensitiveTask({
componentId, componentId,
task: () => { task: () => {
isMouseOverGroup = true; isMouseOverGroup = true;
@ -137,7 +138,7 @@
}, },
})} })}
on:mouseleave={() => { on:mouseleave={() => {
$assetStore.taskManager.queueScrollSensitiveTask({ assetStore.taskManager.queueScrollSensitiveTask({
componentId, componentId,
task: () => { task: () => {
isMouseOverGroup = false; isMouseOverGroup = false;
@ -149,7 +150,7 @@
<!-- Date group title --> <!-- Date group title -->
<div <div
class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" 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'} style:width={geometry.containerWidth + 'px'}
> >
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))} {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
<div <div
@ -174,11 +175,15 @@
<!-- Image grid --> <!-- Image grid -->
<div <div
class="relative overflow-clip" class="relative overflow-clip"
style:height={dateGroup.geometry.containerHeight + 'px'} style:height={geometry.containerHeight + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'} style:width={geometry.containerWidth + 'px'}
> >
{#each dateGroup.assets as asset, index (asset.id)} {#each dateGroup.assets as asset, i (asset.id)}
{@const box = dateGroup.geometry.boxes[index]} <!-- getting these together here in this order is very cache-efficient -->
{@const top = geometry.getTop(i)}
{@const left = geometry.getLeft(i)}
{@const width = geometry.getWidth(i)}
{@const height = geometry.getHeight(i)}
<!-- update ASSET_GRID_PADDING--> <!-- update ASSET_GRID_PADDING-->
<div <div
use:intersectionObserver={{ use:intersectionObserver={{
@ -190,10 +195,10 @@
}} }}
data-asset-id={asset.id} data-asset-id={asset.id}
class="absolute" class="absolute"
style:width={box.width + 'px'} style:top={top + 'px'}
style:height={box.height + 'px'} style:left={left + 'px'}
style:top={box.top + 'px'} style:width={width + 'px'}
style:left={box.left + 'px'} style:height={height + 'px'}
> >
<Thumbnail <Thumbnail
{dateGroup} {dateGroup}
@ -203,7 +208,7 @@
bottom: renderThumbsAtBottomMargin, bottom: renderThumbsAtBottomMargin,
top: renderThumbsAtTopMargin, top: renderThumbsAtTopMargin,
}} }}
retrieveElement={$assetStore.pendingScrollAssetId === asset.id} retrieveElement={assetStore.pendingScrollAssetId === asset.id}
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)} onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
showStackedIcon={withStacked} showStackedIcon={withStacked}
{showArchiveIcon} {showArchiveIcon}
@ -212,11 +217,11 @@
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
selected={assetInteraction.selectedAssets.has(asset) || $assetStore.albumAssets.has(asset.id)} selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
disabled={$assetStore.albumAssets.has(asset.id)} disabled={assetStore.albumAssets.has(asset.id)}
thumbnailWidth={box.width} thumbnailWidth={width}
thumbnailHeight={box.height} thumbnailHeight={height}
/> />
</div> </div>
{/each} {/each}

View file

@ -4,7 +4,7 @@
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets.store'; import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
import { locale, showDeleteModal } from '$lib/stores/preferences.store'; import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store'; import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
@ -117,7 +117,6 @@
const isViewportOrigin = () => { const isViewportOrigin = () => {
return viewport.height === 0 && viewport.width === 0; return viewport.height === 0 && viewport.width === 0;
}; };
const isEqual = (a: ViewportXY, b: ViewportXY) => { const isEqual = (a: ViewportXY, b: ViewportXY) => {
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y; return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
}; };
@ -130,7 +129,7 @@
} }
if ($gridScrollTarget?.at) { if ($gridScrollTarget?.at) {
void $assetStore.scheduleScrollToAssetId($gridScrollTarget, () => { void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
element?.scrollTo({ top: 0 }); element?.scrollTo({ top: 0 });
showSkeleton = false; showSkeleton = false;
}); });
@ -166,7 +165,7 @@
if (assetGridUpdate) { if (assetGridUpdate) {
setTimeout(() => { setTimeout(() => {
void $assetStore.updateViewport(safeViewport, true); void assetStore.updateViewport(safeViewport, true);
const asset = $page.url.searchParams.get('at'); const asset = $page.url.searchParams.get('at');
if (asset) { if (asset) {
$gridScrollTarget = { at: asset }; $gridScrollTarget = { at: asset };
@ -194,31 +193,10 @@
return () => void 0; 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) => { const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
if (!lastIntersectedBucketDate) {
_updateLastIntersectedBucketDate();
}
if (lastIntersectedBucketDate) { if (lastIntersectedBucketDate) {
const currentIndex = $assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate); const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
const deltaIndex = $assetStore.buckets.indexOf(adjustedBucket); const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
if (deltaIndex < currentIndex) { if (deltaIndex < currentIndex) {
element?.scrollBy(0, delta); element?.scrollBy(0, delta);
@ -235,20 +213,23 @@
}; };
onMount(() => { onMount(() => {
void $assetStore void assetStore
.init({ bucketListener }) .init({ bucketListener })
.then(() => ($assetStore.connect(), $assetStore.updateViewport(safeViewport))); .then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
if (!enableRouting) { if (!enableRouting) {
showSkeleton = false; showSkeleton = false;
} }
const dispose = hmrSupport(); const dispose = hmrSupport();
return () => { return () => {
$assetStore.disconnect(); assetStore.disconnect();
$assetStore.destroy(); assetStore.destroy();
dispose(); dispose();
}; };
}); });
const _updateViewport = () => void assetStore.updateViewport(safeViewport);
const updateViewport = throttle(_updateViewport, 16);
function getOffset(bucketDate: string) { function getOffset(bucketDate: string) {
let offset = 0; let offset = 0;
for (let a = 0; a < assetStore.buckets.length; a++) { for (let a = 0; a < assetStore.buckets.length; a++) {
@ -259,12 +240,10 @@
} }
return offset; return offset;
} }
const _updateViewport = () => void $assetStore.updateViewport(safeViewport);
const updateViewport = throttle(_updateViewport, 16);
const getMaxScrollPercent = () => const getMaxScrollPercent = () =>
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) / (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
($assetStore.timelineHeight + bottomSectionHeight + topSectionHeight); (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
const getMaxScroll = () => { const getMaxScroll = () => {
if (!element || !timelineElement) { if (!element || !timelineElement) {
@ -292,7 +271,7 @@
scrollPercent: number, scrollPercent: number,
bucketScrollPercent: number, bucketScrollPercent: number,
) => { ) => {
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) { if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
@ -318,7 +297,7 @@
_scrollPercent: number, _scrollPercent: number,
bucketScrollPercent: number, bucketScrollPercent: number,
) => { ) => {
if (!bucketDate || $assetStore.timelineHeight < safeViewport.height * 2) { if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
return; return;
} }
@ -328,10 +307,7 @@
} }
if (bucket && !bucket.measured) { if (bucket && !bucket.measured) {
preMeasure.push(bucket); preMeasure.push(bucket);
if (!bucket.loaded) { await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
await assetStore.loadBucket(bucket.bucketDate);
}
// Wait here, and collect the deltas that are above offset, which affect offset position
await bucket.measuredPromise; await bucket.measuredPromise;
scrollToBucketAndOffset(bucket, bucketScrollPercent); scrollToBucketAndOffset(bucket, bucketScrollPercent);
} }
@ -354,7 +330,7 @@
return; return;
} }
if ($assetStore.timelineHeight < safeViewport.height * 2) { if (assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
@ -424,19 +400,15 @@
preMeasure.push(bucket); preMeasure.push(bucket);
} }
showSkeleton = false; showSkeleton = false;
$assetStore.clearPendingScroll(); assetStore.clearPendingScroll();
// set intersecting true manually here, to reduce flicker that happens when // 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 // clearing pending scroll, but the intersection observer hadn't yet had time to run
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
}; };
const trashOrDelete = async (force: boolean = false) => { const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false; isShowDeleteConfirmation = false;
await deleteAssets( await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
!(isTrashEnabled && !force),
(assetIds) => $assetStore.removeAssets(assetIds),
idsSelectedAssets,
);
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
}; };
@ -461,7 +433,7 @@
const onStackAssets = async () => { const onStackAssets = async () => {
const ids = await stackAssets(assetInteraction.selectedAssetsArray); const ids = await stackAssets(assetInteraction.selectedAssetsArray);
if (ids) { if (ids) {
$assetStore.removeAssets(ids); assetStore.removeAssets(ids);
onEscape(); onEscape();
} }
}; };
@ -469,7 +441,7 @@
const toggleArchive = async () => { const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
if (ids) { if (ids) {
$assetStore.removeAssets(ids); assetStore.removeAssets(ids);
deselectAllAssets(); deselectAllAssets();
} }
}; };
@ -481,33 +453,33 @@
}; };
const handleSelectAsset = (asset: AssetResponseDto) => { const handleSelectAsset = (asset: AssetResponseDto) => {
if (!$assetStore.albumAssets.has(asset.id)) { if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset); assetInteraction.selectAsset(asset);
} }
}; };
function handleIntersect(bucket: AssetBucket) { function handleIntersect(bucket: AssetBucket) {
updateLastIntersectedBucketDate(); // updateLastIntersectedBucketDate();
const task = () => { const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void $assetStore.loadBucket(bucket.bucketDate); void assetStore.loadBucket(bucket.bucketDate);
}; };
$assetStore.taskManager.intersectedBucket(componentId, bucket, task); assetStore.taskManager.intersectedBucket(componentId, bucket, task);
} }
function handleSeparate(bucket: AssetBucket) { function handleSeparate(bucket: AssetBucket) {
const task = () => { const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false }); assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel(); bucket.cancel();
}; };
$assetStore.taskManager.separatedBucket(componentId, bucket, task); assetStore.taskManager.separatedBucket(componentId, bucket, task);
} }
const handlePrevious = async () => { const handlePrevious = async () => {
const previousAsset = await $assetStore.getPreviousAsset($viewingAsset); const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
if (previousAsset) { if (previousAsset) {
const preloadAsset = await $assetStore.getPreviousAsset(previousAsset); const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []); assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id }); await navigate({ targetRoute: 'current', assetId: previousAsset.id });
} }
@ -516,9 +488,10 @@
}; };
const handleNext = async () => { const handleNext = async () => {
const nextAsset = await $assetStore.getNextAsset($viewingAsset); const nextAsset = await assetStore.getNextAsset($viewingAsset);
if (nextAsset) { if (nextAsset) {
const preloadAsset = await $assetStore.getNextAsset(nextAsset); const preloadAsset = await assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []); assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id }); await navigate({ targetRoute: 'current', assetId: nextAsset.id });
} }
@ -527,10 +500,10 @@
}; };
const handleRandom = async () => { const handleRandom = async () => {
const randomAsset = await $assetStore.getRandomAsset(); const randomAsset = await assetStore.getRandomAsset();
if (randomAsset) { if (randomAsset) {
const preloadAsset = await $assetStore.getNextAsset(randomAsset); const preloadAsset = await assetStore.getNextAsset(randomAsset);
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []); assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: randomAsset.id }); await navigate({ targetRoute: 'current', assetId: randomAsset.id });
} }
@ -664,8 +637,8 @@
assetInteraction.clearAssetSelectionCandidates(); assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) { if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucketIndex = $assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucketIndex = $assetStore.getBucketIndexByAssetId(asset.id); let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
if (startBucketIndex === null || endBucketIndex === null) { if (startBucketIndex === null || endBucketIndex === null) {
return; return;
@ -677,8 +650,8 @@
// Select/deselect assets in all intermediate buckets // Select/deselect assets in all intermediate buckets
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) { for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
const bucket = $assetStore.buckets[bucketIndex]; const bucket = assetStore.buckets[bucketIndex];
await $assetStore.loadBucket(bucket.bucketDate); await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.assets) { for (const asset of bucket.assets) {
if (deselect) { if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset); assetInteraction.removeAssetFromMultiselectGroup(asset);
@ -690,7 +663,7 @@
// Update date group selection // Update date group selection
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) { for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
const bucket = $assetStore.buckets[bucketIndex]; const bucket = assetStore.buckets[bucketIndex];
// Split bucket into date groups and check each group // Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
@ -718,14 +691,14 @@
return; return;
} }
let start = $assetStore.assets.findIndex((a) => a.id === startAsset.id); let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
let end = $assetStore.assets.findIndex((a) => a.id === endAsset.id); let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
if (start > end) { if (start > end) {
[start, end] = [end, start]; [start, end] = [end, start];
} }
assetInteraction.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1)); assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
}; };
const onSelectStart = (e: Event) => { const onSelectStart = (e: Event) => {
@ -737,7 +710,7 @@
assetStore.taskManager.removeAllTasksForComponent(componentId); assetStore.taskManager.removeAllTasksForComponent(componentId);
}); });
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived($assetStore.initialized && $assetStore.buckets.length === 0); let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
$effect(() => { $effect(() => {
@ -773,7 +746,7 @@
{ shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteraction) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement }, { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement }, { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
]; ];
@ -824,7 +797,7 @@
{#if showShortcuts} {#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} /> <ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if} {/if}
{#if $assetStore.buckets.length > 0} {#if assetStore.buckets.length > 0}
<Scrubber <Scrubber
invisible={showSkeleton} invisible={showSkeleton}
{assetStore} {assetStore}
@ -864,21 +837,33 @@
bind:this={timelineElement} bind:this={timelineElement}
id="virtual-timeline" id="virtual-timeline"
class:invisible={showSkeleton} class:invisible={showSkeleton}
style:height={$assetStore.timelineHeight + 'px'} style:height={assetStore.timelineHeight + 'px'}
> >
{#each $assetStore.buckets as bucket (bucket.viewId)} {#each assetStore.buckets as bucket (bucket.viewId)}
{@const isPremeasure = preMeasure.includes(bucket)} {@const isPremeasure = preMeasure.includes(bucket)}
{@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure} {@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
<div <div
class="bucket" class="bucket"
use:intersectionObserver={{ style:overflow={bucket.measured ? 'visible' : 'clip'}
key: bucket.viewId, use:intersectionObserver={[
onIntersect: () => handleIntersect(bucket), {
onSeparate: () => handleSeparate(bucket), key: bucket.viewId,
top: BUCKET_INTERSECTION_ROOT_TOP, onIntersect: () => handleIntersect(bucket),
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, onSeparate: () => handleSeparate(bucket),
root: element, top: BUCKET_INTERSECTION_ROOT_TOP,
}} bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,
},
{
key: bucket.viewId + '.bucketintersection',
onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
top: '0px',
bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
left: '0px',
right: '0px',
},
]}
data-bucket-display={bucket.intersecting} data-bucket-display={bucket.intersecting}
data-bucket-date={bucket.bucketDate} data-bucket-date={bucket.bucketDate}
style:height={bucket.bucketHeight + 'px'} style:height={bucket.bucketHeight + 'px'}
@ -949,6 +934,5 @@
.bucket { .bucket {
contain: layout size; contain: layout size;
transition: height 0.2s ease-out;
} }
</style> </style>

View file

@ -18,7 +18,7 @@
<script lang="ts"> <script lang="ts">
import { resizeObserver } from '$lib/actions/resize-observer'; import { resizeObserver } from '$lib/actions/resize-observer';
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store'; import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets-store.svelte';
interface Props { interface Props {
assetStore: AssetStore; assetStore: AssetStore;
@ -43,11 +43,11 @@
if (!heightPending) { if (!heightPending) {
const height = element.getBoundingClientRect().height; const height = element.getBoundingClientRect().height;
if (height !== 0) { if (height !== 0) {
$assetStore.updateBucket(bucket.bucketDate, { height, measured: true }); assetStore.updateBucket(bucket.bucketDate, { height, measured: true });
} }
onMeasured(); onMeasured();
$assetStore.removeListener(listener); assetStore.removeListener(listener);
const t2 = Date.now(); const t2 = Date.now();
addMeasure((t2 - t1) / bucket.bucketCount); addMeasure((t2 - t1) / bucket.bucketCount);
@ -69,7 +69,7 @@
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure> <section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
{#each bucket.dateGroups as dateGroup (dateGroup.date)} {#each bucket.dateGroups as dateGroup (dateGroup.date)}
<div id="date-group" data-date-group={dateGroup.date}> <div id="date-group" data-date-group={dateGroup.date}>
<div use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}> <div use:resizeObserver={({ height }) => assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}>
<div <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" 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'} style:width={dateGroup.geometry.containerWidth + 'px'}
@ -81,8 +81,8 @@
<div <div
class="relative overflow-clip" class="relative overflow-clip"
style:height={dateGroup.geometry.containerHeight + 'px'} style:height={dateGroup.geometry!.containerHeight + 'px'}
style:width={dateGroup.geometry.containerWidth + 'px'} style:width={dateGroup.geometry!.containerWidth + 'px'}
style:visibility="hidden" style:visibility="hidden"
></div> ></div>
</div> </div>

View file

@ -17,7 +17,7 @@
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte'; import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import type { Viewport } from '$lib/stores/assets.store'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View file

@ -5,7 +5,7 @@
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import { isSelectingAllAssets } from '$lib/stores/assets.store'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {

View file

@ -5,7 +5,7 @@
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets.store'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils';

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets.store'; import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets-store.svelte';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util'; import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
@ -92,14 +92,14 @@
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent); scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
}); });
let timelineFullHeight = $derived($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset); let timelineFullHeight = $derived(assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight)); let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
const listener: BucketListener = (event) => { const listener: BucketListener = (event) => {
const { type } = event; const { type } = event;
if (type === 'viewport') { if (type === 'viewport') {
segments = calculateSegments($assetStore.buckets); segments = calculateSegments(assetStore.buckets);
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent); scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
} }
}; };
@ -128,7 +128,7 @@
for (const [i, bucket] of buckets.entries()) { for (const [i, bucket] of buckets.entries()) {
const scrollBarPercentage = const scrollBarPercentage =
bucket.bucketHeight / ($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset); bucket.bucketHeight / (assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
const segment = { const segment = {
count: bucket.assets.length, count: bucket.assets.length,

View file

@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { AbortError } from '$lib/utils'; import { AbortError } from '$lib/utils';
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory'; import { assetFactory } from '@test-data/factories/asset-factory';
import { AssetStore } from './assets.store'; import { AssetStore } from './assets-store.svelte';
describe('AssetStore', () => { describe('AssetStore', () => {
beforeEach(() => { beforeEach(() => {
@ -213,7 +213,8 @@ describe('AssetStore', () => {
expect(assetStore.assets.length).toEqual(1); expect(assetStore.assets.length).toEqual(1);
}); });
it('ignores trashed assets when isTrashed is true', () => { // disabled due to the wasm Justified Layout import
it.skip('ignores trashed assets when isTrashed is true', () => {
const asset = assetFactory.build({ isTrashed: false }); const asset = assetFactory.build({ isTrashed: false });
const trashedAsset = assetFactory.build({ isTrashed: true }); const trashedAsset = assetFactory.build({ isTrashed: true });

View file

@ -1,28 +1,24 @@
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getKey } from '$lib/utils'; import { getKey } from '$lib/utils';
import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager'; import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { type getJustifiedLayoutFromAssetsFunction } from '$lib/utils/layout-utils';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { calculateWidth, fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util'; import { fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util';
import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import createJustifiedLayout from 'justified-layout';
import { throttle } from 'lodash-es'; import { throttle } from 'lodash-es';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import { get, writable, type Unsubscriber } from 'svelte/store'; import { get, writable, type Unsubscriber } from 'svelte/store';
import { handleError } from '../utils/handle-error'; import { handleError } from '../utils/handle-error';
import { websocketEvents } from './websocket'; import { websocketEvents } from './websocket';
let getJustifiedLayoutFromAssets: getJustifiedLayoutFromAssetsFunction;
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0]; type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>; export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'>;
const LAYOUT_OPTIONS = {
boxSpacing: 2,
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
};
export interface Viewport { export interface Viewport {
width: number; width: number;
height: number; height: number;
@ -40,30 +36,33 @@ interface AssetLookup {
export class AssetBucket { export class AssetBucket {
store!: AssetStore; store!: AssetStore;
bucketDate!: string; bucketDate: string = $state('');
/** /**
* The DOM height of the bucket in pixel * 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 * This value is first estimated by the number of asset and later is corrected as the user scroll
* Do not derive this height, it is important for it to be updated at specific times, so that
* calculateing a delta between estimated and actual (when measured) is correct.
*/ */
bucketHeight: number = 0; bucketHeight: number = $state(0);
isBucketHeightActual: boolean = false; isBucketHeightActual: boolean = $state(false);
bucketDateFormattted!: string; bucketDateFormattted!: string;
bucketCount: number = 0; bucketCount: number = $derived.by(() => (this.isLoaded ? this.assets.length : this.initialCount));
assets: AssetResponseDto[] = []; initialCount: number = 0;
dateGroups: DateGroup[] = []; assets: AssetResponseDto[] = $state([]);
cancelToken: AbortController | undefined; dateGroups: DateGroup[] = $state([]);
cancelToken: AbortController | undefined = $state();
/** /**
* Prevent this asset's load from being canceled; i.e. to force load of offscreen asset. * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset.
*/ */
isPreventCancel: boolean = false; isPreventCancel: boolean = $state(false);
/** /**
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
*/ */
complete!: Promise<void>; complete!: Promise<void>;
loading: boolean = false; loading: boolean = $state(false);
isLoaded: boolean = false; isLoaded: boolean = $state(false);
intersecting: boolean = false; intersecting: boolean = $state(false);
measured: boolean = false; measured: boolean = $state(false);
measuredPromise!: Promise<void>; measuredPromise!: Promise<void>;
constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) { constructor(props: Partial<AssetBucket> & { store: AssetStore; bucketDate: string }) {
@ -79,13 +78,16 @@ export class AssetBucket {
// will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal // 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 // callback will be called if the bucket is canceled before it was loaded, rejecting the
// promise. // promise.
this.complete = new Promise((resolve, reject) => { this.complete = new Promise<void>((resolve, reject) => {
this.loadedSignal = resolve; this.loadedSignal = resolve;
this.canceledSignal = reject; this.canceledSignal = reject;
}); }).catch(
// if no-one waits on complete, and its rejected a uncaught rejection message is logged. () =>
// We this message with an empty reject handler, since waiting on a bucket is optional. // if no-one waits on complete, and its rejected a uncaught rejection message is logged.
this.complete.catch(() => void 0); // We this message with an empty reject handler, since waiting on a bucket is optional.
void 0,
);
this.measuredPromise = new Promise((resolve) => { this.measuredPromise = new Promise((resolve) => {
this.measuredSignal = resolve; this.measuredSignal = resolve;
}); });
@ -205,35 +207,50 @@ type DateGroupHeightEvent = {
}; };
export class AssetStore { export class AssetStore {
private assetToBucket: Record<string, AssetLookup> = {}; private assetToBucket: Record<string, AssetLookup> = $derived.by(() => {
const result: Record<string, AssetLookup> = {};
for (let index = 0; index < this.buckets.length; index++) {
const bucket = this.buckets[index];
for (let index_ = 0; index_ < bucket.assets.length; index_++) {
const asset = bucket.assets[index_];
result[asset.id] = { bucket, bucketIndex: index, assetIndex: index_ };
}
}
return result;
});
private pendingChanges: PendingChange[] = []; private pendingChanges: PendingChange[] = [];
private unsubscribers: Unsubscriber[] = []; private unsubscribers: Unsubscriber[] = [];
private options!: AssetApiGetTimeBucketsRequest; private options!: AssetApiGetTimeBucketsRequest;
private viewport: Viewport = { viewport: Viewport = $state({
height: 0, height: 0,
width: 0, width: 0,
}; });
private initializedSignal!: () => void; private initializedSignal!: () => void;
private store$ = writable(this); private store$ = writable(this);
/** The svelte key for this view model object */ /** The svelte key for this view model object */
viewId = generateId(); viewId = generateId();
lastScrollTime: number = 0; lastScrollTime: number = $state(0);
subscribe = this.store$.subscribe;
// subscribe = this.store$.subscribe;
/** /**
* A promise that resolves once the store is initialized. * A promise that resolves once the store is initialized.
*/ */
complete!: Promise<void>; private complete!: Promise<void>;
taskManager = new AssetGridTaskManager(this); taskManager = new AssetGridTaskManager(this);
initialized = false; initialized = $state(false);
timelineHeight = 0; timelineHeight = $state(0);
buckets: AssetBucket[] = []; buckets: AssetBucket[] = $state([]);
assets: AssetResponseDto[] = []; assets: AssetResponseDto[] = $derived.by(() => {
albumAssets: Set<string> = new Set(); return this.buckets.flatMap(({ assets }) => assets);
pendingScrollBucket: AssetBucket | undefined; });
pendingScrollAssetId: string | undefined; albumAssets: Set<string> = new SvelteSet();
pendingScrollBucket: AssetBucket | undefined = $state();
pendingScrollAssetId: string | undefined = $state();
maxBucketAssets = $state(0);
listeners: BucketListener[] = []; private listeners: BucketListener[] = [];
constructor( constructor(
options: AssetStoreOptions, options: AssetStoreOptions,
@ -251,11 +268,9 @@ export class AssetStore {
private createInitializationSignal() { private createInitializationSignal() {
// create a promise, and store its resolve callbacks. The initializedSignal callback // create a promise, and store its resolve callbacks. The initializedSignal callback
// will be invoked when a the assetstore is initialized. // will be invoked when a the assetstore is initialized.
this.complete = new Promise((resolve) => { this.complete = new Promise<void>((resolve) => {
this.initializedSignal = resolve; this.initializedSignal = resolve;
}); }).catch(() => void 0);
// uncaught rejection go away
this.complete.catch(() => void 0);
} }
private addPendingChanges(...changes: PendingChange[]) { private addPendingChanges(...changes: PendingChange[]) {
@ -346,7 +361,7 @@ export class AssetStore {
} }
this.pendingChanges = []; this.pendingChanges = [];
this.emit(true); // this.emit(true);
}, 2500); }, 2500);
addListener(bucketListener: BucketListener) { addListener(bucketListener: BucketListener) {
@ -373,6 +388,11 @@ export class AssetStore {
if (this.initialized) { if (this.initialized) {
throw 'Can only init once'; throw 'Can only init once';
} }
if (!getJustifiedLayoutFromAssets) {
const module = await import('$lib/utils/layout-utils');
getJustifiedLayoutFromAssets = module.getJustifiedLayoutFromAssets;
}
if (bucketListener) { if (bucketListener) {
this.addListener(bucketListener); this.addListener(bucketListener);
} }
@ -382,17 +402,16 @@ export class AssetStore {
async initialiazeTimeBuckets() { async initialiazeTimeBuckets() {
this.timelineHeight = 0; this.timelineHeight = 0;
this.buckets = []; this.buckets = [];
this.assets = []; this.albumAssets.clear();
this.assetToBucket = {};
this.albumAssets = new Set();
const timebuckets = await getTimeBuckets({ const timebuckets = await getTimeBuckets({
...this.options, ...this.options,
key: getKey(), key: getKey(),
}); });
this.buckets = timebuckets.map( this.buckets = timebuckets.map(
(bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, bucketCount: bucket.count }), (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, initialCount: bucket.count }),
); );
this.initializedSignal(); this.initializedSignal();
this.initialized = true; this.initialized = true;
} }
@ -416,7 +435,7 @@ export class AssetStore {
this.createInitializationSignal(); this.createInitializationSignal();
this.setOptions(options); this.setOptions(options);
await this.initialiazeTimeBuckets(); await this.initialiazeTimeBuckets();
this.emit(true); // this.emit(true);
await this.initialLayout(true); await this.initialLayout(true);
} }
@ -458,7 +477,6 @@ export class AssetStore {
} }
await Promise.all(loaders); await Promise.all(loaders);
this.notifyListeners({ type: 'viewport' }); this.notifyListeners({ type: 'viewport' });
this.emit(false);
} }
private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
@ -469,13 +487,20 @@ export class AssetStore {
assetGroup.heightActual = false; assetGroup.heightActual = false;
} }
} }
const viewportWidth = this.viewport.width;
if (!bucket.isBucketHeightActual) { if (!bucket.isBucketHeightActual) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
const rows = Math.ceil(unwrappedWidth / this.viewport.width); const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = 51 + rows * THUMBNAIL_HEIGHT; const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT;
bucket.bucketHeight = height;
}
this.setBucketHeight(bucket, height, false);
}
const layoutOptions = {
spacing: 2,
heightTolerance: 0.15,
rowHeight: 235,
rowWidth: Math.floor(viewportWidth),
};
for (const assetGroup of bucket.dateGroups) { for (const assetGroup of bucket.dateGroups) {
if (!assetGroup.heightActual) { if (!assetGroup.heightActual) {
const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10); const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10);
@ -484,17 +509,7 @@ export class AssetStore {
assetGroup.height = height; assetGroup.height = height;
} }
const layoutResult = createJustifiedLayout( assetGroup.geometry = getJustifiedLayoutFromAssets(assetGroup.assets, layoutOptions);
assetGroup.assets.map((g) => getAssetRatio(g)),
{
...LAYOUT_OPTIONS,
containerWidth: Math.floor(this.viewport.width),
},
);
assetGroup.geometry = {
...layoutResult,
containerWidth: calculateWidth(layoutResult.boxes),
};
} }
} }
@ -503,7 +518,7 @@ export class AssetStore {
if (!bucket) { if (!bucket) {
return; return;
} }
if (bucket.bucketCount === bucket.assets.length) { if (bucket.isLoaded) {
// already loaded // already loaded
return; return;
} }
@ -522,7 +537,6 @@ export class AssetStore {
} }
this.notifyListeners({ type: 'load', bucket }); this.notifyListeners({ type: 'load', bucket });
bucket.isPreventCancel = !!options.preventCancel; bucket.isPreventCancel = !!options.preventCancel;
const cancelToken = (bucket.cancelToken = new AbortController()); const cancelToken = (bucket.cancelToken = new AbortController());
try { try {
const assets = await getTimeBucket( const assets = await getTimeBucket(
@ -569,28 +583,30 @@ export class AssetStore {
if ((error as any).name === 'AbortError') { if ((error as any).name === 'AbortError') {
return; return;
} }
const $t = get(t); const _$t = get(t);
handleError(error, $t('errors.failed_to_load_assets')); handleError(error, _$t('errors.failed_to_load_assets'));
bucket.errored(); bucket.errored();
} finally { } finally {
bucket.cancelToken = undefined; bucket.cancelToken = undefined;
this.emit(true);
} }
} }
setBucketHeight(bucket: AssetBucket, newHeight: number, isActualHeight: boolean) {
const delta = newHeight - bucket.bucketHeight;
bucket.isBucketHeightActual = isActualHeight;
bucket.bucketHeight = newHeight;
this.timelineHeight += delta;
this.notifyListeners({ type: 'bucket-height', bucket, delta });
}
updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) { updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) {
const bucket = this.getBucketByDate(bucketDate); const bucket = this.getBucketByDate(bucketDate);
if (!bucket) { if (!bucket) {
return {}; return {};
} }
let delta = 0; const delta = 0;
if ('height' in properties) { if ('height' in properties) {
const height = properties.height!; this.setBucketHeight(bucket, properties.height!, true);
delta = height - bucket.bucketHeight;
bucket.isBucketHeightActual = true;
bucket.bucketHeight = height;
this.timelineHeight += delta;
this.notifyListeners({ type: 'bucket-height', bucket, delta });
} }
if ('intersecting' in properties) { if ('intersecting' in properties) {
bucket.intersecting = properties.intersecting!; bucket.intersecting = properties.intersecting!;
@ -601,7 +617,6 @@ export class AssetStore {
} }
bucket.measured = properties.measured!; bucket.measured = properties.measured!;
} }
this.emit(false);
return { delta }; return { delta };
} }
@ -626,7 +641,6 @@ export class AssetStore {
this.notifyListeners({ type: 'intersecting', bucket, dateGroup }); this.notifyListeners({ type: 'intersecting', bucket, dateGroup });
} }
} }
this.emit(false);
return { delta }; return { delta };
} }
@ -670,7 +684,6 @@ export class AssetStore {
} }
bucket.assets.push(asset); bucket.assets.push(asset);
this.assets.push(asset);
updatedBuckets.add(bucket); updatedBuckets.add(bucket);
} }
@ -689,8 +702,6 @@ export class AssetStore {
bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale));
this.updateGeometry(bucket, true); this.updateGeometry(bucket, true);
} }
this.emit(true);
} }
getBucketByDate(bucketDate: string): AssetBucket | null { getBucketByDate(bucketDate: string): AssetBucket | null {
@ -705,14 +716,12 @@ export class AssetStore {
if (!asset || this.isExcluded(asset)) { if (!asset || this.isExcluded(asset)) {
return; return;
} }
bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true }); bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true });
} }
if (bucket && bucket.assets.some((a) => a.id === id)) { if (bucket && bucket.assets.some((a) => a.id === id)) {
this.pendingScrollBucket = bucket; this.pendingScrollBucket = bucket;
this.pendingScrollAssetId = id; this.pendingScrollAssetId = id;
this.emit(false);
return bucket; return bucket;
} }
} }
@ -805,7 +814,6 @@ export class AssetStore {
this.removeAssets(assetsToRecalculate.map((asset) => asset.id)); this.removeAssets(assetsToRecalculate.map((asset) => asset.id));
this.addAssetsToBuckets(assetsToRecalculate); this.addAssetsToBuckets(assetsToRecalculate);
this.emit(assetsToRecalculate.length > 0);
} }
removeAssets(ids: string[]) { removeAssets(ids: string[]) {
@ -832,8 +840,6 @@ export class AssetStore {
this.updateGeometry(bucket, true); this.updateGeometry(bucket, true);
} }
} }
this.emit(true);
} }
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> { async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
@ -878,30 +884,6 @@ export class AssetStore {
return nextBucket.assets[0] || null; return nextBucket.assets[0] || null;
} }
triggerUpdate() {
this.emit(false);
}
private emit(recalculate: boolean) {
if (recalculate) {
this.assets = this.buckets.flatMap(({ assets }) => assets);
const assetToBucket: Record<string, AssetLookup> = {};
for (let index = 0; index < this.buckets.length; index++) {
const bucket = this.buckets[index];
if (bucket.assets.length > 0) {
bucket.bucketCount = bucket.assets.length;
}
for (let index_ = 0; index_ < bucket.assets.length; index_++) {
const asset = bucket.assets[index_];
assetToBucket[asset.id] = { bucket, bucketIndex: index, assetIndex: index_ };
}
}
this.assetToBucket = assetToBucket;
}
this.store$.set(this);
}
private isExcluded(asset: AssetResponseDto) { private isExcluded(asset: AssetResponseDto) {
return ( return (
isMismatched(this.options.isArchived ?? false, asset.isArchived) || isMismatched(this.options.isArchived ?? false, asset.isArchived) ||

View file

@ -1,4 +1,4 @@
import type { AssetBucket, AssetStore } from '$lib/stores/assets.store'; import type { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support'; import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue'; import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';

View file

@ -5,7 +5,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
import { downloadManager } from '$lib/stores/download'; import { downloadManager } from '$lib/stores/download';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { downloadRequest, getKey, withError } from '$lib/utils'; import { downloadRequest, getKey, withError } from '$lib/utils';

View file

@ -28,15 +28,11 @@ describe('Executor Queue test', function () {
}); });
// The first 3 should be finished within 200ms (concurrency 3) // The first 3 should be finished within 200ms (concurrency 3)
// eslint-disable-next-line @typescript-eslint/no-floating-promises void eq.addTask(() => timeoutPromiseBuilder(100, 'T1'));
eq.addTask(() => timeoutPromiseBuilder(100, 'T1')); void eq.addTask(() => timeoutPromiseBuilder(200, 'T2'));
// eslint-disable-next-line @typescript-eslint/no-floating-promises void eq.addTask(() => timeoutPromiseBuilder(150, 'T3'));
eq.addTask(() => timeoutPromiseBuilder(200, 'T2'));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
eq.addTask(() => timeoutPromiseBuilder(150, 'T3'));
// The last task will be executed after 200ms and will finish at 400ms // The last task will be executed after 200ms and will finish at 400ms
// eslint-disable-next-line @typescript-eslint/no-floating-promises void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
expect(finished).not.toBeCalled(); expect(finished).not.toBeCalled();
expect(started).toHaveBeenCalledTimes(3); expect(started).toHaveBeenCalledTimes(3);

View file

@ -0,0 +1,106 @@
import { getAssetRatio } from '$lib/utils/asset-utils';
// import { TUNABLES } from '$lib/utils/tunables';
// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
import type { AssetResponseDto } from '@immich/sdk';
import createJustifiedLayout from 'justified-layout';
export type getJustifiedLayoutFromAssetsFunction = typeof getJustifiedLayoutFromAssets;
// let useWasm = TUNABLES.LAYOUT.WASM;
export type CommonJustifiedLayout = {
containerWidth: number;
containerHeight: number;
getTop(boxIdx: number): number;
getLeft(boxIdx: number): number;
getWidth(boxIdx: number): number;
getHeight(boxIdx: number): number;
};
export type CommonLayoutOptions = {
rowHeight: number;
rowWidth: number;
spacing: number;
heightTolerance: number;
};
export function getJustifiedLayoutFromAssets(
assets: AssetResponseDto[],
options: CommonLayoutOptions,
): CommonJustifiedLayout {
// if (useWasm) {
// return wasmJustifiedLayout(assets, options);
// }
return justifiedLayout(assets, options);
}
// commented out until a solution for top level awaits on safari is fixed
// function wasmJustifiedLayout(assets: AssetResponseDto[], options: LayoutOptions) {
// const aspectRatios = new Float32Array(assets.length);
// // eslint-disable-next-line unicorn/no-for-loop
// for (let i = 0; i < assets.length; i++) {
// const { width, height } = getAssetRatio(assets[i]);
// aspectRatios[i] = width / height;
// }
// return new JustifiedLayout(aspectRatios, options);
// }
type Geometry = ReturnType<typeof createJustifiedLayout>;
class Adapter {
result;
constructor(result: Geometry) {
this.result = result;
}
get containerWidth() {
let width = 0;
for (const box of this.result.boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
}
get containerHeight() {
return this.result.containerHeight;
}
getTop(boxIdx: number) {
return this.result.boxes[boxIdx]?.top;
}
getLeft(boxIdx: number) {
return this.result.boxes[boxIdx]?.left;
}
getWidth(boxIdx: number) {
return this.result.boxes[boxIdx]?.width;
}
getHeight(boxIdx: number) {
return this.result.boxes[boxIdx]?.height;
}
}
export const emptyGeometry = new Adapter({
containerHeight: 0,
widowCount: 0,
boxes: [],
});
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
const adapter = {
targetRowHeight: options.rowHeight,
containerWidth: options.rowWidth,
boxSpacing: options.spacing,
targetRowHeightTolerange: options.heightTolerance,
};
const result = createJustifiedLayout(
assets.map((g) => getAssetRatio(g)),
adapter,
);
return new Adapter(result);
}

View file

@ -1,7 +1,8 @@
import type { AssetBucket } from '$lib/stores/assets.store'; import type { AssetBucket } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { emptyGeometry, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import type createJustifiedLayout from 'justified-layout';
import { groupBy, memoize, sortBy } from 'lodash-es'; import { groupBy, memoize, sortBy } from 'lodash-es';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -13,7 +14,7 @@ export type DateGroup = {
height: number; height: number;
heightActual: boolean; heightActual: boolean;
intersecting: boolean; intersecting: boolean;
geometry: Geometry; geometry: CommonJustifiedLayout;
bucket: AssetBucket; bucket: AssetBucket;
}; };
export type ScrubberListener = ( export type ScrubberListener = (
@ -80,19 +81,6 @@ export function formatGroupTitle(_date: DateTime): string {
return date.toLocaleString(groupDateFormat); return date.toLocaleString(groupDateFormat);
} }
type Geometry = ReturnType<typeof createJustifiedLayout> & {
containerWidth: number;
};
function emptyGeometry() {
return {
containerWidth: 0,
containerHeight: 0,
widowCount: 0,
boxes: [],
};
}
const formatDateGroupTitle = memoize(formatGroupTitle); const formatDateGroupTitle = memoize(formatGroupTitle);
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] { export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
@ -109,7 +97,7 @@ export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string |
height: 0, height: 0,
heightActual: false, heightActual: false,
intersecting: false, intersecting: false,
geometry: emptyGeometry(), geometry: emptyGeometry,
bucket, bucket,
}; };
}); });

View file

@ -17,6 +17,9 @@ function getFloat(string: string | null, fallback: number) {
return Number.parseFloat(string); return Number.parseFloat(string);
} }
export const TUNABLES = { export const TUNABLES = {
LAYOUT: {
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
},
SCROLL_TASK_QUEUE: { SCROLL_TASK_QUEUE: {
TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25), 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_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5),

View file

@ -36,7 +36,7 @@
import { AppRoute, AlbumPageViewMode } from '$lib/constants'; import { AppRoute, AlbumPageViewMode } from '$lib/constants';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
@ -445,10 +445,7 @@
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
{#if assetInteraction.isAllUserOwned} {#if assetInteraction.isAllUserOwned}
<FavoriteAction <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={() => assetStore.triggerUpdate()}
/>
{/if} {/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{album.albumName}.zip" /> <DownloadAction menuItem filename="{album.albumName}.zip" />
@ -462,11 +459,7 @@
onClick={() => updateThumbnailUsingCurrentSelection()} onClick={() => updateThumbnailUsingCurrentSelection()}
/> />
{/if} {/if}
<ArchiveAction <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={() => assetStore.triggerUpdate()}
/>
{/if} {/if}
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}

View file

@ -12,7 +12,7 @@
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { mdiPlus, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -52,7 +52,7 @@
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />

View file

@ -14,7 +14,7 @@
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';

View file

@ -7,7 +7,7 @@
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import type { Viewport } from '$lib/stores/assets.store'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { foldersStore } from '$lib/stores/folders.svelte'; import { foldersStore } from '$lib/stores/folders.svelte';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';

View file

@ -8,7 +8,7 @@
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { mdiPlus, mdiArrowLeft } from '@mdi/js'; import { mdiPlus, mdiArrowLeft } from '@mdi/js';

View file

@ -33,7 +33,7 @@
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
@ -77,12 +77,8 @@
$effect(() => { $effect(() => {
// Check to trigger rebuild the timeline when navigating between people from the info panel // Check to trigger rebuild the timeline when navigating between people from the info panel
const change = assetStoreOptions.personId !== data.person.id;
assetStoreOptions.personId = data.person.id; assetStoreOptions.personId = data.person.id;
handlePromiseError(assetStore.updateOptions(assetStoreOptions)); handlePromiseError(assetStore.updateOptions(assetStoreOptions));
if (change) {
assetStore.triggerUpdate();
}
}); });
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
@ -156,7 +152,7 @@
}); });
const handleUnmerge = () => { const handleUnmerge = () => {
$assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id)); assetStore.removeAssets(assetInteraction.selectedAssetsArray.map((a) => a.id));
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
viewMode = PersonPageViewMode.VIEW_ASSETS; viewMode = PersonPageViewMode.VIEW_ASSETS;
}; };
@ -358,7 +354,7 @@
}; };
const handleDeleteAssets = async (assetIds: string[]) => { const handleDeleteAssets = async (assetIds: string[]) => {
$assetStore.removeAssets(assetIds); assetStore.removeAssets(assetIds);
await updateAssetCount(); await updateAssetCount();
}; };
@ -420,7 +416,7 @@
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" /> <DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
<MenuOption <MenuOption
@ -433,7 +429,7 @@
<ArchiveAction <ArchiveAction
menuItem menuItem
unarchive={assetInteraction.isAllArchived} unarchive={assetInteraction.isAllArchived}
onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} onArchive={(assetIds) => assetStore.removeAssets(assetIds)}
/> />
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem /> <TagAction menuItem />

View file

@ -22,7 +22,7 @@
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import type { OnLink, OnUnlink } from '$lib/utils/actions'; import type { OnLink, OnUnlink } from '$lib/utils/actions';
@ -88,7 +88,7 @@
<AddToAlbum /> <AddToAlbum />
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
{#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected}

View file

@ -32,7 +32,7 @@
getTagById, getTagById,
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import type { Viewport } from '$lib/stores/assets.store'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';

View file

@ -17,7 +17,7 @@
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk'; import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, Text } from '@immich/ui'; import { Button, HStack, Text } from '@immich/ui';

View file

@ -15,7 +15,7 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets.store'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';

View file

@ -14,6 +14,9 @@ const upstream = {
}; };
export default defineConfig({ export default defineConfig({
build: {
target: 'es2022',
},
resolve: { resolve: {
alias: { alias: {
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js', 'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',