mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: timeline performance (#16446)
* Squash - feature complete * remove need to init assetstore * More optimizations. No need to init. Fix tests * lint * add missing selector for e2e * e2e selectors again * Update: fully reactive store, some transitions, bugfixes * merge fallout * Test fallout * safari quirk * security * lint * lint * Bug fixes * lint/format * accidental commit * lock * null check, more throttle * revert long duration * Fix intersection bounds * Fix bugs in intersection calculation * lint, tweak scrubber ui a tiny bit * bugfix - deselecting asset doesnt work * fix not loading bucket, scroll off-by-1 error, jsdoc, naming
This commit is contained in:
parent
dd263b010c
commit
e96ffd43e7
48 changed files with 2318 additions and 2764 deletions
|
|
@ -1,465 +0,0 @@
|
|||
import type { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
|
||||
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
|
||||
import { type DateGroup } from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { clamp } from 'lodash-es';
|
||||
|
||||
type Task = () => void;
|
||||
|
||||
class InternalTaskManager {
|
||||
assetStore: AssetStore;
|
||||
componentTasks = new Map<string, Set<string>>();
|
||||
priorityQueue = new KeyedPriorityQueue<string, Task>();
|
||||
idleQueue = new Map<string, Task>();
|
||||
taskCleaners = new Map<string, Task>();
|
||||
|
||||
queueTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
lastIdle: number | undefined;
|
||||
|
||||
constructor(assetStore: AssetStore) {
|
||||
this.assetStore = assetStore;
|
||||
}
|
||||
destroy() {
|
||||
this.componentTasks.clear();
|
||||
this.priorityQueue.clear();
|
||||
this.idleQueue.clear();
|
||||
this.taskCleaners.clear();
|
||||
clearTimeout(this.queueTimer);
|
||||
if (this.lastIdle) {
|
||||
cancelIdleCB(this.lastIdle);
|
||||
}
|
||||
}
|
||||
getOrCreateComponentTasks(componentId: string) {
|
||||
let componentTaskSet = this.componentTasks.get(componentId);
|
||||
if (!componentTaskSet) {
|
||||
componentTaskSet = new Set<string>();
|
||||
this.componentTasks.set(componentId, componentTaskSet);
|
||||
}
|
||||
|
||||
return componentTaskSet;
|
||||
}
|
||||
deleteFromComponentTasks(componentId: string, taskId: string) {
|
||||
if (this.componentTasks.has(componentId)) {
|
||||
const componentTaskSet = this.componentTasks.get(componentId);
|
||||
componentTaskSet?.delete(taskId);
|
||||
if (componentTaskSet?.size === 0) {
|
||||
this.componentTasks.delete(componentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drainIntersectedQueue() {
|
||||
let count = 0;
|
||||
for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) {
|
||||
t.value();
|
||||
if (this.taskCleaners.has(t.key)) {
|
||||
this.taskCleaners.get(t.key)!();
|
||||
this.taskCleaners.delete(t.key);
|
||||
}
|
||||
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
|
||||
this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) {
|
||||
clearTimeout(this.queueTimer);
|
||||
this.queueTimer = setTimeout(() => {
|
||||
const delta = Date.now() - this.assetStore.lastScrollTime;
|
||||
if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
|
||||
let amount = clamp(
|
||||
1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR),
|
||||
1,
|
||||
TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2,
|
||||
);
|
||||
|
||||
const nextDelay = clamp(
|
||||
amount > 1
|
||||
? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR)
|
||||
: TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS,
|
||||
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY,
|
||||
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY,
|
||||
);
|
||||
|
||||
while (amount > 0) {
|
||||
this.priorityQueue.shift()?.value();
|
||||
amount--;
|
||||
}
|
||||
if (this.priorityQueue.length > 0) {
|
||||
this.scheduleDrainIntersectedQueue(nextDelay);
|
||||
}
|
||||
} else {
|
||||
this.drainIntersectedQueue();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
removeAllTasksForComponent(componentId: string) {
|
||||
if (this.componentTasks.has(componentId)) {
|
||||
const tasksIds = this.componentTasks.get(componentId) || [];
|
||||
for (const taskId of tasksIds) {
|
||||
this.priorityQueue.remove(taskId);
|
||||
this.idleQueue.delete(taskId);
|
||||
if (this.taskCleaners.has(taskId)) {
|
||||
const cleanup = this.taskCleaners.get(taskId);
|
||||
this.taskCleaners.delete(taskId);
|
||||
cleanup!();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.componentTasks.delete(componentId);
|
||||
}
|
||||
|
||||
queueScrollSensitiveTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
priority = 10,
|
||||
taskId = generateId(),
|
||||
}: {
|
||||
task: Task;
|
||||
cleanup?: Task;
|
||||
componentId: string;
|
||||
priority?: number;
|
||||
taskId?: string;
|
||||
}) {
|
||||
this.priorityQueue.push(taskId, task, priority);
|
||||
if (cleanup) {
|
||||
this.taskCleaners.set(taskId, cleanup);
|
||||
}
|
||||
this.getOrCreateComponentTasks(componentId).add(taskId);
|
||||
const lastTime = this.assetStore.lastScrollTime;
|
||||
const delta = Date.now() - lastTime;
|
||||
if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
|
||||
this.scheduleDrainIntersectedQueue();
|
||||
} else {
|
||||
// flush the queue early
|
||||
clearTimeout(this.queueTimer);
|
||||
this.drainIntersectedQueue();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDrainSeparatedQueue() {
|
||||
if (this.lastIdle) {
|
||||
cancelIdleCB(this.lastIdle);
|
||||
}
|
||||
this.lastIdle = idleCB(
|
||||
() => {
|
||||
let count = 0;
|
||||
let entry = this.idleQueue.entries().next().value;
|
||||
while (entry) {
|
||||
const [taskId, task] = entry;
|
||||
this.idleQueue.delete(taskId);
|
||||
task();
|
||||
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
|
||||
break;
|
||||
}
|
||||
entry = this.idleQueue.entries().next().value;
|
||||
}
|
||||
if (this.idleQueue.size > 0) {
|
||||
this.scheduleDrainSeparatedQueue();
|
||||
}
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
}
|
||||
queueSeparateTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
taskId,
|
||||
}: {
|
||||
task: Task;
|
||||
cleanup: Task;
|
||||
componentId: string;
|
||||
taskId: string;
|
||||
}) {
|
||||
this.idleQueue.set(taskId, task);
|
||||
this.taskCleaners.set(taskId, cleanup);
|
||||
this.getOrCreateComponentTasks(componentId).add(taskId);
|
||||
this.scheduleDrainSeparatedQueue();
|
||||
}
|
||||
|
||||
removeIntersectedTask(taskId: string) {
|
||||
const removed = this.priorityQueue.remove(taskId);
|
||||
if (this.taskCleaners.has(taskId)) {
|
||||
const cleanup = this.taskCleaners.get(taskId);
|
||||
this.taskCleaners.delete(taskId);
|
||||
cleanup!();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
removeSeparateTask(taskId: string) {
|
||||
const removed = this.idleQueue.delete(taskId);
|
||||
if (this.taskCleaners.has(taskId)) {
|
||||
const cleanup = this.taskCleaners.get(taskId);
|
||||
this.taskCleaners.delete(taskId);
|
||||
cleanup!();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetGridTaskManager {
|
||||
private internalManager: InternalTaskManager;
|
||||
constructor(assetStore: AssetStore) {
|
||||
this.internalManager = new InternalTaskManager(assetStore);
|
||||
}
|
||||
|
||||
tasks: Map<AssetBucket, BucketTask> = new Map();
|
||||
|
||||
queueScrollSensitiveTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
priority = 10,
|
||||
taskId = generateId(),
|
||||
}: {
|
||||
task: Task;
|
||||
cleanup?: Task;
|
||||
componentId: string;
|
||||
priority?: number;
|
||||
taskId?: string;
|
||||
}) {
|
||||
return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId });
|
||||
}
|
||||
|
||||
removeAllTasksForComponent(componentId: string) {
|
||||
return this.internalManager.removeAllTasksForComponent(componentId);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
return this.internalManager.destroy();
|
||||
}
|
||||
|
||||
private getOrCreateBucketTask(bucket: AssetBucket) {
|
||||
let bucketTask = this.tasks.get(bucket);
|
||||
if (!bucketTask) {
|
||||
bucketTask = this.createBucketTask(bucket);
|
||||
}
|
||||
return bucketTask;
|
||||
}
|
||||
|
||||
private createBucketTask(bucket: AssetBucket) {
|
||||
const bucketTask = new BucketTask(this.internalManager, this, bucket);
|
||||
this.tasks.set(bucket, bucketTask);
|
||||
return bucketTask;
|
||||
}
|
||||
|
||||
intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
||||
bucketTask.scheduleIntersected(componentId, task);
|
||||
}
|
||||
|
||||
separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
||||
bucketTask.scheduleSeparated(componentId, separated);
|
||||
}
|
||||
|
||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
|
||||
}
|
||||
|
||||
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
bucketTask.separatedDateGroup(componentId, dateGroup, separated);
|
||||
}
|
||||
|
||||
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
|
||||
}
|
||||
|
||||
separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.separatedThumbnail(componentId, asset, separated);
|
||||
}
|
||||
}
|
||||
|
||||
class IntersectionTask {
|
||||
internalTaskManager: InternalTaskManager;
|
||||
separatedKey;
|
||||
intersectedKey;
|
||||
priority;
|
||||
|
||||
intersected: Task | undefined;
|
||||
separated: Task | undefined;
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
|
||||
this.internalTaskManager = internalTaskManager;
|
||||
this.separatedKey = keyPrefix + ':s:' + key;
|
||||
this.intersectedKey = keyPrefix + ':i:' + key;
|
||||
this.priority = priority;
|
||||
}
|
||||
|
||||
trackIntersectedTask(componentId: string, task: Task) {
|
||||
const execTask = () => {
|
||||
if (this.separated) {
|
||||
return;
|
||||
}
|
||||
task?.();
|
||||
};
|
||||
this.intersected = execTask;
|
||||
const cleanup = () => {
|
||||
this.intersected = undefined;
|
||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey);
|
||||
};
|
||||
return { task: execTask, cleanup };
|
||||
}
|
||||
|
||||
trackSeparatedTask(componentId: string, task: Task) {
|
||||
const execTask = () => {
|
||||
if (this.intersected) {
|
||||
return;
|
||||
}
|
||||
task?.();
|
||||
};
|
||||
this.separated = execTask;
|
||||
const cleanup = () => {
|
||||
this.separated = undefined;
|
||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
|
||||
};
|
||||
return { task: execTask, cleanup };
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
if (this.separated) {
|
||||
this.internalTaskManager.removeSeparateTask(this.separatedKey);
|
||||
}
|
||||
}
|
||||
removePendingIntersected() {
|
||||
if (this.intersected) {
|
||||
this.internalTaskManager.removeIntersectedTask(this.intersectedKey);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleIntersected(componentId: string, intersected: Task) {
|
||||
this.removePendingSeparated();
|
||||
if (this.intersected) {
|
||||
return;
|
||||
}
|
||||
const { task, cleanup } = this.trackIntersectedTask(componentId, intersected);
|
||||
this.internalTaskManager.queueScrollSensitiveTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
priority: this.priority,
|
||||
taskId: this.intersectedKey,
|
||||
});
|
||||
}
|
||||
|
||||
scheduleSeparated(componentId: string, separated: Task) {
|
||||
this.removePendingIntersected();
|
||||
|
||||
if (this.separated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { task, cleanup } = this.trackSeparatedTask(componentId, separated);
|
||||
this.internalTaskManager.queueSeparateTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId,
|
||||
taskId: this.separatedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
class BucketTask extends IntersectionTask {
|
||||
assetBucket: AssetBucket;
|
||||
assetGridTaskManager: AssetGridTaskManager;
|
||||
// indexed by dateGroup's date
|
||||
dateTasks: Map<DateGroup, DateGroupTask> = new Map();
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) {
|
||||
super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY);
|
||||
this.assetBucket = assetBucket;
|
||||
this.assetGridTaskManager = parent;
|
||||
}
|
||||
|
||||
getOrCreateDateGroupTask(dateGroup: DateGroup) {
|
||||
let dateGroupTask = this.dateTasks.get(dateGroup);
|
||||
if (!dateGroupTask) {
|
||||
dateGroupTask = this.createDateGroupTask(dateGroup);
|
||||
}
|
||||
return dateGroupTask;
|
||||
}
|
||||
|
||||
createDateGroupTask(dateGroup: DateGroup) {
|
||||
const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup);
|
||||
this.dateTasks.set(dateGroup, dateGroupTask);
|
||||
return dateGroupTask;
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
super.removePendingSeparated();
|
||||
for (const dateGroupTask of this.dateTasks.values()) {
|
||||
dateGroupTask.removePendingSeparated();
|
||||
}
|
||||
}
|
||||
|
||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
||||
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.scheduleIntersected(componentId, intersected);
|
||||
}
|
||||
|
||||
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
|
||||
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.scheduleSeparated(componentId, separated);
|
||||
}
|
||||
}
|
||||
class DateGroupTask extends IntersectionTask {
|
||||
dateGroup: DateGroup;
|
||||
bucketTask: BucketTask;
|
||||
// indexed by thumbnail's asset
|
||||
thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map();
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) {
|
||||
super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY);
|
||||
this.dateGroup = dateGroup;
|
||||
this.bucketTask = parent;
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
super.removePendingSeparated();
|
||||
for (const thumbnailTask of this.thumbnailTasks.values()) {
|
||||
thumbnailTask.removePendingSeparated();
|
||||
}
|
||||
}
|
||||
|
||||
getOrCreateThumbnailTask(asset: AssetResponseDto) {
|
||||
let thumbnailTask = this.thumbnailTasks.get(asset);
|
||||
if (!thumbnailTask) {
|
||||
thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset);
|
||||
this.thumbnailTasks.set(asset, thumbnailTask);
|
||||
}
|
||||
return thumbnailTask;
|
||||
}
|
||||
|
||||
intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) {
|
||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
||||
thumbnailTask.scheduleIntersected(componentId, intersected);
|
||||
}
|
||||
|
||||
separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
|
||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
||||
thumbnailTask.scheduleSeparated(componentId, separated);
|
||||
}
|
||||
}
|
||||
class ThumbnailTask extends IntersectionTask {
|
||||
asset: AssetResponseDto;
|
||||
dateGroupTask: DateGroupTask;
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) {
|
||||
super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY);
|
||||
this.asset = asset;
|
||||
this.dateGroupTask = parent;
|
||||
}
|
||||
}
|
||||
|
|
@ -476,7 +476,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
|
|||
if (!get(isSelectingAllAssets)) {
|
||||
break; // Cancelled
|
||||
}
|
||||
assetInteraction.selectAssets(bucket.assets);
|
||||
assetInteraction.selectAssets(bucket.getAssets().map((a) => $state.snapshot(a)));
|
||||
|
||||
// We use setTimeout to allow the UI to update. Otherwise, this may
|
||||
// cause a long delay between the start of 'select all' and the
|
||||
|
|
|
|||
135
web/src/lib/utils/cancellable-task.ts
Normal file
135
web/src/lib/utils/cancellable-task.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
export class CancellableTask {
|
||||
cancelToken: AbortController | null = null;
|
||||
cancellable: boolean = true;
|
||||
/**
|
||||
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
|
||||
*/
|
||||
complete!: Promise<unknown>;
|
||||
executed: boolean = false;
|
||||
|
||||
private loadedSignal: (() => void) | undefined;
|
||||
private canceledSignal: (() => void) | undefined;
|
||||
|
||||
constructor(
|
||||
private loadedCallback?: () => void,
|
||||
private canceledCallback?: () => void,
|
||||
private errorCallback?: (error: unknown) => void,
|
||||
) {
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return !!this.cancelToken;
|
||||
}
|
||||
|
||||
async waitUntilCompletion() {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
||||
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
||||
// in either case, we wait on the promise.
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
}
|
||||
|
||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||
if (this.executed) {
|
||||
return 'DONE';
|
||||
}
|
||||
|
||||
// if promise is pending, wait on previous request instead.
|
||||
if (this.cancelToken) {
|
||||
// if promise is pending, and preventCancel is requested,
|
||||
// do not allow transition from prevent cancel to allow cancel.
|
||||
if (this.cancellable && !cancellable) {
|
||||
this.cancellable = cancellable;
|
||||
}
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
}
|
||||
this.cancellable = cancellable;
|
||||
const cancelToken = (this.cancelToken = new AbortController());
|
||||
|
||||
try {
|
||||
await f(cancelToken.signal);
|
||||
this.#transitionToExecuted();
|
||||
return 'LOADED';
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).name === 'AbortError') {
|
||||
// abort error is not treated as an error, but as a cancelation.
|
||||
return 'CANCELED';
|
||||
}
|
||||
this.#transitionToErrored(error);
|
||||
return 'ERRORED';
|
||||
} finally {
|
||||
this.cancelToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
// 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<void>((resolve, reject) => {
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
}).catch(
|
||||
() =>
|
||||
// if no-one waits on complete its rejected a uncaught rejection message is logged.
|
||||
// prevent this message with an empty reject handler, since waiting on a bucket is optional.
|
||||
void 0,
|
||||
);
|
||||
}
|
||||
|
||||
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
|
||||
async reset() {
|
||||
this.#transitionToCancelled();
|
||||
if (this.cancelToken) {
|
||||
await this.waitUntilCompletion();
|
||||
}
|
||||
this.init();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.#transitionToCancelled();
|
||||
}
|
||||
|
||||
#transitionToCancelled() {
|
||||
if (this.executed) {
|
||||
return;
|
||||
}
|
||||
if (!this.cancellable) {
|
||||
return;
|
||||
}
|
||||
this.cancelToken?.abort();
|
||||
this.canceledSignal?.();
|
||||
this.init();
|
||||
this.canceledCallback?.();
|
||||
}
|
||||
|
||||
#transitionToExecuted() {
|
||||
this.executed = true;
|
||||
this.loadedSignal?.();
|
||||
this.loadedCallback?.();
|
||||
}
|
||||
|
||||
#transitionToErrored(error: unknown) {
|
||||
this.cancelToken = null;
|
||||
this.canceledSignal?.();
|
||||
this.init();
|
||||
this.errorCallback?.(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
interface RequestIdleCallback {
|
||||
didTimeout?: boolean;
|
||||
timeRemaining?(): DOMHighResTimeStamp;
|
||||
}
|
||||
interface RequestIdleCallbackOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) {
|
||||
const start = Date.now();
|
||||
return setTimeout(() => {
|
||||
cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function fake_cancelIdleCallback(id: number) {
|
||||
return clearTimeout(id);
|
||||
}
|
||||
|
||||
export const idleCB = globalThis.requestIdleCallback || fake_requestIdleCallback;
|
||||
export const cancelIdleCB = globalThis.cancelIdleCallback || fake_cancelIdleCallback;
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
export class KeyedPriorityQueue<K, T> {
|
||||
private items: { key: K; value: T; priority: number }[] = [];
|
||||
private set: Set<K> = new Set();
|
||||
|
||||
clear() {
|
||||
this.items = [];
|
||||
this.set.clear();
|
||||
}
|
||||
|
||||
remove(key: K) {
|
||||
const removed = this.set.delete(key);
|
||||
if (removed) {
|
||||
const idx = this.items.findIndex((i) => i.key === key);
|
||||
if (idx !== -1) {
|
||||
this.items.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
push(key: K, value: T, priority: number) {
|
||||
if (this.set.has(key)) {
|
||||
return this.length;
|
||||
}
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (this.items[i].priority > priority) {
|
||||
this.set.add(key);
|
||||
this.items.splice(i, 0, { key, value, priority });
|
||||
return this.length;
|
||||
}
|
||||
}
|
||||
this.set.add(key);
|
||||
return this.items.push({ key, value, priority });
|
||||
}
|
||||
|
||||
shift() {
|
||||
let item = this.items.shift();
|
||||
while (item) {
|
||||
if (this.set.has(item.key)) {
|
||||
this.set.delete(item.key);
|
||||
return item;
|
||||
}
|
||||
item = this.items.shift();
|
||||
}
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.set.size;
|
||||
}
|
||||
}
|
||||
|
|
@ -49,18 +49,21 @@ export function getJustifiedLayoutFromAssets(
|
|||
type Geometry = ReturnType<typeof createJustifiedLayout>;
|
||||
class Adapter {
|
||||
result;
|
||||
width;
|
||||
constructor(result: Geometry) {
|
||||
this.result = result;
|
||||
this.width = 0;
|
||||
for (const box of this.result.boxes) {
|
||||
if (box.top < 100) {
|
||||
this.width = box.left + box.width;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get containerWidth() {
|
||||
let width = 0;
|
||||
for (const box of this.result.boxes) {
|
||||
if (box.top < 100) {
|
||||
width = box.left + box.width;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
return this.width;
|
||||
}
|
||||
|
||||
get containerHeight() {
|
||||
|
|
@ -84,12 +87,6 @@ class Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
export const emptyGeometry = new Adapter({
|
||||
containerHeight: 0,
|
||||
widowCount: 0,
|
||||
boxes: [],
|
||||
});
|
||||
|
||||
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
|
||||
const adapter = {
|
||||
targetRowHeight: options.rowHeight,
|
||||
|
|
@ -104,3 +101,26 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
|
|||
);
|
||||
return new Adapter(result);
|
||||
}
|
||||
|
||||
export const emptyGeometry = () =>
|
||||
new Adapter({
|
||||
containerHeight: 0,
|
||||
widowCount: 0,
|
||||
boxes: [],
|
||||
});
|
||||
|
||||
export type CommonPosition = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function getPosition(geometry: CommonJustifiedLayout, boxIdx: number): CommonPosition {
|
||||
const top = geometry.getTop(boxIdx);
|
||||
const left = geometry.getLeft(boxIdx);
|
||||
const width = geometry.getWidth(boxIdx);
|
||||
const height = geometry.getHeight(boxIdx);
|
||||
|
||||
return { top, left, width, height };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
export class PriorityQueue<T> {
|
||||
private items: { value: T; priority: number }[] = [];
|
||||
|
||||
push(value: T, priority: number) {
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (this.items[i].priority > priority) {
|
||||
this.items.splice(i, 0, { value, priority });
|
||||
return this.length;
|
||||
}
|
||||
}
|
||||
return this.items.push({ value, priority });
|
||||
}
|
||||
|
||||
shift() {
|
||||
return this.items.shift();
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this.items.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
import type { AssetBucket } from '$lib/stores/assets-store.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { emptyGeometry, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||
import { type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { groupBy, memoize, sortBy } from 'lodash-es';
|
||||
import { memoize } from 'lodash-es';
|
||||
import { DateTime, type LocaleOptions } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export type DateGroup = {
|
||||
bucket: AssetBucket;
|
||||
index: number;
|
||||
row: number;
|
||||
col: number;
|
||||
date: DateTime;
|
||||
groupTitle: string;
|
||||
assets: AssetResponseDto[];
|
||||
assetsIntersecting: boolean[];
|
||||
height: number;
|
||||
heightActual: boolean;
|
||||
intersecting: boolean;
|
||||
geometry: CommonJustifiedLayout;
|
||||
bucket: AssetBucket;
|
||||
};
|
||||
export type ScrubberListener = (
|
||||
bucketDate: string | undefined,
|
||||
|
|
@ -40,6 +43,31 @@ export const fromLocalDateTime = (localDateTime: string) =>
|
|||
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
|
||||
DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
|
||||
|
||||
export type LayoutBox = {
|
||||
aspectRatio: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
forcedAspectRatio?: boolean;
|
||||
};
|
||||
|
||||
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
|
||||
let offset = 0;
|
||||
while (element.offsetParent && element !== stop) {
|
||||
offset += element.offsetTop;
|
||||
element = element.offsetParent as HTMLElement;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
export const groupDateFormat: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
export function formatGroupTitle(_date: DateTime): string {
|
||||
if (!_date.isValid) {
|
||||
return _date.toString();
|
||||
|
|
@ -73,56 +101,7 @@ export function formatGroupTitle(_date: DateTime): string {
|
|||
|
||||
return getDateLocaleString(date);
|
||||
}
|
||||
|
||||
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
||||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
||||
|
||||
const formatDateGroupTitle = memoize(formatGroupTitle);
|
||||
|
||||
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
|
||||
const grouped = groupBy(bucket.assets, (asset) =>
|
||||
getDateLocaleString(fromLocalDateTime(asset.localDateTime), { locale }),
|
||||
);
|
||||
const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0]));
|
||||
return sorted.map((group) => {
|
||||
const date = fromLocalDateTime(group[0].localDateTime).startOf('day');
|
||||
return {
|
||||
date,
|
||||
groupTitle: formatDateGroupTitle(date),
|
||||
assets: group,
|
||||
height: 0,
|
||||
heightActual: false,
|
||||
intersecting: false,
|
||||
geometry: emptyGeometry,
|
||||
bucket,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type LayoutBox = {
|
||||
aspectRatio: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
forcedAspectRatio?: boolean;
|
||||
};
|
||||
|
||||
export function calculateWidth(boxes: LayoutBox[]): number {
|
||||
let width = 0;
|
||||
for (const box of boxes) {
|
||||
if (box.top < 100) {
|
||||
width = box.left + box.width;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
|
||||
let offset = 0;
|
||||
while (element.offsetParent && element !== stop) {
|
||||
offset += element.offsetTop;
|
||||
element = element.offsetParent as HTMLElement;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
export const formatDateGroupTitle = memoize(formatGroupTitle);
|
||||
|
|
|
|||
|
|
@ -10,56 +10,17 @@ function getNumber(string: string | null, fallback: number) {
|
|||
}
|
||||
return Number.parseInt(string);
|
||||
}
|
||||
function getFloat(string: string | null, fallback: number) {
|
||||
if (string === null) {
|
||||
return fallback;
|
||||
}
|
||||
return Number.parseFloat(string);
|
||||
}
|
||||
export const TUNABLES = {
|
||||
LAYOUT: {
|
||||
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
|
||||
},
|
||||
SCROLL_TASK_QUEUE: {
|
||||
TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25),
|
||||
TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5),
|
||||
TRICKLE_ACCELERATED_MIN_DELAY: getNumber(
|
||||
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'),
|
||||
8,
|
||||
),
|
||||
TRICKLE_ACCELERATED_MAX_DELAY: getNumber(
|
||||
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'),
|
||||
2000,
|
||||
),
|
||||
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15),
|
||||
DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16),
|
||||
MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200),
|
||||
CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16),
|
||||
},
|
||||
INTERSECTION_OBSERVER_QUEUE: {
|
||||
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15),
|
||||
THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16),
|
||||
THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true),
|
||||
TIMELINE: {
|
||||
INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
|
||||
INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
|
||||
},
|
||||
ASSET_GRID: {
|
||||
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
||||
},
|
||||
BUCKET: {
|
||||
PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2),
|
||||
INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%',
|
||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%',
|
||||
},
|
||||
DATEGROUP: {
|
||||
PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4),
|
||||
INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false),
|
||||
INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%',
|
||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%',
|
||||
},
|
||||
THUMBNAIL: {
|
||||
PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8),
|
||||
INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%',
|
||||
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%',
|
||||
},
|
||||
IMAGE_THUMBNAIL: {
|
||||
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue