feat(web): lighter timeline buckets

This commit is contained in:
Min Idzelis
2025-04-19 22:43:08 +00:00
parent 242a559e0f
commit 5a8f9f3b5c
47 changed files with 531 additions and 406 deletions
+120 -83
View File
@@ -7,7 +7,7 @@ import {
type CommonLayoutOptions,
type CommonPosition,
} from '$lib/utils/layout-utils';
import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util';
import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import {
AssetOrder,
@@ -16,11 +16,11 @@ import {
getTimeBuckets,
TimeBucketSize,
type AssetResponseDto,
type AssetStackResponseDto,
} from '@immich/sdk';
import { clamp, debounce, isEqual, 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';
@@ -62,13 +62,30 @@ function updateObject(target: any, source: any): boolean {
return updated;
}
export function assetSnapshot(asset: AssetResponseDto) {
return $state.snapshot(asset);
export function assetSnapshot(asset: TimelineAsset): TimelineAsset {
return $state.snapshot(asset) as TimelineAsset;
}
export function assetsSnapshot(assets: AssetResponseDto[]) {
return assets.map((a) => $state.snapshot(a));
export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] {
return assets.map((a) => $state.snapshot(a)) as TimelineAsset[];
}
export type TimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: string;
isArchived: boolean;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
stack: AssetStackResponseDto | null;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
};
class IntersectingAsset {
// --- public ---
readonly #group: AssetDateGroup;
@@ -92,17 +109,17 @@ class IntersectingAsset {
});
position: CommonPosition | undefined = $state();
asset: AssetResponseDto | undefined = $state();
asset: TimelineAsset | undefined = $state();
id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
constructor(group: AssetDateGroup, asset: TimelineAsset) {
this.#group = group;
this.asset = asset;
}
}
type AssetOperation = (asset: AssetResponseDto) => { remove: boolean };
type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
type MoveAsset = { asset: AssetResponseDto; year: number; month: number };
type MoveAsset = { asset: TimelineAsset; year: number; month: number };
export class AssetDateGroup {
// --- public
readonly bucket: AssetBucket;
@@ -131,8 +148,8 @@ export class AssetDateGroup {
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
this.intersetingAssets.sort((a, b) => {
const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC();
const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC();
const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC();
const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC();
if (sortOrder === AssetOrder.Asc) {
return aDate.diff(bDate).milliseconds;
@@ -223,6 +240,25 @@ export type ViewportXY = Viewport & {
y: number;
};
class AddContext {
lookupCache: {
[dayOfMonth: number]: AssetDateGroup;
} = {};
unprocessedAssets: TimelineAsset[] = [];
changedDateGroups = new Set<AssetDateGroup>();
newDateGroups = new Set<AssetDateGroup>();
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
for (const group of this.changedDateGroups) {
group.sortAssets(sortOrder);
}
for (const group of this.newDateGroups) {
group.sortAssets(sortOrder);
}
if (this.newDateGroups.size > 0) {
bucket.sortDateGroups();
}
}
}
export class AssetBucket {
// --- public ---
#intersecting: boolean = $state(false);
@@ -314,7 +350,7 @@ export class AssetBucket {
getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce
return this.dateGroups.reduce(
(accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
(accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
[],
);
}
@@ -379,55 +415,51 @@ export class AssetBucket {
}
// note - if the assets are not part of this bucket, they will not be added
addAssets(assets: AssetResponseDto[]) {
const lookupCache: {
[dayOfMonth: number]: AssetDateGroup;
} = {};
const unprocessedAssets: AssetResponseDto[] = [];
const changedDateGroups = new Set<AssetDateGroup>();
const newDateGroups = new Set<AssetDateGroup>();
for (const asset of assets) {
const date = DateTime.fromISO(asset.localDateTime).toUTC();
const month = date.get('month');
const year = date.get('year');
if (this.month === month && this.year === year) {
const day = date.get('day');
let dateGroup: AssetDateGroup | undefined = lookupCache[day];
if (!dateGroup) {
dateGroup = this.findDateGroupByDay(day);
if (dateGroup) {
lookupCache[day] = dateGroup;
}
}
addAssets(bucketResponse: AssetResponseDto[]) {
const addContext = new AddContext();
for (const asset of bucketResponse) {
const timelineAsset = toTimelineAsset(asset);
this.addTimelineAsset(timelineAsset, addContext);
}
addContext.sort(this, this.#sortOrder);
return addContext.unprocessedAssets;
}
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
const { id, localDateTime } = timelineAsset;
const date = DateTime.fromISO(localDateTime).toUTC();
const month = date.get('month');
const year = date.get('year');
if (this.month === month && this.year === year) {
const day = date.get('day');
let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day];
if (!dateGroup) {
dateGroup = this.findDateGroupByDay(day);
if (dateGroup) {
const intersectingAsset = new IntersectingAsset(dateGroup, asset);
if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) {
console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`);
} else {
dateGroup.intersetingAssets.push(intersectingAsset);
changedDateGroups.add(dateGroup);
}
addContext.lookupCache[day] = dateGroup;
}
}
if (dateGroup) {
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
} else {
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset));
this.dateGroups.push(dateGroup);
lookupCache[day] = dateGroup;
newDateGroups.add(dateGroup);
dateGroup.intersetingAssets.push(intersectingAsset);
addContext.changedDateGroups.add(dateGroup);
}
} else {
unprocessedAssets.push(asset);
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, timelineAsset));
this.dateGroups.push(dateGroup);
addContext.lookupCache[day] = dateGroup;
addContext.newDateGroups.add(dateGroup);
}
} else {
addContext.unprocessedAssets.push(timelineAsset);
}
for (const group of changedDateGroups) {
group.sortAssets(this.#sortOrder);
}
for (const group of newDateGroups) {
group.sortAssets(this.#sortOrder);
}
if (newDateGroups.size > 0) {
this.sortDateGroups();
}
return unprocessedAssets;
}
getRandomDateGroup() {
const random = Math.floor(Math.random() * this.dateGroups.length);
@@ -514,12 +546,12 @@ const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
interface AddAsset {
type: 'add';
values: AssetResponseDto[];
values: TimelineAsset[];
}
interface UpdateAsset {
type: 'update';
values: AssetResponseDto[];
values: TimelineAsset[];
}
interface DeleteAsset {
@@ -701,9 +733,13 @@ export class AssetStore {
connect() {
this.#unsubscribers.push(
websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })),
websocketEvents.on('on_upload_success', (asset) =>
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(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_update', (asset) =>
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
);
}
@@ -717,8 +753,8 @@ export class AssetStore {
#getPendingChangeBatches() {
const batch: {
add: AssetResponseDto[];
update: AssetResponseDto[];
add: TimelineAsset[];
update: TimelineAsset[];
remove: string[];
} = {
add: [],
@@ -1042,7 +1078,7 @@ export class AssetStore {
// so no need to load the bucket, it already has assets
return;
}
const assets = await getTimeBucket(
const bucketResponse = await getTimeBucket(
{
...this.#options,
timeBucket: bucketDate,
@@ -1051,9 +1087,9 @@ export class AssetStore {
},
{ signal },
);
if (assets) {
if (bucketResponse) {
if (this.#options.timelineAlbumId) {
const albumAssets = await getTimeBucket(
const bucketAssets = await getTimeBucket(
{
albumId: this.#options.timelineAlbumId,
timeBucket: bucketDate,
@@ -1062,12 +1098,11 @@ export class AssetStore {
},
{ signal },
);
for (const asset of albumAssets) {
this.albumAssets.add(asset.id);
for (const { id } of bucketAssets) {
this.albumAssets.add(id);
}
}
const unprocessed = bucket.addAssets(assets);
const unprocessed = bucket.addAssets(bucketResponse);
if (unprocessed.length > 0) {
console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
@@ -1081,8 +1116,8 @@ export class AssetStore {
}
}
addAssets(assets: AssetResponseDto[]) {
const assetsToUpdate: AssetResponseDto[] = [];
addAssets(assets: TimelineAsset[]) {
const assetsToUpdate: TimelineAsset[] = [];
for (const asset of assets) {
if (this.isExcluded(asset)) {
@@ -1095,7 +1130,7 @@ export class AssetStore {
this.#addAssetsToBuckets([...notUpdated]);
}
#addAssetsToBuckets(assets: AssetResponseDto[]) {
#addAssetsToBuckets(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
@@ -1112,7 +1147,9 @@ export class AssetStore {
bucket = new AssetBucket(this, utc, 1, this.#options.order);
this.buckets.push(bucket);
}
bucket.addAssets([asset]);
const addContext = new AddContext();
bucket.addTimelineAsset(asset, addContext);
addContext.sort(bucket, this.#options.order);
updatedBuckets.add(bucket);
}
@@ -1138,7 +1175,7 @@ export class AssetStore {
await this.initTask.waitUntilCompletion();
let bucket = this.#findBucketForAsset(id);
if (!bucket) {
const asset = await getAssetInfo({ id });
const asset = toTimelineAsset(await getAssetInfo({ id }));
if (!asset || this.isExcluded(asset)) {
return;
}
@@ -1151,7 +1188,7 @@ export class AssetStore {
}
async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) {
let date = fromLocalDateTime(localDateTime);
let date = DateTime.fromISO(localDateTime).toUTC();
// Only support TimeBucketSize.Month
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
const iso = date.toISO()!;
@@ -1161,7 +1198,7 @@ export class AssetStore {
return this.getBucketByDate(year, month);
}
async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) {
async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) {
const bucketInfo = this.#findBucketForAsset(asset.id);
if (bucketInfo) {
return bucketInfo;
@@ -1195,7 +1232,7 @@ export class AssetStore {
const changedBuckets = new Set<AssetBucket>();
let idsToProcess = new Set(ids);
const idsProcessed = new Set<string>();
const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = [];
const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = [];
for (const bucket of this.buckets) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
@@ -1238,8 +1275,8 @@ export class AssetStore {
this.#runAssetOperation(new Set(ids), operation);
}
updateAssets(assets: AssetResponseDto[]) {
const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset]));
updateAssets(assets: TimelineAsset[]) {
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
updateObject(asset, lookup.get(asset.id));
return { remove: false };
@@ -1261,11 +1298,11 @@ export class AssetStore {
this.updateIntersections();
}
getFirstAsset(): AssetResponseDto | undefined {
getFirstAsset(): TimelineAsset | undefined {
return this.buckets[0]?.getFirstAsset();
}
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset);
if (!bucket) {
return;
@@ -1308,7 +1345,7 @@ export class AssetStore {
}
}
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
async getNextAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset);
if (!bucket) {
return;
@@ -1347,7 +1384,7 @@ export class AssetStore {
}
}
isExcluded(asset: AssetResponseDto) {
isExcluded(asset: TimelineAsset) {
return (
isMismatched(this.#options.isArchived, asset.isArchived) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||