import { locale } from '$lib/stores/preferences.store'; import { getKey } from '$lib/utils'; import { AssetGridTaskManager } from '$lib/utils/asset-store-task-manager'; import { generateId } from '$lib/utils/generate-id'; import { type getJustifiedLayoutFromAssetsFunction } from '$lib/utils/layout-utils'; import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { fromLocalDateTime, splitBucketIntoDateGroups, type DateGroup } from '$lib/utils/timeline-util'; import { TimeBucketSize, getAssetInfo, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; import { throttle } from 'lodash-es'; import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; import { SvelteSet } from 'svelte/reactivity'; import { get, writable, type Unsubscriber } from 'svelte/store'; import { handleError } from '../utils/handle-error'; import { websocketEvents } from './websocket'; let getJustifiedLayoutFromAssets: getJustifiedLayoutFromAssetsFunction; type AssetApiGetTimeBucketsRequest = Parameters[0]; export type AssetStoreOptions = Omit; export interface Viewport { width: number; height: number; } export type ViewportXY = Viewport & { x: number; y: number; }; interface AssetLookup { bucket: AssetBucket; bucketIndex: number; assetIndex: number; } export class AssetBucket { store!: AssetStore; bucketDate: string = $state(''); /** * The DOM height of the bucket in pixel * This value is first estimated by the number of asset and later is corrected as the user scroll * Do not derive this height, it is important for it to be updated at specific times, so that * calculateing a delta between estimated and actual (when measured) is correct. */ bucketHeight: number = $state(0); isBucketHeightActual: boolean = $state(false); bucketDateFormattted!: string; bucketCount: number = $derived.by(() => (this.isLoaded ? this.assets.length : this.initialCount)); initialCount: number = 0; assets: AssetResponseDto[] = $state([]); dateGroups: DateGroup[] = $state([]); cancelToken: AbortController | undefined = $state(); /** * Prevent this asset's load from being canceled; i.e. to force load of offscreen asset. */ isPreventCancel: boolean = $state(false); /** * A promise that resolves once the bucket is loaded, and rejects if bucket is canceled. */ complete!: Promise; loading: boolean = $state(false); isLoaded: boolean = $state(false); intersecting: boolean = $state(false); measured: boolean = $state(false); measuredPromise!: Promise; constructor(props: Partial & { store: AssetStore; bucketDate: string }) { Object.assign(this, props); this.init(); } /** The svelte key for this view model object */ get viewId() { return this.store.viewId + '-' + this.bucketDate; } private init() { // create a promise, and store its resolve/reject callbacks. The loadedSignal callback // will be incoked when a bucket is loaded, fulfilling the promise. The canceledSignal // callback will be called if the bucket is canceled before it was loaded, rejecting the // promise. this.complete = new Promise((resolve, reject) => { this.loadedSignal = resolve; this.canceledSignal = reject; }).catch( () => // if no-one waits on complete, and its rejected a uncaught rejection message is logged. // We this message with an empty reject handler, since waiting on a bucket is optional. void 0, ); this.measuredPromise = new Promise((resolve) => { this.measuredSignal = resolve; }); this.bucketDateFormattted = fromLocalDateTime(this.bucketDate) .startOf('month') .toJSDate() .toLocaleString(get(locale), { month: 'short', year: 'numeric', timeZone: 'UTC', }); } private loadedSignal: (() => void) | undefined; private canceledSignal: (() => void) | undefined; measuredSignal: (() => void) | undefined; cancel() { if (this.isLoaded) { return; } if (this.isPreventCancel) { return; } this.cancelToken?.abort(); this.canceledSignal?.(); this.init(); } loaded() { this.loadedSignal?.(); this.isLoaded = true; } errored() { this.canceledSignal?.(); this.init(); } } const isMismatched = (option: boolean | undefined, value: boolean): boolean => option === undefined ? false : option !== value; const THUMBNAIL_HEIGHT = 235; interface AddAsset { type: 'add'; values: AssetResponseDto[]; } interface UpdateAsset { type: 'update'; values: AssetResponseDto[]; } interface DeleteAsset { type: 'delete'; values: string[]; } interface TrashAssets { type: 'trash'; values: string[]; } interface UpdateStackAssets { type: 'update_stack_assets'; values: string[]; } export const photoViewerImgElement = writable(null); type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets; export type BucketListener = ( event: | ViewPortEvent | BucketLoadEvent | BucketLoadedEvent | BucketCancelEvent | BucketHeightEvent | DateGroupIntersecting | DateGroupHeightEvent, ) => void; type ViewPortEvent = { type: 'viewport'; }; type BucketLoadEvent = { type: 'load'; bucket: AssetBucket; }; type BucketLoadedEvent = { type: 'loaded'; bucket: AssetBucket; }; type BucketCancelEvent = { type: 'cancel'; bucket: AssetBucket; }; type BucketHeightEvent = { type: 'bucket-height'; bucket: AssetBucket; delta: number; }; type DateGroupIntersecting = { type: 'intersecting'; bucket: AssetBucket; dateGroup: DateGroup; }; type DateGroupHeightEvent = { type: 'height'; bucket: AssetBucket; dateGroup: DateGroup; delta: number; height: number; }; export class AssetStore { private assetToBucket: Record = $derived.by(() => { const result: Record = {}; for (let index = 0; index < this.buckets.length; index++) { const bucket = this.buckets[index]; for (let index_ = 0; index_ < bucket.assets.length; index_++) { const asset = bucket.assets[index_]; result[asset.id] = { bucket, bucketIndex: index, assetIndex: index_ }; } } return result; }); private pendingChanges: PendingChange[] = []; private unsubscribers: Unsubscriber[] = []; private options!: AssetApiGetTimeBucketsRequest; viewport: Viewport = $state({ height: 0, width: 0, }); private initializedSignal!: () => void; private store$ = writable(this); /** The svelte key for this view model object */ viewId = generateId(); lastScrollTime: number = $state(0); // subscribe = this.store$.subscribe; /** * A promise that resolves once the store is initialized. */ private complete!: Promise; taskManager = new AssetGridTaskManager(this); initialized = $state(false); timelineHeight = $state(0); buckets: AssetBucket[] = $state([]); assets: AssetResponseDto[] = $derived.by(() => { return this.buckets.flatMap(({ assets }) => assets); }); albumAssets: Set = new SvelteSet(); pendingScrollBucket: AssetBucket | undefined = $state(); pendingScrollAssetId: string | undefined = $state(); maxBucketAssets = $state(0); private listeners: BucketListener[] = []; constructor( options: AssetStoreOptions, private albumId?: string, ) { this.setOptions(options); this.createInitializationSignal(); this.store$.set(this); } private setOptions(options: AssetStoreOptions) { this.options = { ...options, size: TimeBucketSize.Month }; } private createInitializationSignal() { // create a promise, and store its resolve callbacks. The initializedSignal callback // will be invoked when a the assetstore is initialized. this.complete = new Promise((resolve) => { this.initializedSignal = resolve; }).catch(() => void 0); } private addPendingChanges(...changes: PendingChange[]) { // prevent websocket events from happening before local client events setTimeout(() => { this.pendingChanges.push(...changes); this.processPendingChanges(); }, 1000); } connect() { this.unsubscribers.push( websocketEvents.on('on_upload_success', (_) => { // TODO!: Temporarily disable to avoid flashing effect of the timeline // this.addPendingChanges({ type: 'add', values: [asset] }); }), websocketEvents.on('on_asset_trash', (ids) => { this.addPendingChanges({ type: 'trash', values: ids }); }), websocketEvents.on('on_asset_update', (asset) => { this.addPendingChanges({ type: 'update', values: [asset] }); }), websocketEvents.on('on_asset_delete', (id: string) => { this.addPendingChanges({ type: 'delete', values: [id] }); }), ); } disconnect() { for (const unsubscribe of this.unsubscribers) { unsubscribe(); } this.unsubscribers = []; } private getPendingChangeBatches() { const batches: PendingChange[] = []; let batch: PendingChange | undefined; for (const { type, values: _values } of this.pendingChanges) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const values = _values as any[]; if (batch && batch.type !== type) { batches.push(batch); batch = undefined; } if (batch) { batch.values.push(...values); } else { batch = { type, values }; } } if (batch) { batches.push(batch); } return batches; } processPendingChanges = throttle(() => { for (const { type, values } of this.getPendingChangeBatches()) { switch (type) { case 'add': { this.addAssets(values); break; } case 'update': { this.updateAssets(values); break; } case 'trash': { if (!this.options.isTrashed) { this.removeAssets(values); } break; } case 'delete': { this.removeAssets(values); break; } } } this.pendingChanges = []; // this.emit(true); }, 2500); addListener(bucketListener: BucketListener) { this.listeners.push(bucketListener); } removeListener(bucketListener: BucketListener) { this.listeners = this.listeners.filter((l) => l != bucketListener); } private notifyListeners( event: | ViewPortEvent | BucketLoadEvent | BucketLoadedEvent | BucketCancelEvent | BucketHeightEvent | DateGroupIntersecting | DateGroupHeightEvent, ) { for (const fn of this.listeners) { fn(event); } } async init({ bucketListener }: { bucketListener?: BucketListener } = {}) { if (this.initialized) { throw 'Can only init once'; } if (!getJustifiedLayoutFromAssets) { const module = await import('$lib/utils/layout-utils'); getJustifiedLayoutFromAssets = module.getJustifiedLayoutFromAssets; } if (bucketListener) { this.addListener(bucketListener); } await this.initialiazeTimeBuckets(); } async initialiazeTimeBuckets() { this.timelineHeight = 0; this.buckets = []; this.albumAssets.clear(); const timebuckets = await getTimeBuckets({ ...this.options, key: getKey(), }); this.buckets = timebuckets.map( (bucket) => new AssetBucket({ store: this, bucketDate: bucket.timeBucket, initialCount: bucket.count }), ); this.initializedSignal(); this.initialized = true; } async updateOptions(options: AssetStoreOptions) { // Make sure to re-initialize if the personId changes const needsReinitializing = this.options.personId !== options.personId; if (!this.initialized && !needsReinitializing) { this.setOptions(options); return; } // TODO: don't call updateObjects frequently after // init - cancelation of the initialize tasks isn't // performed right now, and will cause issues if // multiple updateOptions() calls are interleved. await this.complete; this.taskManager.destroy(); this.taskManager = new AssetGridTaskManager(this); this.initialized = false; this.viewId = generateId(); this.createInitializationSignal(); this.setOptions(options); await this.initialiazeTimeBuckets(); // this.emit(true); await this.initialLayout(true); } public destroy() { this.taskManager.destroy(); this.listeners = []; this.initialized = false; } async updateViewport(viewport: Viewport, force?: boolean) { if (viewport.height === 0 && viewport.width === 0) { return; } if (!force && this.viewport.height === viewport.height && this.viewport.width === viewport.width) { return; } await this.complete; // changing width invalidates the actual height, and needs to be remeasured, since width changes causes // layout reflows. const changedWidth = this.viewport.width != viewport.width; this.viewport = { ...viewport }; await this.initialLayout(changedWidth); } private async initialLayout(changedWidth: boolean) { for (const bucket of this.buckets) { this.updateGeometry(bucket, changedWidth); } this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); const loaders = []; let height = 0; for (const bucket of this.buckets) { if (height >= this.viewport.height) { break; } height += bucket.bucketHeight; loaders.push(this.loadBucket(bucket.bucketDate)); } await Promise.all(loaders); this.notifyListeners({ type: 'viewport' }); } private updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { if (invalidateHeight) { bucket.isBucketHeightActual = false; bucket.measured = false; for (const assetGroup of bucket.dateGroups) { assetGroup.heightActual = false; } } const viewportWidth = this.viewport.width; if (!bucket.isBucketHeightActual) { const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10); const rows = Math.ceil(unwrappedWidth / viewportWidth); const height = 51 + Math.max(1, rows) * THUMBNAIL_HEIGHT; this.setBucketHeight(bucket, height, false); } const layoutOptions = { spacing: 2, heightTolerance: 0.15, rowHeight: 235, rowWidth: Math.floor(viewportWidth), }; for (const assetGroup of bucket.dateGroups) { if (!assetGroup.heightActual) { const unwrappedWidth = (3 / 2) * assetGroup.assets.length * THUMBNAIL_HEIGHT * (7 / 10); const rows = Math.ceil(unwrappedWidth / this.viewport.width); const height = rows * THUMBNAIL_HEIGHT; assetGroup.height = height; } assetGroup.geometry = getJustifiedLayoutFromAssets(assetGroup.assets, layoutOptions); } } async loadBucket(bucketDate: string, options: { preventCancel?: boolean; pending?: boolean } = {}): Promise { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { return; } if (bucket.isLoaded) { // already loaded return; } if (bucket.cancelToken != null && bucket.bucketCount !== bucket.assets.length) { // if promise is pending, and preventCancel is requested, then don't overwrite it if (!bucket.isPreventCancel && options.preventCancel) { bucket.isPreventCancel = options.preventCancel; } await bucket.complete; return; } if (options.pending) { this.pendingScrollBucket = bucket; } this.notifyListeners({ type: 'load', bucket }); bucket.isPreventCancel = !!options.preventCancel; const cancelToken = (bucket.cancelToken = new AbortController()); try { const assets = await getTimeBucket( { ...this.options, timeBucket: bucketDate, key: getKey(), }, { signal: cancelToken.signal }, ); if (cancelToken.signal.aborted) { this.notifyListeners({ type: 'cancel', bucket }); return; } if (this.albumId) { const albumAssets = await getTimeBucket( { albumId: this.albumId, timeBucket: bucketDate, size: this.options.size, key: getKey(), }, { signal: cancelToken.signal }, ); if (cancelToken.signal.aborted) { this.notifyListeners({ type: 'cancel', bucket }); return; } for (const asset of albumAssets) { this.albumAssets.add(asset.id); } } bucket.assets = assets; bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); this.updateGeometry(bucket, true); this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0); bucket.loaded(); this.notifyListeners({ type: 'loaded', bucket }); } catch (error) { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ if ((error as any).name === 'AbortError') { return; } const _$t = get(t); handleError(error, _$t('errors.failed_to_load_assets')); bucket.errored(); } finally { bucket.cancelToken = undefined; } } setBucketHeight(bucket: AssetBucket, newHeight: number, isActualHeight: boolean) { const delta = newHeight - bucket.bucketHeight; bucket.isBucketHeightActual = isActualHeight; bucket.bucketHeight = newHeight; this.timelineHeight += delta; this.notifyListeners({ type: 'bucket-height', bucket, delta }); } updateBucket(bucketDate: string, properties: { height?: number; intersecting?: boolean; measured?: boolean }) { const bucket = this.getBucketByDate(bucketDate); if (!bucket) { return {}; } const delta = 0; if ('height' in properties) { this.setBucketHeight(bucket, properties.height!, true); } if ('intersecting' in properties) { bucket.intersecting = properties.intersecting!; } if ('measured' in properties) { if (properties.measured) { bucket.measuredSignal?.(); } bucket.measured = properties.measured!; } return { delta }; } updateBucketDateGroup( bucket: AssetBucket, dateGroup: DateGroup, properties: { height?: number; intersecting?: boolean }, ) { let delta = 0; if ('height' in properties) { const height = properties.height!; if (height > 0) { delta = height - dateGroup.height; dateGroup.heightActual = true; dateGroup.height = height; this.notifyListeners({ type: 'height', bucket, dateGroup, delta, height }); } } if ('intersecting' in properties) { dateGroup.intersecting = properties.intersecting!; if (dateGroup.intersecting) { this.notifyListeners({ type: 'intersecting', bucket, dateGroup }); } } return { delta }; } addAssets(assets: AssetResponseDto[]) { const assetsToUpdate: AssetResponseDto[] = []; const assetsToAdd: AssetResponseDto[] = []; for (const asset of assets) { if ( this.assetToBucket[asset.id] || this.options.userId || this.options.personId || this.options.albumId || this.isExcluded(asset) ) { // If asset is already in the bucket we don't need to recalculate // asset store containers assetsToUpdate.push(asset); } else { assetsToAdd.push(asset); } } this.updateAssets(assetsToUpdate); this.addAssetsToBuckets(assetsToAdd); } private addAssetsToBuckets(assets: AssetResponseDto[]) { if (assets.length === 0) { return; } const updatedBuckets = new Set(); for (const asset of assets) { const timeBucket = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month').toString(); let bucket = this.getBucketByDate(timeBucket); if (!bucket) { bucket = new AssetBucket({ store: this, bucketDate: timeBucket, bucketHeight: THUMBNAIL_HEIGHT }); this.buckets.push(bucket); } bucket.assets.push(asset); updatedBuckets.add(bucket); } this.buckets = this.buckets.sort((a, b) => { const aDate = DateTime.fromISO(a.bucketDate).toUTC(); const bDate = DateTime.fromISO(b.bucketDate).toUTC(); return bDate.diff(aDate).milliseconds; }); for (const bucket of updatedBuckets) { bucket.assets.sort((a, b) => { const aDate = DateTime.fromISO(a.fileCreatedAt).toUTC(); const bDate = DateTime.fromISO(b.fileCreatedAt).toUTC(); return bDate.diff(aDate).milliseconds; }); bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); this.updateGeometry(bucket, true); } } getBucketByDate(bucketDate: string): AssetBucket | null { return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null; } async findAndLoadBucketAsPending(id: string) { const bucketInfo = this.assetToBucket[id]; let bucket: AssetBucket | null = bucketInfo?.bucket ?? null; if (!bucket) { const asset = await getAssetInfo({ id }); if (!asset || this.isExcluded(asset)) { return; } bucket = await this.loadBucketAtTime(asset.localDateTime, { preventCancel: true, pending: true }); } if (bucket && bucket.assets.some((a) => a.id === id)) { this.pendingScrollBucket = bucket; this.pendingScrollAssetId = id; return bucket; } } /* Must be paired with matching clearPendingScroll() call */ async scheduleScrollToAssetId(scrollTarget: AssetGridRouteSearchParams, onFailure: () => void) { try { const { at: assetId } = scrollTarget; if (assetId) { await this.complete; const bucket = await this.findAndLoadBucketAsPending(assetId); if (bucket) { return; } } } catch { // failure } onFailure(); } clearPendingScroll() { this.pendingScrollBucket = undefined; this.pendingScrollAssetId = undefined; } private async loadBucketAtTime(localDateTime: string, options: { preventCancel?: boolean; pending?: boolean }) { let date = fromLocalDateTime(localDateTime); if (this.options.size == TimeBucketSize.Month) { date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); } else if (this.options.size == TimeBucketSize.Day) { date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); } const iso = date.toISO()!; await this.loadBucket(iso, options); return this.getBucketByDate(iso); } private async getBucketInfoForAsset( { id, localDateTime }: Pick, options: { preventCancel?: boolean; pending?: boolean } = {}, ) { const bucketInfo = this.assetToBucket[id]; if (bucketInfo) { return bucketInfo; } await this.loadBucketAtTime(localDateTime, options); return this.assetToBucket[id] || null; } getBucketIndexByAssetId(assetId: string) { return this.assetToBucket[assetId]?.bucketIndex ?? null; } async getRandomAsset(): Promise { let index = Math.floor( Math.random() * this.buckets.reduce((accumulator, bucket) => accumulator + bucket.bucketCount, 0), ); for (const bucket of this.buckets) { if (index < bucket.bucketCount) { await this.loadBucket(bucket.bucketDate); return bucket.assets[index] || null; } index -= bucket.bucketCount; } return null; } updateAssets(assets: AssetResponseDto[]) { if (assets.length === 0) { return; } const assetsToRecalculate: AssetResponseDto[] = []; for (const _asset of assets) { const asset = this.assets.find((asset) => asset.id === _asset.id); if (!asset) { continue; } const recalculate = asset.localDateTime !== _asset.localDateTime; Object.assign(asset, _asset); if (recalculate) { assetsToRecalculate.push(asset); } } this.removeAssets(assetsToRecalculate.map((asset) => asset.id)); this.addAssetsToBuckets(assetsToRecalculate); } removeAssets(ids: string[]) { const idSet = new Set(ids); // Iterate in reverse to allow array splicing. for (let index = this.buckets.length - 1; index >= 0; index--) { const bucket = this.buckets[index]; let changed = false; for (let index_ = bucket.assets.length - 1; index_ >= 0; index_--) { const asset = bucket.assets[index_]; if (!idSet.has(asset.id)) { continue; } bucket.assets.splice(index_, 1); changed = true; if (bucket.assets.length === 0) { this.buckets.splice(index, 1); } } if (changed) { bucket.dateGroups = splitBucketIntoDateGroups(bucket, get(locale)); this.updateGeometry(bucket, true); } } } async getPreviousAsset(asset: AssetResponseDto): Promise { const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } const { bucket, assetIndex, bucketIndex } = info; if (assetIndex !== 0) { return bucket.assets[assetIndex - 1]; } if (bucketIndex === 0) { return null; } const previousBucket = this.buckets[bucketIndex - 1]; await this.loadBucket(previousBucket.bucketDate); return previousBucket.assets.at(-1) || null; } async getNextAsset(asset: AssetResponseDto): Promise { const info = await this.getBucketInfoForAsset(asset); if (!info) { return null; } const { bucket, assetIndex, bucketIndex } = info; if (assetIndex !== bucket.assets.length - 1) { return bucket.assets[assetIndex + 1]; } if (bucketIndex === this.buckets.length - 1) { return null; } const nextBucket = this.buckets[bucketIndex + 1]; await this.loadBucket(nextBucket.bucketDate); return nextBucket.assets[0] || null; } private isExcluded(asset: AssetResponseDto) { return ( isMismatched(this.options.isArchived ?? false, asset.isArchived) || isMismatched(this.options.isFavorite, asset.isFavorite) || isMismatched(this.options.isTrashed ?? false, asset.isTrashed) ); } } export const isSelectingAllAssets = writable(false);