Compare commits

...

3 Commits

Author SHA1 Message Date
Min Idzelis c1e4b565ab refactor(web): replace per-asset viewport proximity with day-tier active indices
Binary search on asset positions replaces per-ViewerAsset $derived
proximity tracking. Reactive churn during scroll reduces from O(N)
per-asset deriveds to O(log N) per-day binary search.

Change-Id: Ib4bdaec5df4801d1347f41bbabd607956a6a6964
2026-05-24 22:18:22 +00:00
Ben Beckford 9733fa4872 fix(web): timeline stuttering with many assets in 1 day (#28509)
* fix(web): timeline stuttering with many assets in 1 day

* cache isInOrNearViewport per day

* skip inOrNearViewport check on first run
2026-05-24 16:03:46 -05:00
Alex 3b34c53092 feat: command for user pages (#28554) 2026-05-24 16:03:12 -05:00
8 changed files with 218 additions and 46 deletions
+145 -1
View File
@@ -1,17 +1,35 @@
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
import {
mdiAccountMultipleOutline,
mdiAccountOutline,
mdiArchiveArrowDownOutline,
mdiBookshelf,
mdiCog,
mdiContentDuplicate,
mdiCrosshairsGps,
mdiFolderOutline,
mdiHeartOutline,
mdiImageAlbum,
mdiImageMultipleOutline,
mdiImageSizeSelectLarge,
mdiKeyboard,
mdiLink,
mdiLockOutline,
mdiMagnify,
mdiMapOutline,
mdiServer,
mdiStateMachine,
mdiSync,
mdiTagMultipleOutline,
mdiThemeLightDark,
mdiToolboxOutline,
mdiTrashCanOutline,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { copyToClipboard } from '$lib/utils';
@@ -49,7 +67,133 @@ export const getPagesProvider = ($t: MessageFormatter) => {
},
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
return defaultProvider({ name: $t('page'), actions: adminPages });
const userPages: ActionItem[] = [
{
title: $t('photos'),
icon: mdiImageMultipleOutline,
onAction: () => goto(Route.photos()),
},
{
title: $t('explore'),
icon: mdiMagnify,
onAction: () => goto(Route.explore()),
$if: () => authManager.authenticated && featureFlagsManager.value.search,
},
{
title: $t('map'),
icon: mdiMapOutline,
onAction: () => goto(Route.map()),
$if: () => authManager.authenticated && featureFlagsManager.value.map,
},
{
title: $t('people'),
description: $t('people_feature_description'),
icon: mdiAccountOutline,
onAction: () => goto(Route.people()),
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
},
{
title: $t('shared_links'),
icon: mdiLink,
onAction: () => goto(Route.sharedLinks()),
$if: () => authManager.authenticated && authManager.preferences.sharedLinks.enabled,
},
{
title: $t('recently_added'),
icon: mdiMagnify,
onAction: () => goto(Route.recentlyAdded()),
$if: () => authManager.authenticated,
},
{
title: $t('sharing'),
icon: mdiAccountMultipleOutline,
onAction: () => goto(Route.sharing()),
$if: () => authManager.authenticated,
},
{
title: $t('favorites'),
icon: mdiHeartOutline,
onAction: () => goto(Route.favorites()),
$if: () => authManager.authenticated,
},
{
title: $t('albums'),
description: $t('albums_feature_description'),
icon: mdiImageAlbum,
onAction: () => goto(Route.albums()),
$if: () => authManager.authenticated,
},
{
title: $t('tags'),
description: $t('tag_feature_description'),
icon: mdiTagMultipleOutline,
onAction: () => goto(Route.tags()),
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
},
{
title: $t('folders'),
description: $t('folders_feature_description'),
icon: mdiFolderOutline,
onAction: () => goto(Route.folders()),
$if: () => authManager.authenticated && authManager.preferences.folders.enabled,
},
{
title: $t('utilities'),
icon: mdiToolboxOutline,
onAction: () => goto(Route.utilities()),
$if: () => authManager.authenticated,
},
{
title: $t('archive'),
icon: mdiArchiveArrowDownOutline,
onAction: () => goto(Route.archive()),
$if: () => authManager.authenticated,
},
{
title: $t('locked_folder'),
icon: mdiLockOutline,
onAction: () => goto(Route.locked()),
$if: () => authManager.authenticated,
},
{
title: $t('trash'),
icon: mdiTrashCanOutline,
onAction: () => goto(Route.trash()),
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
},
{
title: $t('admin.user_settings'),
icon: mdiCog,
onAction: () => goto(Route.userSettings()),
$if: () => authManager.authenticated,
},
].map((route) => ({ $if: () => authManager.authenticated, ...route }));
const utilityPages: ActionItem[] = [
{
title: $t('review_duplicates'),
icon: mdiContentDuplicate,
onAction: () => goto(Route.duplicatesUtility()),
},
{
title: $t('review_large_files'),
icon: mdiImageSizeSelectLarge,
onAction: () => goto(Route.largeFileUtility()),
},
{
title: $t('manage_geolocation'),
icon: mdiCrosshairsGps,
onAction: () => goto(Route.geolocationUtility()),
},
{
title: $t('workflows'),
icon: mdiStateMachine,
onAction: () => goto(Route.workflows()),
},
].map((route) => ({ ...route, $if: () => authManager.authenticated }));
return defaultProvider({ name: $t('page'), actions: [...userPages, ...utilityPages, ...adminPages] });
};
const getMyImmichLink = () => {
@@ -1,6 +1,5 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
@@ -13,6 +12,8 @@
type Props = {
viewerAssets: ViewerAsset[];
firstActiveIndex: number;
lastActiveIndex: number;
width: number;
height: number;
manager: VirtualScrollManager;
@@ -27,15 +28,27 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const {
viewerAssets,
firstActiveIndex,
lastActiveIndex,
width,
height,
manager,
thumbnail,
customThumbnailLayout,
}: Props = $props();
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const visibleViewerAssets = $derived(
firstActiveIndex === -1 ? [] : viewerAssets.slice(firstActiveIndex, lastActiveIndex + 1),
);
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{#each visibleViewerAssets as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
@@ -101,6 +101,8 @@
<AssetLayout
{manager}
viewerAssets={timelineDay.viewerAssets}
firstActiveIndex={timelineDay.firstActiveIndex}
lastActiveIndex={timelineDay.lastActiveIndex}
height={timelineDay.height}
width={timelineDay.width}
{customThumbnailLayout}
@@ -53,17 +53,3 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
timelineManager.clearDeferredLayout(month);
}
}
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
@@ -1,12 +1,31 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
let lo = 0;
let hi = assets.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (key(assets[mid].position!) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export class TimelineDay {
readonly timelineMonth: TimelineMonth;
readonly index: number;
@@ -17,7 +36,11 @@ export class TimelineDay {
height = $state(0);
width = $state(0);
isInOrNearViewport = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.isInOrNearViewport));
// Assets in or near the viewport; active assets should be added to the DOM.
firstActiveIndex = $state(-1);
lastActiveIndex = $state(-1);
isInOrNearViewport = $derived(this.firstActiveIndex !== -1);
#top: number = $state(0);
#start: number = $state(0);
@@ -149,6 +172,29 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i);
}
this.updateAssetBoundaries();
}
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.firstActiveIndex = -1;
this.lastActiveIndex = -1;
return;
}
const dayOffset = this.absoluteTimelineDayTop;
const headerHeight = manager.headerHeight;
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
const hasActive = last >= first && first < this.viewerAssets.length;
this.firstActiveIndex = hasActive ? first : -1;
this.lastActiveIndex = hasActive ? last : -1;
}
get absoluteTimelineDayTop() {
@@ -214,6 +214,11 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) {
updateTimelineMonthViewportProximity(this, month);
if (month.isInOrNearViewport && month.isLoaded) {
for (const day of month.timelineDays) {
day.updateAssetBoundaries();
}
}
}
const month = this.months.find((month) => month.isInViewport);
@@ -254,7 +254,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay);
}
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
const viewerAsset = new ViewerAsset(timelineAsset);
timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay);
}
@@ -1,36 +1,12 @@
import type { CommonPosition } from '$lib/utils/layout-utils';
import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
} from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types';
export class ViewerAsset {
readonly #group: TimelineDay;
#viewportProximity = $derived.by(() => {
if (!this.position) {
return ViewportProximity.FarFromViewport;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return isInOrNearViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id);
constructor(group: TimelineDay, asset: TimelineAsset) {
this.#group = group;
constructor(asset: TimelineAsset) {
this.asset = asset;
}
}