mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web): Scroll to asset in gridview; increase gridview perf; reduce memory; scrollbar ticks in fixed position (#10646)
* Squashed * Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation * Reduce jank on scroll, delay DOM updates until after scroll * css opt, log measure time * Trickle out queue while scrolling, flush when stopped * yay * Cleanup cleanup... * everybody... * everywhere... * Clean up cleanup! * Everybody do their share * CLEANUP! * package-lock ? * dynamic measure, todo * Fix web test * type lint * fix e2e * e2e test * Better scrollbar * Tuning, and more tunables * Tunable tweaks, more tunables * Scrollbar dots and viewport events * lint * Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes * New tunables, and don't update url by default * Bug fixes * Bug fix, with debug * Fix flickr, fix graybox bug, reduced debug * Refactor/cleanup * Fix * naming * Final cleanup * review comment * Forgot to update this after naming change * scrubber works, with debug * cleanup * Rename scrollbar to scrubber * rename to * left over rename and change to previous album bar * bugfix addassets, comments * missing destroy(), cleanup --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
07538299cf
commit
837b1e4929
50 changed files with 2947 additions and 843 deletions
465
web/src/lib/utils/asset-store-task-manager.ts
Normal file
465
web/src/lib/utils/asset-store-task-manager.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
import type { AssetBucket, AssetStore } from '$lib/stores/assets.store';
|
||||
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);
|
||||
}
|
||||
|
||||
seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(bucket);
|
||||
bucketTask.scheduleSeparated(componentId, seperated);
|
||||
}
|
||||
|
||||
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
|
||||
}
|
||||
|
||||
seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
|
||||
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
|
||||
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
|
||||
dateGroupTask.separatedThumbnail(componentId, asset, seperated);
|
||||
}
|
||||
}
|
||||
|
||||
class IntersectionTask {
|
||||
internalTaskManager: InternalTaskManager;
|
||||
seperatedKey;
|
||||
intersectedKey;
|
||||
priority;
|
||||
|
||||
intersected: Task | undefined;
|
||||
separated: Task | undefined;
|
||||
|
||||
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
|
||||
this.internalTaskManager = internalTaskManager;
|
||||
this.seperatedKey = 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 };
|
||||
}
|
||||
|
||||
trackSeperatedTask(componentId: string, task: Task) {
|
||||
const execTask = () => {
|
||||
if (this.intersected) {
|
||||
return;
|
||||
}
|
||||
task?.();
|
||||
};
|
||||
this.separated = execTask;
|
||||
const cleanup = () => {
|
||||
this.separated = undefined;
|
||||
this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
|
||||
};
|
||||
return { task: execTask, cleanup };
|
||||
}
|
||||
|
||||
removePendingSeparated() {
|
||||
if (this.separated) {
|
||||
this.internalTaskManager.removeSeparateTask(this.seperatedKey);
|
||||
}
|
||||
}
|
||||
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: componentId,
|
||||
priority: this.priority,
|
||||
taskId: this.intersectedKey,
|
||||
});
|
||||
}
|
||||
|
||||
scheduleSeparated(componentId: string, separated: Task) {
|
||||
this.removePendingIntersected();
|
||||
|
||||
if (this.separated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { task, cleanup } = this.trackSeperatedTask(componentId, separated);
|
||||
this.internalTaskManager.queueSeparateTask({
|
||||
task,
|
||||
cleanup,
|
||||
componentId: componentId,
|
||||
taskId: this.seperatedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
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, seperated: Task) {
|
||||
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
|
||||
thumbnailTask.scheduleSeparated(componentId, seperated);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue