mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
refactor: timeline manager renames (#19007)
* refactor: timeline manager renames * refactor(web): improve timeline manager naming consistency - Rename AddContext → GroupInsertionCache for clearer purpose - Rename TimelineDay → DayGroup for better clarity - Rename TimelineMonth → MonthGroup for better clarity - Replace all "bucket" references with "monthGroup" terminology - Update all component props, method names, and variable references - Maintain consistent naming patterns across TypeScript and Svelte files * refactor(web): rename buckets to months in timeline manager - Rename TimelineManager.buckets property to months - Update all store.buckets references to store.months - Use 'month' shorthand for monthGroup arguments (not method names) - Update component templates and test files for consistency - Maintain API-related 'bucket' terminology (bucketHeight, getTimeBucket) * refactor(web): rename assetStore to timelineManager and update types - Rename assetStore variables to timelineManager in all .svelte files - Update parameter names in actions.ts and asset-utils.ts functions - Rename AssetStoreLayoutOptions to TimelineManagerLayoutOptions - Rename AssetStoreOptions to TimelineManagerOptions - Move assets-store.spec.ts to timeline-manager.spec.ts * refactor(web): rename intersectingAssets to viewerAssets and fix property references - Rename intersectingAssets to viewerAssets in DayGroup and MonthGroup classes - Update arrow function parameters to use viewerAsset/viewAsset shorthand - Rename topIntersectingBucket to topIntersectingMonthGroup - Fix dateGroups references to dayGroups in asset-utils.ts and album page - Update template loops and variable names in Svelte components * refactor(web): rename #initializeTimeBuckets to #initializeMonthGroups and bucketDateFormatted to monthGroupTitle * refactor(web): rename monthGroupHeight to height * refactor(web): rename bucketCount to assetsCount, bucketsIterator to monthGroupIterator, and related properties * refactor(web): rename count to assetCount in TimelineManager * refactor(web): rename LiteBucket to ScrubberMonth and update scrubber variables - Rename LiteBucket type to ScrubberMonth - Rename bucketDateFormattted to title in ScrubberMonth type - Rename bucketPercentY to monthGroupPercentY in scrubber component - Rename scrubBucket to scrubberMonth and scrubBucketPercent to scrubberMonthPercent * fix remaining refs to bucket * reset submodule to correct commit * reset submodule to correct commit * refactor(web): extract TimelineManager internals into separate modules - Move search-related functions to internal/search-support.svelte.ts - Extract websocket event handling into WebsocketSupport class - Move utility functions (updateObject, isMismatched) to internal/utils.svelte.ts - Update imports in tests to use new module structure * refactor(web): extract intersection logic from TimelineManager - Create intersection-support.svelte.ts with updateIntersection and calculateIntersecting functions - Remove private intersection methods from TimelineManager - Export findMonthGroupForAsset from search-support for reuse - Update TimelineManager to use the extracted intersection functions * refactor(web): rename a few methods in intersecting * refactor(web): rename a few methods in intersecting * refactor(web): extract layout logic from TimelineManager - Create layout-support.svelte.ts with updateGeometry and layoutMonthGroup functions - Remove private layout methods from TimelineManager - Update TimelineManager to use the extracted layout functions - Remove unused UpdateGeometryOptions import * refactor(web): extract asset operations from TimelineManager - Create operations-support.svelte.ts with addAssetsToMonthGroups and runAssetOperation functions - Remove private asset operation methods from TimelineManager - Update TimelineManager to use extracted operation functions with proper AssetOrder handling - Rename getMonthGroupIndexByAssetId to getMonthGroupByAssetId for consistency - Move utility functions from utils.svelte.ts to internal/utils.svelte.ts - Fix method name references in asset-grid.svelte and tests * refactor(web): extract loading logic from TimelineManager - Create load-support.svelte.ts with loadFromTimeBuckets function - Extract time bucket loading, album asset handling, and error logging - Simplify TimelineManager's loadMonthGroup method to use extracted function * refresh timeline after archive keyboard shortcut * remove debugger * rename * Review comments - remove shadowed var * reduce indents - early return * review comment * refactor: simplify asset filtering in addAssets method Replace for loop with filter operation for better readability * fix: bad merge * refactor(web): simplify timeline layout algorithm - Replace rowSpaceRemaining array with direct cumulative width tracking - Invert logic from tracking remaining space to tracking used space - Fix spelling: cummulative to cumulative - Rename lastRowHeight to currentRowHeight for clarity - Remove confusing lastRow variable and simplify final height calculation - Add explanatory comments for clarity - Rename loop variable assetGroup to dayGroup for consistency * simplify assetsIterator usage * merge/lint --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
6499057b4c
commit
4b4ee5abf3
39 changed files with 2288 additions and 2139 deletions
|
|
@ -1,60 +0,0 @@
|
|||
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import type { AssetBucket } from './asset-bucket.svelte';
|
||||
import type { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class AddContext {
|
||||
#lookupCache: {
|
||||
[year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
changedDateGroups = new Set<AssetDateGroup>();
|
||||
newDateGroups = new Set<AssetDateGroup>();
|
||||
|
||||
getDateGroup({ year, month, day }: TimelinePlainDate): AssetDateGroup | undefined {
|
||||
return this.#lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDateGroup(dateGroup: AssetDateGroup, { year, month, day }: TimelinePlainDate) {
|
||||
if (!this.#lookupCache[year]) {
|
||||
this.#lookupCache[year] = {};
|
||||
}
|
||||
if (!this.#lookupCache[year][month]) {
|
||||
this.#lookupCache[year][month] = {};
|
||||
}
|
||||
this.#lookupCache[year][month][day] = dateGroup;
|
||||
}
|
||||
|
||||
get existingDateGroups() {
|
||||
return this.changedDateGroups.difference(this.newDateGroups);
|
||||
}
|
||||
|
||||
get updatedBuckets() {
|
||||
const updated = new Set<AssetBucket>();
|
||||
for (const group of this.changedDateGroups) {
|
||||
updated.add(group.bucket);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
get bucketsWithNewDateGroups() {
|
||||
const updated = new Set<AssetBucket>();
|
||||
for (const group of this.newDateGroups) {
|
||||
updated.add(group.bucket);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,934 +0,0 @@
|
|||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import {
|
||||
plainDateTimeCompare,
|
||||
toISOYearMonthUTC,
|
||||
toTimelineAsset,
|
||||
type TimelinePlainDate,
|
||||
type TimelinePlainDateTime,
|
||||
type TimelinePlainYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { getAssetInfo, getTimeBucket, getTimeBuckets } from '@immich/sdk';
|
||||
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { Unsubscriber } from 'svelte/store';
|
||||
import { AddContext } from './add-context.svelte';
|
||||
import { AssetBucket } from './asset-bucket.svelte';
|
||||
import { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
AssetStoreLayoutOptions,
|
||||
AssetStoreOptions,
|
||||
Direction,
|
||||
LiteBucket,
|
||||
PendingChange,
|
||||
TimelineAsset,
|
||||
UpdateGeometryOptions,
|
||||
Viewport,
|
||||
} from './types';
|
||||
import { isMismatched, updateObject } from './utils.svelte';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export class AssetStore {
|
||||
isInitialized = $state(false);
|
||||
buckets: AssetBucket[] = $state([]);
|
||||
topSectionHeight = $state(0);
|
||||
timelineHeight = $derived(
|
||||
this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight,
|
||||
);
|
||||
count = $derived(this.buckets.reduce((accumulator, b) => accumulator + b.bucketCount, 0));
|
||||
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
|
||||
scrubberBuckets: LiteBucket[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
topIntersectingBucket: AssetBucket | undefined = $state();
|
||||
|
||||
visibleWindow = $derived.by(() => ({
|
||||
top: this.#scrollTop,
|
||||
bottom: this.#scrollTop + this.viewportHeight,
|
||||
}));
|
||||
|
||||
initTask = new CancellableTask(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
},
|
||||
() => {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
);
|
||||
|
||||
static #INIT_OPTIONS = {};
|
||||
#viewportHeight = $state(0);
|
||||
#viewportWidth = $state(0);
|
||||
#scrollTop = $state(0);
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
|
||||
#rowHeight = $state(235);
|
||||
#headerHeight = $state(48);
|
||||
#gap = $state(12);
|
||||
|
||||
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
|
||||
|
||||
#scrolling = $state(false);
|
||||
#suspendTransitions = $state(false);
|
||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||
scrollCompensation: {
|
||||
heightDelta: number | undefined;
|
||||
scrollTop: number | undefined;
|
||||
bucket: AssetBucket | undefined;
|
||||
} = $state({
|
||||
heightDelta: 0,
|
||||
scrollTop: 0,
|
||||
bucket: undefined,
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: AssetStoreLayoutOptions) {
|
||||
let changed = false;
|
||||
changed ||= this.#setHeaderHeight(headerHeight);
|
||||
changed ||= this.#setGap(gap);
|
||||
changed ||= this.#setRowHeight(rowHeight);
|
||||
if (changed) {
|
||||
this.refreshLayout();
|
||||
}
|
||||
}
|
||||
|
||||
#setHeaderHeight(value: number) {
|
||||
if (this.#headerHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#headerHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get headerHeight() {
|
||||
return this.#headerHeight;
|
||||
}
|
||||
|
||||
#setGap(value: number) {
|
||||
if (this.#gap == value) {
|
||||
return false;
|
||||
}
|
||||
this.#gap = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get gap() {
|
||||
return this.#gap;
|
||||
}
|
||||
|
||||
#setRowHeight(value: number) {
|
||||
if (this.#rowHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#rowHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get rowHeight() {
|
||||
return this.#rowHeight;
|
||||
}
|
||||
|
||||
set scrolling(value: boolean) {
|
||||
this.#scrolling = value;
|
||||
if (value) {
|
||||
this.suspendTransitions = true;
|
||||
this.#resetScrolling();
|
||||
}
|
||||
}
|
||||
|
||||
get scrolling() {
|
||||
return this.#scrolling;
|
||||
}
|
||||
|
||||
set suspendTransitions(value: boolean) {
|
||||
this.#suspendTransitions = value;
|
||||
if (value) {
|
||||
this.#resetSuspendTransitions();
|
||||
}
|
||||
}
|
||||
|
||||
get suspendTransitions() {
|
||||
return this.#suspendTransitions;
|
||||
}
|
||||
|
||||
set viewportWidth(value: number) {
|
||||
const changed = value !== this.#viewportWidth;
|
||||
this.#viewportWidth = value;
|
||||
this.suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(changed);
|
||||
}
|
||||
|
||||
get viewportWidth() {
|
||||
return this.#viewportWidth;
|
||||
}
|
||||
|
||||
set viewportHeight(value: number) {
|
||||
this.#viewportHeight = value;
|
||||
this.#suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
get viewportHeight() {
|
||||
return this.#viewportHeight;
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startBucket?: AssetBucket;
|
||||
startDateGroup?: AssetDateGroup;
|
||||
startAsset?: TimelineAsset;
|
||||
direction?: Direction;
|
||||
}) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startDateGroup, startAsset } = options ?? {};
|
||||
for (const bucket of this.bucketsIterator({ direction, startBucket: options?.startBucket })) {
|
||||
await this.loadBucket(bucket.yearMonth, { cancelable: false });
|
||||
yield* bucket.assetsIterator({ startDateGroup, startAsset, direction });
|
||||
startDateGroup = startAsset = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
*bucketsIterator(options?: { direction?: Direction; startBucket?: AssetBucket }) {
|
||||
const isEarlier = options?.direction === 'earlier';
|
||||
let startIndex = options?.startBucket
|
||||
? this.buckets.indexOf(options.startBucket)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.buckets.length - 1;
|
||||
|
||||
while (startIndex >= 0 && startIndex < this.buckets.length) {
|
||||
yield this.buckets[startIndex];
|
||||
startIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
#addPendingChanges(...changes: PendingChange[]) {
|
||||
this.#pendingChanges.push(...changes);
|
||||
this.#processPendingChanges();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.#unsubscribers.push(
|
||||
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: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
||||
);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
for (const unsubscribe of this.#unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.#unsubscribers = [];
|
||||
}
|
||||
|
||||
#getPendingChangeBatches() {
|
||||
const batch: {
|
||||
add: TimelineAsset[];
|
||||
update: TimelineAsset[];
|
||||
remove: string[];
|
||||
} = {
|
||||
add: [],
|
||||
update: [],
|
||||
remove: [],
|
||||
};
|
||||
for (const { type, values } of this.#pendingChanges) {
|
||||
switch (type) {
|
||||
case 'add': {
|
||||
batch.add.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
batch.update.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'delete':
|
||||
case 'trash': {
|
||||
batch.remove.push(...values);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
#findBucketForAsset(id: string) {
|
||||
for (const bucket of this.buckets) {
|
||||
const asset = bucket.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { bucket, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#findBucketForDate(targetYearMonth: TimelinePlainYearMonth) {
|
||||
for (const bucket of this.buckets) {
|
||||
const { year, month } = bucket.yearMonth;
|
||||
if (month === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSlidingWindow(scrollTop: number) {
|
||||
if (this.#scrollTop !== scrollTop) {
|
||||
this.#scrollTop = scrollTop;
|
||||
this.updateIntersections();
|
||||
}
|
||||
}
|
||||
|
||||
clearScrollCompensation() {
|
||||
this.scrollCompensation = {
|
||||
heightDelta: undefined,
|
||||
scrollTop: undefined,
|
||||
bucket: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
updateIntersections() {
|
||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
return;
|
||||
}
|
||||
let topIntersectingBucket = undefined;
|
||||
for (const bucket of this.buckets) {
|
||||
this.#updateIntersection(bucket);
|
||||
if (!topIntersectingBucket && bucket.actuallyIntersecting) {
|
||||
topIntersectingBucket = bucket;
|
||||
}
|
||||
}
|
||||
if (topIntersectingBucket !== undefined && this.topIntersectingBucket !== topIntersectingBucket) {
|
||||
this.topIntersectingBucket = topIntersectingBucket;
|
||||
}
|
||||
for (const bucket of this.buckets) {
|
||||
if (bucket === this.topIntersectingBucket) {
|
||||
this.topIntersectingBucket.percent = clamp(
|
||||
(this.visibleWindow.top - this.topIntersectingBucket.top) / this.topIntersectingBucket.bucketHeight,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
} else {
|
||||
bucket.percent = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#calculateIntersecting(bucket: AssetBucket, expandTop: number, expandBottom: number) {
|
||||
const bucketTop = bucket.top;
|
||||
const bucketBottom = bucketTop + bucket.bucketHeight;
|
||||
const topWindow = this.visibleWindow.top - expandTop;
|
||||
const bottomWindow = this.visibleWindow.bottom + expandBottom;
|
||||
|
||||
return (
|
||||
(bucketTop >= topWindow && bucketTop < bottomWindow) ||
|
||||
(bucketBottom >= topWindow && bucketBottom < bottomWindow) ||
|
||||
(bucketTop < topWindow && bucketBottom >= bottomWindow)
|
||||
);
|
||||
}
|
||||
|
||||
clearDeferredLayout(bucket: AssetBucket) {
|
||||
const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout);
|
||||
if (hasDeferred) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true, noDefer: true });
|
||||
for (const group of bucket.dateGroups) {
|
||||
group.deferredLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#updateIntersection(bucket: AssetBucket) {
|
||||
const actuallyIntersecting = this.#calculateIntersecting(bucket, 0, 0);
|
||||
let preIntersecting = false;
|
||||
if (!actuallyIntersecting) {
|
||||
preIntersecting = this.#calculateIntersecting(bucket, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM);
|
||||
}
|
||||
bucket.intersecting = actuallyIntersecting || preIntersecting;
|
||||
bucket.actuallyIntersecting = actuallyIntersecting;
|
||||
if (preIntersecting || actuallyIntersecting) {
|
||||
this.clearDeferredLayout(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
#processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.addAssets(add);
|
||||
}
|
||||
if (update.length > 0) {
|
||||
this.updateAssets(update);
|
||||
}
|
||||
if (remove.length > 0) {
|
||||
this.removeAssets(remove);
|
||||
}
|
||||
this.#pendingChanges = [];
|
||||
}, 2500);
|
||||
|
||||
async #initializeTimeBuckets() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...this.#options,
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
this.buckets = timebuckets.map((bucket) => {
|
||||
const date = new Date(bucket.timeBucket);
|
||||
return new AssetBucket(
|
||||
this,
|
||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||
bucket.count,
|
||||
this.#options.order,
|
||||
);
|
||||
});
|
||||
this.albumAssets.clear();
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async updateOptions(options: AssetStoreOptions) {
|
||||
if (options.deferInit) {
|
||||
return;
|
||||
}
|
||||
if (this.#options !== AssetStore.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async #init(options: AssetStoreOptions) {
|
||||
this.isInitialized = false;
|
||||
this.buckets = [];
|
||||
this.albumAssets.clear();
|
||||
await this.initTask.execute(async () => {
|
||||
this.#options = options;
|
||||
await this.#initializeTimeBuckets();
|
||||
}, true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport) {
|
||||
if (viewport.height === 0 && viewport.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.initTask.executed) {
|
||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
}
|
||||
|
||||
const changedWidth = viewport.width !== this.viewportWidth;
|
||||
this.viewportHeight = viewport.height;
|
||||
this.viewportWidth = viewport.width;
|
||||
this.#updateViewportGeometry(changedWidth);
|
||||
}
|
||||
|
||||
#updateViewportGeometry(changedWidth: boolean) {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
||||
return;
|
||||
}
|
||||
for (const bucket of this.buckets) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: changedWidth });
|
||||
}
|
||||
this.updateIntersections();
|
||||
this.#createScrubBuckets();
|
||||
}
|
||||
|
||||
#createScrubBuckets() {
|
||||
this.scrubberBuckets = this.buckets.map((bucket) => ({
|
||||
assetCount: bucket.bucketCount,
|
||||
year: bucket.yearMonth.year,
|
||||
month: bucket.yearMonth.month,
|
||||
bucketDateFormattted: bucket.bucketDateFormatted,
|
||||
bucketHeight: bucket.bucketHeight,
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.timelineHeight;
|
||||
}
|
||||
|
||||
createLayoutOptions() {
|
||||
const viewportWidth = this.viewportWidth;
|
||||
|
||||
return {
|
||||
spacing: 2,
|
||||
heightTolerance: 0.15,
|
||||
rowHeight: this.#rowHeight,
|
||||
rowWidth: Math.floor(viewportWidth),
|
||||
};
|
||||
}
|
||||
|
||||
#updateGeometry(bucket: AssetBucket, options: UpdateGeometryOptions) {
|
||||
const { invalidateHeight, noDefer = false } = options;
|
||||
if (invalidateHeight) {
|
||||
bucket.isBucketHeightActual = false;
|
||||
}
|
||||
if (!bucket.isLoaded) {
|
||||
const viewportWidth = this.viewportWidth;
|
||||
if (!bucket.isBucketHeightActual) {
|
||||
const unwrappedWidth = (3 / 2) * bucket.bucketCount * this.#rowHeight * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||
const height = 51 + Math.max(1, rows) * this.#rowHeight;
|
||||
bucket.bucketHeight = height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#layoutBucket(bucket, noDefer);
|
||||
}
|
||||
|
||||
#layoutBucket(bucket: AssetBucket, noDefer: boolean = false) {
|
||||
let cummulativeHeight = 0;
|
||||
let cummulativeWidth = 0;
|
||||
let lastRowHeight = 0;
|
||||
let lastRow = 0;
|
||||
|
||||
let dateGroupRow = 0;
|
||||
let dateGroupCol = 0;
|
||||
|
||||
const rowSpaceRemaining: number[] = Array.from({ length: bucket.dateGroups.length });
|
||||
rowSpaceRemaining.fill(this.viewportWidth, 0, bucket.dateGroups.length);
|
||||
const options = this.createLayoutOptions();
|
||||
for (const assetGroup of bucket.dateGroups) {
|
||||
assetGroup.layout(options, noDefer);
|
||||
rowSpaceRemaining[dateGroupRow] -= assetGroup.width - 1;
|
||||
if (dateGroupCol > 0) {
|
||||
rowSpaceRemaining[dateGroupRow] -= this.gap;
|
||||
}
|
||||
if (rowSpaceRemaining[dateGroupRow] >= 0) {
|
||||
assetGroup.row = dateGroupRow;
|
||||
assetGroup.col = dateGroupCol;
|
||||
assetGroup.left = cummulativeWidth;
|
||||
assetGroup.top = cummulativeHeight;
|
||||
|
||||
dateGroupCol++;
|
||||
|
||||
cummulativeWidth += assetGroup.width + this.gap;
|
||||
} else {
|
||||
cummulativeWidth = 0;
|
||||
dateGroupRow++;
|
||||
dateGroupCol = 0;
|
||||
assetGroup.row = dateGroupRow;
|
||||
assetGroup.col = dateGroupCol;
|
||||
assetGroup.left = cummulativeWidth;
|
||||
|
||||
rowSpaceRemaining[dateGroupRow] -= assetGroup.width;
|
||||
dateGroupCol++;
|
||||
cummulativeHeight += lastRowHeight;
|
||||
assetGroup.top = cummulativeHeight;
|
||||
cummulativeWidth += assetGroup.width + this.gap;
|
||||
lastRow = assetGroup.row - 1;
|
||||
}
|
||||
lastRowHeight = assetGroup.height + this.headerHeight;
|
||||
}
|
||||
if (lastRow === 0 || lastRow !== bucket.lastDateGroup?.row) {
|
||||
cummulativeHeight += lastRowHeight;
|
||||
}
|
||||
|
||||
bucket.bucketHeight = cummulativeHeight;
|
||||
bucket.isBucketHeightActual = true;
|
||||
}
|
||||
|
||||
async loadBucket(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||
let cancelable = true;
|
||||
if (options) {
|
||||
cancelable = options.cancelable;
|
||||
}
|
||||
const bucket = this.getBucketByDate(yearMonth);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bucket.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bucket.loader?.execute(async (signal: AbortSignal) => {
|
||||
if (bucket.getFirstAsset()) {
|
||||
return;
|
||||
}
|
||||
const timeBucket = toISOYearMonthUTC(bucket.yearMonth);
|
||||
const key = authManager.key;
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...this.#options,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (bucketResponse) {
|
||||
if (this.#options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
albumId: this.#options.timelineAlbumId,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const id of albumAssets.id) {
|
||||
this.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
const unprocessedAssets = bucket.addAssets(bucketResponse);
|
||||
if (unprocessedAssets.length > 0) {
|
||||
console.error(
|
||||
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.yearMonth.month}, ${JSON.stringify(
|
||||
unprocessedAssets.map((unprocessed) => ({
|
||||
id: unprocessed.id,
|
||||
localDateTime: unprocessed.localDateTime,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
this.#layoutBucket(bucket);
|
||||
}
|
||||
}, cancelable);
|
||||
if (result === 'LOADED') {
|
||||
this.#updateIntersection(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate: TimelineAsset[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
if (this.isExcluded(asset)) {
|
||||
continue;
|
||||
}
|
||||
assetsToUpdate.push(asset);
|
||||
}
|
||||
|
||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||
this.#addAssetsToBuckets([...notUpdated]);
|
||||
}
|
||||
|
||||
#addAssetsToBuckets(assets: TimelineAsset[]) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addContext = new AddContext();
|
||||
const updatedBuckets = new Set<AssetBucket>();
|
||||
const bucketCount = this.buckets.length;
|
||||
for (const asset of assets) {
|
||||
let bucket = this.getBucketByDate(asset.localDateTime);
|
||||
|
||||
if (!bucket) {
|
||||
bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order);
|
||||
bucket.isLoaded = true;
|
||||
this.buckets.push(bucket);
|
||||
}
|
||||
|
||||
bucket.addTimelineAsset(asset, addContext);
|
||||
updatedBuckets.add(bucket);
|
||||
}
|
||||
|
||||
if (this.buckets.length !== bucketCount) {
|
||||
this.buckets.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDateGroups) {
|
||||
group.sortAssets(this.#options.order);
|
||||
}
|
||||
|
||||
for (const bucket of addContext.bucketsWithNewDateGroups) {
|
||||
bucket.sortDateGroups();
|
||||
}
|
||||
|
||||
for (const bucket of addContext.updatedBuckets) {
|
||||
bucket.sortDateGroups();
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getBucketByDate(targetYearMonth: TimelinePlainYearMonth): AssetBucket | undefined {
|
||||
return this.buckets.find(
|
||||
(bucket) => bucket.yearMonth.year === targetYearMonth.year && bucket.yearMonth.month === targetYearMonth.month,
|
||||
);
|
||||
}
|
||||
|
||||
async findBucketForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
let { bucket } = this.#findBucketForAsset(id) ?? {};
|
||||
if (bucket) {
|
||||
return bucket;
|
||||
}
|
||||
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (bucket?.findAssetById({ id })) {
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadBucketAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.loadBucket(yearMonth, options);
|
||||
return this.getBucketByDate(yearMonth);
|
||||
}
|
||||
|
||||
getBucketIndexByAssetId(assetId: string) {
|
||||
const bucketInfo = this.#findBucketForAsset(assetId);
|
||||
return bucketInfo?.bucket;
|
||||
}
|
||||
|
||||
async getRandomBucket() {
|
||||
const random = Math.floor(Math.random() * this.buckets.length);
|
||||
const bucket = this.buckets[random];
|
||||
await this.loadBucket(bucket.yearMonth, { cancelable: false });
|
||||
return bucket;
|
||||
}
|
||||
|
||||
async getRandomAsset() {
|
||||
const bucket = await this.getRandomBucket();
|
||||
return bucket?.getRandomAsset();
|
||||
}
|
||||
|
||||
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
|
||||
}
|
||||
|
||||
const changedBuckets = new Set<AssetBucket>();
|
||||
let idsToProcess = new Set(ids);
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
|
||||
for (const bucket of this.buckets) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = idsToProcess.difference(processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedBuckets.add(bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
this.#addAssetsToBuckets(combinedMoveAssets.flat().map((a) => a.asset));
|
||||
}
|
||||
const changedGeometry = changedBuckets.size > 0;
|
||||
for (const bucket of changedBuckets) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
this.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
this.#runAssetOperation(new Set(ids), operation);
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
return unprocessedIds.values().map((id) => lookup.get(id)!);
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => {
|
||||
return { remove: true };
|
||||
});
|
||||
return [...unprocessedIds];
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
for (const bucket of this.buckets) {
|
||||
this.#updateGeometry(bucket, { invalidateHeight: true });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getFirstAsset(): TimelineAsset | undefined {
|
||||
return this.buckets[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
|
||||
const bucket = this.#findBucketForDate(dateTime);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
await this.loadBucket(dateTime, { cancelable: false });
|
||||
const asset = bucket.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.assetsIterator({ startBucket: bucket })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, bucket: startBucket } = this.#findBucketForAsset(start.id) ?? {};
|
||||
if (!startBucket || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, bucket: endBucket } = this.#findBucketForAsset(end.id) ?? {};
|
||||
if (!endBucket || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
let direction: Direction = 'earlier';
|
||||
if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startBucket, endBucket] = [endBucket, startBucket];
|
||||
direction = 'earlier';
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDateGroup = startBucket.findDateGroupForAsset(startAsset);
|
||||
for await (const targetAsset of this.assetsIterator({
|
||||
startBucket,
|
||||
startDateGroup,
|
||||
startAsset,
|
||||
direction,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
async #getAssetWithOffset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, bucket } = this.#findBucketForAsset(assetDescriptor.id) ?? {};
|
||||
if (!bucket || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return this.#getAssetByAssetOffset(asset, bucket, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return this.#getAssetByDayOffset(asset, bucket, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return this.#getAssetByMonthOffset(bucket, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return this.#getAssetByYearOffset(bucket, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByAssetOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
|
||||
const dateGroup = bucket.findDateGroupForAsset(asset);
|
||||
for await (const targetAsset of this.assetsIterator({
|
||||
startBucket: bucket,
|
||||
startDateGroup: dateGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id === targetAsset.id) {
|
||||
continue;
|
||||
}
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByDayOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
|
||||
const dateGroup = bucket.findDateGroupForAsset(asset);
|
||||
for await (const targetAsset of this.assetsIterator({
|
||||
startBucket: bucket,
|
||||
startDateGroup: dateGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByMonthOffset(bucket: AssetBucket, direction: Direction) {
|
||||
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
|
||||
if (targetBucket.yearMonth.month !== bucket.yearMonth.month) {
|
||||
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByYearOffset(bucket: AssetBucket, direction: Direction) {
|
||||
for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
|
||||
if (targetBucket.yearMonth.year !== bucket.yearMonth.year) {
|
||||
for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,23 @@
|
|||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
|
||||
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import type { AssetBucket } from './asset-bucket.svelte';
|
||||
import { IntersectingAsset } from './intersecting-asset.svelte';
|
||||
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
|
||||
export class AssetDateGroup {
|
||||
readonly bucket: AssetBucket;
|
||||
import type { MonthGroup } from './month-group.svelte';
|
||||
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class DayGroup {
|
||||
readonly monthGroup: MonthGroup;
|
||||
readonly index: number;
|
||||
readonly groupTitle: string;
|
||||
readonly day: number;
|
||||
intersectingAssets: IntersectingAsset[] = $state([]);
|
||||
viewerAssets: ViewerAsset[] = $state([]);
|
||||
|
||||
height = $state(0);
|
||||
width = $state(0);
|
||||
intersecting = $derived.by(() => this.intersectingAssets.some((asset) => asset.intersecting));
|
||||
intersecting = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.intersecting));
|
||||
|
||||
#top: number = $state(0);
|
||||
#left: number = $state(0);
|
||||
|
|
@ -23,9 +25,9 @@ export class AssetDateGroup {
|
|||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
|
||||
constructor(bucket: AssetBucket, index: number, day: number, groupTitle: string) {
|
||||
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
|
||||
this.index = index;
|
||||
this.bucket = bucket;
|
||||
this.monthGroup = monthGroup;
|
||||
this.day = day;
|
||||
this.groupTitle = groupTitle;
|
||||
}
|
||||
|
|
@ -72,35 +74,35 @@ export class AssetDateGroup {
|
|||
|
||||
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
|
||||
this.intersectingAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
||||
this.viewerAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
||||
}
|
||||
|
||||
getFirstAsset() {
|
||||
return this.intersectingAssets[0]?.asset;
|
||||
return this.viewerAssets[0]?.asset;
|
||||
}
|
||||
|
||||
getRandomAsset() {
|
||||
const random = Math.floor(Math.random() * this.intersectingAssets.length);
|
||||
return this.intersectingAssets[random];
|
||||
const random = Math.floor(Math.random() * this.viewerAssets.length);
|
||||
return this.viewerAssets[random];
|
||||
}
|
||||
|
||||
*assetsIterator(options: { startAsset?: TimelineAsset; direction?: Direction } = {}) {
|
||||
const isEarlier = (options?.direction ?? 'earlier') === 'earlier';
|
||||
let assetIndex = options?.startAsset
|
||||
? this.intersectingAssets.findIndex((intersectingAsset) => intersectingAsset.asset.id === options.startAsset!.id)
|
||||
? this.viewerAssets.findIndex((viewerAsset) => viewerAsset.asset.id === options.startAsset!.id)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.intersectingAssets.length - 1;
|
||||
: this.viewerAssets.length - 1;
|
||||
|
||||
while (assetIndex >= 0 && assetIndex < this.intersectingAssets.length) {
|
||||
const intersectingAsset = this.intersectingAssets[assetIndex];
|
||||
yield intersectingAsset.asset;
|
||||
while (assetIndex >= 0 && assetIndex < this.viewerAssets.length) {
|
||||
const viewerAsset = this.viewerAssets[assetIndex];
|
||||
yield viewerAsset.asset;
|
||||
assetIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
getAssets() {
|
||||
return this.intersectingAssets.map((intersectingasset) => intersectingasset.asset);
|
||||
return this.viewerAssets.map((viewerAsset) => viewerAsset.asset);
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
|
|
@ -117,12 +119,12 @@ export class AssetDateGroup {
|
|||
const moveAssets: MoveAsset[] = [];
|
||||
let changedGeometry = false;
|
||||
for (const assetId of unprocessedIds) {
|
||||
const index = this.intersectingAssets.findIndex((ia) => ia.id == assetId);
|
||||
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const asset = this.intersectingAssets[index].asset!;
|
||||
const asset = this.viewerAssets[index].asset!;
|
||||
const oldTime = { ...asset.localDateTime };
|
||||
let { remove } = operation(asset);
|
||||
const newTime = asset.localDateTime;
|
||||
|
|
@ -133,8 +135,8 @@ export class AssetDateGroup {
|
|||
}
|
||||
unprocessedIds.delete(assetId);
|
||||
processedIds.add(assetId);
|
||||
if (remove || this.bucket.store.isExcluded(asset)) {
|
||||
this.intersectingAssets.splice(index, 1);
|
||||
if (remove || this.monthGroup.timelineManager.isExcluded(asset)) {
|
||||
this.viewerAssets.splice(index, 1);
|
||||
changedGeometry = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -142,21 +144,21 @@ export class AssetDateGroup {
|
|||
}
|
||||
|
||||
layout(options: CommonLayoutOptions, noDefer: boolean) {
|
||||
if (!noDefer && !this.bucket.intersecting) {
|
||||
if (!noDefer && !this.monthGroup.intersecting) {
|
||||
this.#deferredLayout = true;
|
||||
return;
|
||||
}
|
||||
const assets = this.intersectingAssets.map((intersetingAsset) => intersetingAsset.asset!);
|
||||
const assets = this.viewerAssets.map((viewerAsset) => viewerAsset.asset!);
|
||||
const geometry = getJustifiedLayoutFromAssets(assets, options);
|
||||
this.width = geometry.containerWidth;
|
||||
this.height = assets.length === 0 ? 0 : geometry.containerHeight;
|
||||
for (let i = 0; i < this.intersectingAssets.length; i++) {
|
||||
for (let i = 0; i < this.viewerAssets.length; i++) {
|
||||
const position = getPosition(geometry, i);
|
||||
this.intersectingAssets[i].position = position;
|
||||
this.viewerAssets[i].position = position;
|
||||
}
|
||||
}
|
||||
|
||||
get absoluteDateGroupTop() {
|
||||
return this.bucket.top + this.#top;
|
||||
get absoluteDayGroupTop() {
|
||||
return this.monthGroup.top + this.#top;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import type { DayGroup } from './day-group.svelte';
|
||||
import type { MonthGroup } from './month-group.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class GroupInsertionCache {
|
||||
#lookupCache: {
|
||||
[year: number]: { [month: number]: { [day: number]: DayGroup } };
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
changedDayGroups = new Set<DayGroup>();
|
||||
newDayGroups = new Set<DayGroup>();
|
||||
|
||||
getDayGroup({ year, month, day }: TimelinePlainDate): DayGroup | undefined {
|
||||
return this.#lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDayGroup(dayGroup: DayGroup, { year, month, day }: TimelinePlainDate) {
|
||||
if (!this.#lookupCache[year]) {
|
||||
this.#lookupCache[year] = {};
|
||||
}
|
||||
if (!this.#lookupCache[year][month]) {
|
||||
this.#lookupCache[year][month] = {};
|
||||
}
|
||||
this.#lookupCache[year][month][day] = dayGroup;
|
||||
}
|
||||
|
||||
get existingDayGroups() {
|
||||
return this.changedDayGroups.difference(this.newDayGroups);
|
||||
}
|
||||
|
||||
get updatedBuckets() {
|
||||
const updated = new Set<MonthGroup>();
|
||||
for (const group of this.changedDayGroups) {
|
||||
updated.add(group.monthGroup);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
get bucketsWithNewDayGroups() {
|
||||
const updated = new Set<MonthGroup>();
|
||||
for (const group of this.newDayGroups) {
|
||||
updated.add(group.monthGroup);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
sort(monthGroup: MonthGroup, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
for (const group of this.changedDayGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
for (const group of this.newDayGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
if (this.newDayGroups.size > 0) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
|
||||
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
|
||||
let preIntersecting = false;
|
||||
if (!actuallyIntersecting) {
|
||||
preIntersecting = calculateMonthGroupIntersecting(
|
||||
timelineManager,
|
||||
month,
|
||||
INTERSECTION_EXPAND_TOP,
|
||||
INTERSECTION_EXPAND_BOTTOM,
|
||||
);
|
||||
}
|
||||
month.intersecting = actuallyIntersecting || preIntersecting;
|
||||
month.actuallyIntersecting = actuallyIntersecting;
|
||||
if (preIntersecting || actuallyIntersecting) {
|
||||
timelineManager.clearDeferredLayout(month);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* General function to check if a rectangular region intersects with a window.
|
||||
* @param regionTop - Top position of the region to check
|
||||
* @param regionBottom - Bottom position of the region to check
|
||||
* @param windowTop - Top position of the window
|
||||
* @param windowBottom - Bottom position of the window
|
||||
* @returns true if the region intersects with the window
|
||||
*/
|
||||
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
|
||||
return (
|
||||
(regionTop >= windowTop && regionTop < windowBottom) ||
|
||||
(regionBottom >= windowTop && regionBottom < windowBottom) ||
|
||||
(regionTop < windowTop && regionBottom >= windowBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateMonthGroupIntersecting(
|
||||
timelineManager: TimelineManager,
|
||||
monthGroup: MonthGroup,
|
||||
expandTop: number,
|
||||
expandBottom: number,
|
||||
) {
|
||||
const monthGroupTop = monthGroup.top;
|
||||
const monthGroupBottom = monthGroupTop + monthGroup.height;
|
||||
const topWindow = timelineManager.visibleWindow.top - expandTop;
|
||||
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
|
||||
|
||||
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
||||
*/
|
||||
export function calculateViewerAssetIntersecting(
|
||||
timelineManager: TimelineManager,
|
||||
positionTop: number,
|
||||
positionHeight: number,
|
||||
expandTop: number = INTERSECTION_EXPAND_TOP,
|
||||
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
||||
) {
|
||||
const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0;
|
||||
|
||||
const topWindow =
|
||||
timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta;
|
||||
const bottomWindow =
|
||||
timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta;
|
||||
|
||||
const positionBottom = positionTop + positionHeight;
|
||||
|
||||
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { UpdateGeometryOptions } from '../types';
|
||||
|
||||
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
|
||||
const { invalidateHeight, noDefer = false } = options;
|
||||
if (invalidateHeight) {
|
||||
month.isHeightActual = false;
|
||||
}
|
||||
if (!month.isLoaded) {
|
||||
const viewportWidth = timelineManager.viewportWidth;
|
||||
if (!month.isHeightActual) {
|
||||
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);
|
||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||
const height = 51 + Math.max(1, rows) * timelineManager.rowHeight;
|
||||
month.height = height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
layoutMonthGroup(timelineManager, month, noDefer);
|
||||
}
|
||||
|
||||
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
||||
let cumulativeHeight = 0;
|
||||
let cumulativeWidth = 0;
|
||||
let currentRowHeight = 0;
|
||||
|
||||
let dayGroupRow = 0;
|
||||
let dayGroupCol = 0;
|
||||
|
||||
const options = timelineManager.createLayoutOptions();
|
||||
for (const dayGroup of month.dayGroups) {
|
||||
dayGroup.layout(options, noDefer);
|
||||
|
||||
// Calculate space needed for this item (including gap if not first in row)
|
||||
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? timelineManager.gap : 0);
|
||||
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
|
||||
|
||||
if (fitsInCurrentRow) {
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol++;
|
||||
dayGroup.left = cumulativeWidth;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||
} else {
|
||||
// Move to next row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
cumulativeWidth = 0;
|
||||
dayGroupRow++;
|
||||
dayGroupCol = 0;
|
||||
|
||||
// Position at start of new row
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol;
|
||||
dayGroup.left = 0;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
dayGroupCol++;
|
||||
cumulativeWidth += dayGroup.width + timelineManager.gap;
|
||||
}
|
||||
currentRowHeight = dayGroup.height + timelineManager.headerHeight;
|
||||
}
|
||||
|
||||
// Add the height of the final row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
|
||||
month.height = cumulativeHeight;
|
||||
month.isHeightActual = true;
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
||||
import { getTimeBucket } from '@immich/sdk';
|
||||
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { TimelineManagerOptions } from '../types';
|
||||
import { layoutMonthGroup } from './layout-support.svelte';
|
||||
|
||||
export async function loadFromTimeBuckets(
|
||||
timelineManager: TimelineManager,
|
||||
monthGroup: MonthGroup,
|
||||
options: TimelineManagerOptions,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (monthGroup.getFirstAsset()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeBucket = toISOYearMonthUTC(monthGroup.yearMonth);
|
||||
const key = authManager.key;
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...options,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (!bucketResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
albumId: options.timelineAlbumId,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const id of albumAssets.id) {
|
||||
timelineManager.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const unprocessedAssets = monthGroup.addAssets(bucketResponse);
|
||||
if (unprocessedAssets.length > 0) {
|
||||
console.error(
|
||||
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify(
|
||||
unprocessedAssets.map((unprocessed) => ({
|
||||
id: unprocessed.id,
|
||||
localDateTime: unprocessed.localDateTime,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
layoutMonthGroup(timelineManager, monthGroup);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import type { TimelinePlainDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
|
||||
import { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetOperation, TimelineAsset } from '../types';
|
||||
import { updateGeometry } from './layout-support.svelte';
|
||||
import { getMonthGroupByDate } from './search-support.svelte';
|
||||
|
||||
export function addAssetsToMonthGroups(
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addContext = new GroupInsertionCache();
|
||||
const updatedMonthGroups = new Set<MonthGroup>();
|
||||
const monthCount = timelineManager.months.length;
|
||||
for (const asset of assets) {
|
||||
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
|
||||
month.isLoaded = true;
|
||||
timelineManager.months.push(month);
|
||||
}
|
||||
|
||||
month.addTimelineAsset(asset, addContext);
|
||||
updatedMonthGroups.add(month);
|
||||
}
|
||||
|
||||
if (timelineManager.months.length !== monthCount) {
|
||||
timelineManager.months.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDayGroups) {
|
||||
group.sortAssets(options.order);
|
||||
}
|
||||
|
||||
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
|
||||
monthGroup.sortDayGroups();
|
||||
}
|
||||
|
||||
for (const month of addContext.updatedBuckets) {
|
||||
month.sortDayGroups();
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
|
||||
export function runAssetOperation(
|
||||
timelineManager: TimelineManager,
|
||||
ids: Set<string>,
|
||||
operation: AssetOperation,
|
||||
options: { order: AssetOrder },
|
||||
) {
|
||||
if (ids.size === 0) {
|
||||
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
|
||||
}
|
||||
|
||||
const changedMonthGroups = new Set<MonthGroup>();
|
||||
let idsToProcess = new Set(ids);
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
|
||||
for (const month of timelineManager.months) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
}
|
||||
idsToProcess = idsToProcess.difference(processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonthGroups.add(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
addAssetsToMonthGroups(
|
||||
timelineManager,
|
||||
combinedMoveAssets.flat().map((a) => a.asset),
|
||||
options,
|
||||
);
|
||||
}
|
||||
const changedGeometry = changedMonthGroups.size > 0;
|
||||
for (const month of changedMonthGroups) {
|
||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
timelineManager.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { plainDateTimeCompare, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||
|
||||
export async function getAssetWithOffset(
|
||||
timelineManager: TimelineManager,
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {};
|
||||
if (!monthGroup || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return getAssetByAssetOffset(timelineManager, asset, monthGroup, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return getAssetByDayOffset(timelineManager, asset, monthGroup, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return getAssetByMonthOffset(timelineManager, monthGroup, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return getAssetByYearOffset(timelineManager, monthGroup, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findMonthGroupForAsset(timelineManager: TimelineManager, id: string) {
|
||||
for (const month of timelineManager.months) {
|
||||
const asset = month.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { monthGroup: month, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMonthGroupByDate(
|
||||
timelineManager: TimelineManager,
|
||||
targetYearMonth: TimelinePlainYearMonth,
|
||||
): MonthGroup | undefined {
|
||||
return timelineManager.months.find(
|
||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAssetByAssetOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
monthGroup: MonthGroup,
|
||||
direction: Direction,
|
||||
) {
|
||||
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup: monthGroup,
|
||||
startDayGroup: dayGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id !== targetAsset.id) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByDayOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
monthGroup: MonthGroup,
|
||||
direction: Direction,
|
||||
) {
|
||||
const dayGroup = monthGroup.findDayGroupForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup: monthGroup,
|
||||
startDayGroup: dayGroup,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByMonthOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByYearOffset(timelineManager: TimelineManager, month: MonthGroup, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthGroupIterator({ startMonthGroup: month, direction })) {
|
||||
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonthGroup: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, monthGroup: startMonthGroup } = findMonthGroupForAsset(timelineManager, start.id) ?? {};
|
||||
if (!startMonthGroup || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, monthGroup: endMonthGroup } = findMonthGroupForAsset(timelineManager, end.id) ?? {};
|
||||
if (!endMonthGroup || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
let direction: Direction = 'earlier';
|
||||
if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startMonthGroup, endMonthGroup] = [endMonthGroup, startMonthGroup];
|
||||
direction = 'earlier';
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDayGroup = startMonthGroup.findDayGroupForAsset(startAsset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonthGroup,
|
||||
startDayGroup,
|
||||
startAsset,
|
||||
direction,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelinePlainYearMonth) {
|
||||
for (const month of timelineManager.months) {
|
||||
const { year, month: monthNum } = month.yearMonth;
|
||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { throttle } from 'lodash-es';
|
||||
import type { Unsubscriber } from 'svelte/store';
|
||||
|
||||
export class WebsocketSupport {
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
#timelineManager: TimelineManager;
|
||||
|
||||
#processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.#timelineManager.addAssets(add);
|
||||
}
|
||||
if (update.length > 0) {
|
||||
this.#timelineManager.updateAssets(update);
|
||||
}
|
||||
if (remove.length > 0) {
|
||||
this.#timelineManager.removeAssets(remove);
|
||||
}
|
||||
this.#pendingChanges = [];
|
||||
}, 2500);
|
||||
|
||||
constructor(timeineManager: TimelineManager) {
|
||||
this.#timelineManager = timeineManager;
|
||||
}
|
||||
|
||||
connectWebsocketEvents() {
|
||||
this.#unsubscribers.push(
|
||||
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: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
||||
);
|
||||
}
|
||||
|
||||
disconnectWebsocketEvents() {
|
||||
for (const unsubscribe of this.#unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.#unsubscribers = [];
|
||||
}
|
||||
|
||||
#addPendingChanges(...changes: PendingChange[]) {
|
||||
this.#pendingChanges.push(...changes);
|
||||
this.#processPendingChanges();
|
||||
}
|
||||
|
||||
#getPendingChangeBatches() {
|
||||
const batch: {
|
||||
add: TimelineAsset[];
|
||||
update: TimelineAsset[];
|
||||
remove: string[];
|
||||
} = {
|
||||
add: [],
|
||||
update: [],
|
||||
remove: [],
|
||||
};
|
||||
for (const { type, values } of this.#pendingChanges) {
|
||||
switch (type) {
|
||||
case 'add': {
|
||||
batch.add.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
batch.update.push(...values);
|
||||
break;
|
||||
}
|
||||
case 'delete':
|
||||
case 'trash': {
|
||||
batch.remove.push(...values);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
|
||||
export class IntersectingAsset {
|
||||
readonly #group: AssetDateGroup;
|
||||
|
||||
intersecting = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = this.#group.bucket.store;
|
||||
|
||||
const scrollCompensation = store.scrollCompensation;
|
||||
const scrollCompensationHeightDelta = scrollCompensation?.heightDelta ?? 0;
|
||||
|
||||
const topWindow =
|
||||
store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP + scrollCompensationHeightDelta;
|
||||
const bottomWindow =
|
||||
store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM + scrollCompensationHeightDelta;
|
||||
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
|
||||
const positionBottom = positionTop + this.position.height;
|
||||
|
||||
const intersecting =
|
||||
(positionTop >= topWindow && positionTop < bottomWindow) ||
|
||||
(positionBottom >= topWindow && positionBottom < bottomWindow) ||
|
||||
(positionTop < topWindow && positionBottom >= bottomWindow);
|
||||
return intersecting;
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state();
|
||||
asset: TimelineAsset = <TimelineAsset>$state();
|
||||
id: string | undefined = $derived(this.asset?.id);
|
||||
|
||||
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
formatBucketTitle,
|
||||
formatGroupTitle,
|
||||
formatMonthGroupTitle,
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
|
|
@ -10,59 +12,60 @@ import {
|
|||
type TimelinePlainDateTime,
|
||||
type TimelinePlainYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { AddContext } from './add-context.svelte';
|
||||
import { AssetDateGroup } from './asset-date-group.svelte';
|
||||
import type { AssetStore } from './asset-store.svelte';
|
||||
import { IntersectingAsset } from './intersecting-asset.svelte';
|
||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
|
||||
export class AssetBucket {
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||
import type { TimelineManager } from './timeline-manager.svelte';
|
||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
|
||||
export class MonthGroup {
|
||||
#intersecting: boolean = $state(false);
|
||||
actuallyIntersecting: boolean = $state(false);
|
||||
isLoaded: boolean = $state(false);
|
||||
dateGroups: AssetDateGroup[] = $state([]);
|
||||
readonly store: AssetStore;
|
||||
dayGroups: DayGroup[] = $state([]);
|
||||
readonly timelineManager: TimelineManager;
|
||||
|
||||
#bucketHeight: number = $state(0);
|
||||
#height: number = $state(0);
|
||||
#top: number = $state(0);
|
||||
|
||||
#initialCount: number = 0;
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
percent: number = $state(0);
|
||||
|
||||
bucketCount: number = $derived(
|
||||
assetsCount: number = $derived(
|
||||
this.isLoaded
|
||||
? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersectingAssets.length, 0)
|
||||
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
|
||||
: this.#initialCount,
|
||||
);
|
||||
loader: CancellableTask | undefined;
|
||||
isBucketHeightActual: boolean = $state(false);
|
||||
isHeightActual: boolean = $state(false);
|
||||
|
||||
readonly bucketDateFormatted: string;
|
||||
readonly monthGroupTitle: string;
|
||||
readonly yearMonth: TimelinePlainYearMonth;
|
||||
|
||||
constructor(
|
||||
store: AssetStore,
|
||||
store: TimelineManager,
|
||||
yearMonth: TimelinePlainYearMonth,
|
||||
initialCount: number,
|
||||
order: AssetOrder = AssetOrder.Desc,
|
||||
) {
|
||||
this.store = store;
|
||||
this.timelineManager = store;
|
||||
this.#initialCount = initialCount;
|
||||
this.#sortOrder = order;
|
||||
|
||||
this.yearMonth = yearMonth;
|
||||
this.bucketDateFormatted = formatBucketTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
|
||||
this.loader = new CancellableTask(
|
||||
() => {
|
||||
this.isLoaded = true;
|
||||
},
|
||||
() => {
|
||||
this.dateGroups = [];
|
||||
this.dayGroups = [];
|
||||
this.isLoaded = false;
|
||||
},
|
||||
this.#handleLoadError,
|
||||
|
|
@ -76,7 +79,7 @@ export class AssetBucket {
|
|||
}
|
||||
this.#intersecting = newValue;
|
||||
if (newValue) {
|
||||
void this.store.loadBucket(this.yearMonth);
|
||||
void this.timelineManager.loadMonthGroup(this.yearMonth);
|
||||
} else {
|
||||
this.cancel();
|
||||
}
|
||||
|
|
@ -86,28 +89,25 @@ export class AssetBucket {
|
|||
return this.#intersecting;
|
||||
}
|
||||
|
||||
get lastDateGroup() {
|
||||
return this.dateGroups.at(-1);
|
||||
get lastDayGroup() {
|
||||
return this.dayGroups.at(-1);
|
||||
}
|
||||
|
||||
getFirstAsset() {
|
||||
return this.dateGroups[0]?.getFirstAsset();
|
||||
return this.dayGroups[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
getAssets() {
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
return this.dateGroups.reduce(
|
||||
(accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
|
||||
[],
|
||||
);
|
||||
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []);
|
||||
}
|
||||
|
||||
sortDateGroups() {
|
||||
sortDayGroups() {
|
||||
if (this.#sortOrder === AssetOrder.Asc) {
|
||||
return this.dateGroups.sort((a, b) => a.day - b.day);
|
||||
return this.dayGroups.sort((a, b) => a.day - b.day);
|
||||
}
|
||||
|
||||
return this.dateGroups.sort((a, b) => b.day - a.day);
|
||||
return this.dayGroups.sort((a, b) => b.day - a.day);
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
|
|
@ -119,15 +119,15 @@ export class AssetBucket {
|
|||
changedGeometry: false,
|
||||
};
|
||||
}
|
||||
const { dateGroups } = this;
|
||||
const { dayGroups } = this;
|
||||
let combinedChangedGeometry = false;
|
||||
let idsToProcess = new Set(ids);
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: MoveAsset[][] = [];
|
||||
let index = dateGroups.length;
|
||||
let index = dayGroups.length;
|
||||
while (index--) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const group = dateGroups[index];
|
||||
const group = dayGroups[index];
|
||||
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(moveAssets);
|
||||
|
|
@ -137,8 +137,8 @@ export class AssetBucket {
|
|||
idsProcessed.add(id);
|
||||
}
|
||||
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
|
||||
if (group.intersectingAssets.length === 0) {
|
||||
dateGroups.splice(index, 1);
|
||||
if (group.viewerAssets.length === 0) {
|
||||
dayGroups.splice(index, 1);
|
||||
combinedChangedGeometry = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -152,7 +152,7 @@ export class AssetBucket {
|
|||
}
|
||||
|
||||
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||
const addContext = new AddContext();
|
||||
const addContext = new GroupInsertionCache();
|
||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||
const { localDateTime, fileCreatedAt } = getTimes(
|
||||
bucketAssets.fileCreatedAt[i],
|
||||
|
|
@ -188,12 +188,12 @@ export class AssetBucket {
|
|||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
|
||||
for (const group of addContext.existingDateGroups) {
|
||||
for (const group of addContext.existingDayGroups) {
|
||||
group.sortAssets(this.#sortOrder);
|
||||
}
|
||||
|
||||
if (addContext.newDateGroups.size > 0) {
|
||||
this.sortDateGroups();
|
||||
if (addContext.newDayGroups.size > 0) {
|
||||
this.sortDayGroups();
|
||||
}
|
||||
|
||||
addContext.sort(this, this.#sortOrder);
|
||||
|
|
@ -201,7 +201,7 @@ export class AssetBucket {
|
|||
return addContext.unprocessedAssets;
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||
const { localDateTime } = timelineAsset;
|
||||
|
||||
const { year, month } = this.yearMonth;
|
||||
|
|
@ -210,29 +210,29 @@ export class AssetBucket {
|
|||
return;
|
||||
}
|
||||
|
||||
let dateGroup = addContext.getDateGroup(localDateTime) || this.findDateGroupByDay(localDateTime.day);
|
||||
if (dateGroup) {
|
||||
addContext.setDateGroup(dateGroup, localDateTime);
|
||||
let dayGroup = addContext.getDayGroup(localDateTime) || this.findDayGroupByDay(localDateTime.day);
|
||||
if (dayGroup) {
|
||||
addContext.setDayGroup(dayGroup, localDateTime);
|
||||
} else {
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, localDateTime.day, groupTitle);
|
||||
this.dateGroups.push(dateGroup);
|
||||
addContext.setDateGroup(dateGroup, localDateTime);
|
||||
addContext.newDateGroups.add(dateGroup);
|
||||
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
|
||||
this.dayGroups.push(dayGroup);
|
||||
addContext.setDayGroup(dayGroup, localDateTime);
|
||||
addContext.newDayGroups.add(dayGroup);
|
||||
}
|
||||
|
||||
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
||||
dateGroup.intersectingAssets.push(intersectingAsset);
|
||||
addContext.changedDateGroups.add(dateGroup);
|
||||
const viewerAsset = new ViewerAsset(dayGroup, timelineAsset);
|
||||
dayGroup.viewerAssets.push(viewerAsset);
|
||||
addContext.changedDayGroups.add(dayGroup);
|
||||
}
|
||||
|
||||
getRandomDateGroup() {
|
||||
const random = Math.floor(Math.random() * this.dateGroups.length);
|
||||
return this.dateGroups[random];
|
||||
getRandomDayGroup() {
|
||||
const random = Math.floor(Math.random() * this.dayGroups.length);
|
||||
return this.dayGroups[random];
|
||||
}
|
||||
|
||||
getRandomAsset() {
|
||||
return this.getRandomDateGroup()?.getRandomAsset()?.asset;
|
||||
return this.getRandomDayGroup()?.getRandomAsset()?.asset;
|
||||
}
|
||||
|
||||
get viewId() {
|
||||
|
|
@ -240,55 +240,55 @@ export class AssetBucket {
|
|||
return year + '-' + month;
|
||||
}
|
||||
|
||||
set bucketHeight(height: number) {
|
||||
if (this.#bucketHeight === height) {
|
||||
set height(height: number) {
|
||||
if (this.#height === height) {
|
||||
return;
|
||||
}
|
||||
const { store, percent } = this;
|
||||
const index = store.buckets.indexOf(this);
|
||||
const bucketHeightDelta = height - this.#bucketHeight;
|
||||
this.#bucketHeight = height;
|
||||
const prevBucket = store.buckets[index - 1];
|
||||
if (prevBucket) {
|
||||
const newTop = prevBucket.#top + prevBucket.#bucketHeight;
|
||||
const { timelineManager: store, percent } = this;
|
||||
const index = store.months.indexOf(this);
|
||||
const heightDelta = height - this.#height;
|
||||
this.#height = height;
|
||||
const prevMonthGroup = store.months[index - 1];
|
||||
if (prevMonthGroup) {
|
||||
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
||||
if (this.#top !== newTop) {
|
||||
this.#top = newTop;
|
||||
}
|
||||
}
|
||||
for (let cursor = index + 1; cursor < store.buckets.length; cursor++) {
|
||||
const bucket = this.store.buckets[cursor];
|
||||
const newTop = bucket.#top + bucketHeightDelta;
|
||||
if (bucket.#top !== newTop) {
|
||||
bucket.#top = newTop;
|
||||
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
|
||||
const monthGroup = this.timelineManager.months[cursor];
|
||||
const newTop = monthGroup.#top + heightDelta;
|
||||
if (monthGroup.#top !== newTop) {
|
||||
monthGroup.#top = newTop;
|
||||
}
|
||||
}
|
||||
if (store.topIntersectingBucket) {
|
||||
const currentIndex = store.buckets.indexOf(store.topIntersectingBucket);
|
||||
if (store.topIntersectingMonthGroup) {
|
||||
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
|
||||
if (currentIndex > 0) {
|
||||
if (index < currentIndex) {
|
||||
store.scrollCompensation = {
|
||||
heightDelta: bucketHeightDelta,
|
||||
heightDelta,
|
||||
scrollTop: undefined,
|
||||
bucket: this,
|
||||
monthGroup: this,
|
||||
};
|
||||
} else if (percent > 0) {
|
||||
const top = this.top + height * percent;
|
||||
store.scrollCompensation = {
|
||||
heightDelta: undefined,
|
||||
scrollTop: top,
|
||||
bucket: this,
|
||||
monthGroup: this,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get bucketHeight() {
|
||||
return this.#bucketHeight;
|
||||
get height() {
|
||||
return this.#height;
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this.#top + this.store.topSectionHeight;
|
||||
return this.#top + this.timelineManager.topSectionHeight;
|
||||
}
|
||||
|
||||
#handleLoadError(error: unknown) {
|
||||
|
|
@ -296,45 +296,45 @@ export class AssetBucket {
|
|||
handleError(error, _$t('errors.failed_to_load_assets'));
|
||||
}
|
||||
|
||||
findDateGroupForAsset(asset: TimelineAsset) {
|
||||
for (const group of this.dateGroups) {
|
||||
if (group.intersectingAssets.some((IntersectingAsset) => IntersectingAsset.id === asset.id)) {
|
||||
findDayGroupForAsset(asset: TimelineAsset) {
|
||||
for (const group of this.dayGroups) {
|
||||
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findDateGroupByDay(day: number) {
|
||||
return this.dateGroups.find((group) => group.day === day);
|
||||
findDayGroupByDay(day: number) {
|
||||
return this.dayGroups.find((group) => group.day === day);
|
||||
}
|
||||
|
||||
findAssetAbsolutePosition(assetId: string) {
|
||||
this.store.clearDeferredLayout(this);
|
||||
for (const group of this.dateGroups) {
|
||||
const intersectingAsset = group.intersectingAssets.find((asset) => asset.id === assetId);
|
||||
if (intersectingAsset) {
|
||||
if (!intersectingAsset.position) {
|
||||
this.timelineManager.clearDeferredLayout(this);
|
||||
for (const group of this.dayGroups) {
|
||||
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||
if (viewerAsset) {
|
||||
if (!viewerAsset.position) {
|
||||
console.warn('No position for asset');
|
||||
break;
|
||||
}
|
||||
return this.top + group.top + intersectingAsset.position.top + this.store.headerHeight;
|
||||
return this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
*assetsIterator(options?: { startDateGroup?: AssetDateGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||
*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startAsset } = options ?? {};
|
||||
const isEarlier = direction === 'earlier';
|
||||
let groupIndex = options?.startDateGroup
|
||||
? this.dateGroups.indexOf(options.startDateGroup)
|
||||
let groupIndex = options?.startDayGroup
|
||||
? this.dayGroups.indexOf(options.startDayGroup)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.dateGroups.length - 1;
|
||||
: this.dayGroups.length - 1;
|
||||
|
||||
while (groupIndex >= 0 && groupIndex < this.dateGroups.length) {
|
||||
const group = this.dateGroups[groupIndex];
|
||||
while (groupIndex >= 0 && groupIndex < this.dayGroups.length) {
|
||||
const group = this.dayGroups[groupIndex];
|
||||
yield* group.assetsIterator({ startAsset, direction });
|
||||
startAsset = undefined;
|
||||
groupIndex += isEarlier ? 1 : -1;
|
||||
|
|
@ -0,0 +1,583 @@
|
|||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
import { TimelineManager } from './timeline-manager.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
async function getAssets(timelineManager: TimelineManager) {
|
||||
const assets = [];
|
||||
for await (const asset of timelineManager.assetsIterator()) {
|
||||
assets.push(asset);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
||||
return {
|
||||
...arg,
|
||||
localDateTime: arg.fileCreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TimelineManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
{ count: 1, timeBucket: '2024-03-01' },
|
||||
{ count: 100, timeBucket: '2024-02-01' },
|
||||
{ count: 3, timeBucket: '2024-01-01' },
|
||||
]);
|
||||
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('should load months in viewport', () => {
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('calculates month height', () => {
|
||||
const plainMonths = timelineManager.months.map((month) => ({
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
height: month.height,
|
||||
}));
|
||||
|
||||
expect(plainMonths).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ year: 2024, month: 3, height: 185.5 }),
|
||||
expect.objectContaining({ year: 2024, month: 2, height: 12_016 }),
|
||||
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates timeline height', () => {
|
||||
expect(timelineManager.timelineHeight).toBe(12_487.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMonthGroup', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(async ({ timeBucket }, { signal } = {}) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (signal?.aborted) {
|
||||
throw new AbortError();
|
||||
}
|
||||
return bucketAssetsResponse[timeBucket];
|
||||
});
|
||||
await timelineManager.updateViewport({ width: 1588, height: 0 });
|
||||
});
|
||||
|
||||
it('loads a month', async () => {
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
|
||||
it('ignores invalid months', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('cancels month loading', async () => {
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
void timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||
month?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||
});
|
||||
|
||||
it('prevents loading months multiple times', async () => {
|
||||
await Promise.all([
|
||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||
]);
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows loading a canceled month', async () => {
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
|
||||
month.cancel();
|
||||
await loadPromise;
|
||||
expect(month?.getAssets().length).toEqual(0);
|
||||
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
expect(month!.getAssets().length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('is empty initially', () => {
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
it('adds assets to new month', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([asset]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
||||
});
|
||||
|
||||
it('adds assets to existing month', () => {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne]);
|
||||
timelineManager.addAssets([assetTwo]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('orders assets in months by descending date', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
expect(month).not.toBeNull();
|
||||
expect(month?.getAssets().length).toEqual(3);
|
||||
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
|
||||
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
|
||||
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
|
||||
});
|
||||
|
||||
it('orders months by descending date', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(3);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
|
||||
|
||||
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
|
||||
|
||||
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
|
||||
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('updates existing asset', () => {
|
||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.addAssets([asset]);
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
// disabled due to the wasm Justified Layout import
|
||||
it('ignores trashed assets when isTrashed is true', async () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
|
||||
const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
|
||||
|
||||
const timelineManager = new TimelineManager();
|
||||
await timelineManager.updateOptions({ isTrashed: true });
|
||||
timelineManager.addAssets([asset, trashedAsset]);
|
||||
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
it('updates an asset', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||
const updatedAsset = { ...asset, isFavorite: true };
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
||||
});
|
||||
|
||||
it('asset moves months when asset date changes', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
expect(timelineManager.months.length).toEqual(2);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
timelineManager.addAssets(
|
||||
timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)),
|
||||
);
|
||||
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||
});
|
||||
|
||||
it('removes asset from month', () => {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.removeAssets([assetOne.id]);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not remove month when empty', () => {
|
||||
const assets = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstAsset', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await timelineManager.updateViewport({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('empty store returns null', () => {
|
||||
expect(timelineManager.getFirstAsset()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('populated store returns first asset', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLaterAsset', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('returns null for invalid assetId', async () => {
|
||||
expect(() => timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||
expect(await timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns previous assetId', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
|
||||
const a = month!.getAssets()[0];
|
||||
const b = month!.getAssets()[1];
|
||||
const previous = await timelineManager.getLaterAsset(b);
|
||||
expect(previous).toEqual(a);
|
||||
});
|
||||
|
||||
it('returns previous assetId spanning multiple months', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const a = month!.getAssets()[0];
|
||||
const b = previousMonth!.getAssets()[0];
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
});
|
||||
|
||||
it('loads previous month', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const a = month!.getFirstAsset();
|
||||
const b = previousMonth!.getFirstAsset();
|
||||
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute');
|
||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
expect(loadMonthGroupSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
|
||||
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(await timelineManager.getLaterAsset(assetThree)).toEqual(assetOne);
|
||||
});
|
||||
|
||||
it('returns null when no more assets', async () => {
|
||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthGroupIndexByAssetId', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
|
||||
await timelineManager.updateViewport({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('returns null for invalid months', () => {
|
||||
expect(getMonthGroupByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the month index', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('ignores removed months', () => {
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
539
web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
Normal file
539
web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { toTimelineAsset, type TimelinePlainDateTime, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
|
||||
|
||||
import { clamp, debounce, isEqual } from 'lodash-es';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
||||
import {
|
||||
addAssetsToMonthGroups,
|
||||
runAssetOperation,
|
||||
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
||||
import {
|
||||
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
||||
findMonthGroupForDate,
|
||||
getAssetWithOffset,
|
||||
getMonthGroupByDate,
|
||||
retrieveRange as retrieveRangeUtil,
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||
import { DayGroup } from './day-group.svelte';
|
||||
import { isMismatched, updateObject } from './internal/utils.svelte';
|
||||
import { MonthGroup } from './month-group.svelte';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
Direction,
|
||||
ScrubberMonth,
|
||||
TimelineAsset,
|
||||
TimelineManagerLayoutOptions,
|
||||
TimelineManagerOptions,
|
||||
Viewport,
|
||||
} from './types';
|
||||
|
||||
export class TimelineManager {
|
||||
isInitialized = $state(false);
|
||||
months: MonthGroup[] = $state([]);
|
||||
topSectionHeight = $state(0);
|
||||
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
|
||||
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
||||
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
topIntersectingMonthGroup: MonthGroup | undefined = $state();
|
||||
|
||||
visibleWindow = $derived.by(() => ({
|
||||
top: this.#scrollTop,
|
||||
bottom: this.#scrollTop + this.viewportHeight,
|
||||
}));
|
||||
|
||||
initTask = new CancellableTask(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
},
|
||||
() => {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
);
|
||||
|
||||
static #INIT_OPTIONS = {};
|
||||
#viewportHeight = $state(0);
|
||||
#viewportWidth = $state(0);
|
||||
#scrollTop = $state(0);
|
||||
#websocketSupport: WebsocketSupport | undefined;
|
||||
|
||||
#rowHeight = $state(235);
|
||||
#headerHeight = $state(48);
|
||||
#gap = $state(12);
|
||||
|
||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||
|
||||
#scrolling = $state(false);
|
||||
#suspendTransitions = $state(false);
|
||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||
scrollCompensation: {
|
||||
heightDelta: number | undefined;
|
||||
scrollTop: number | undefined;
|
||||
monthGroup: MonthGroup | undefined;
|
||||
} = $state({
|
||||
heightDelta: 0,
|
||||
scrollTop: 0,
|
||||
monthGroup: undefined,
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
|
||||
let changed = false;
|
||||
changed ||= this.#setHeaderHeight(headerHeight);
|
||||
changed ||= this.#setGap(gap);
|
||||
changed ||= this.#setRowHeight(rowHeight);
|
||||
if (changed) {
|
||||
this.refreshLayout();
|
||||
}
|
||||
}
|
||||
|
||||
#setHeaderHeight(value: number) {
|
||||
if (this.#headerHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#headerHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get headerHeight() {
|
||||
return this.#headerHeight;
|
||||
}
|
||||
|
||||
#setGap(value: number) {
|
||||
if (this.#gap == value) {
|
||||
return false;
|
||||
}
|
||||
this.#gap = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get gap() {
|
||||
return this.#gap;
|
||||
}
|
||||
|
||||
#setRowHeight(value: number) {
|
||||
if (this.#rowHeight == value) {
|
||||
return false;
|
||||
}
|
||||
this.#rowHeight = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
get rowHeight() {
|
||||
return this.#rowHeight;
|
||||
}
|
||||
|
||||
set scrolling(value: boolean) {
|
||||
this.#scrolling = value;
|
||||
if (value) {
|
||||
this.suspendTransitions = true;
|
||||
this.#resetScrolling();
|
||||
}
|
||||
}
|
||||
|
||||
get scrolling() {
|
||||
return this.#scrolling;
|
||||
}
|
||||
|
||||
set suspendTransitions(value: boolean) {
|
||||
this.#suspendTransitions = value;
|
||||
if (value) {
|
||||
this.#resetSuspendTransitions();
|
||||
}
|
||||
}
|
||||
|
||||
get suspendTransitions() {
|
||||
return this.#suspendTransitions;
|
||||
}
|
||||
|
||||
set viewportWidth(value: number) {
|
||||
const changed = value !== this.#viewportWidth;
|
||||
this.#viewportWidth = value;
|
||||
this.suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(changed);
|
||||
}
|
||||
|
||||
get viewportWidth() {
|
||||
return this.#viewportWidth;
|
||||
}
|
||||
|
||||
set viewportHeight(value: number) {
|
||||
this.#viewportHeight = value;
|
||||
this.#suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
get viewportHeight() {
|
||||
return this.#viewportHeight;
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startMonthGroup?: MonthGroup;
|
||||
startDayGroup?: DayGroup;
|
||||
startAsset?: TimelineAsset;
|
||||
direction?: Direction;
|
||||
}) {
|
||||
const direction = options?.direction ?? 'earlier';
|
||||
let { startDayGroup, startAsset } = options ?? {};
|
||||
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
||||
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
|
||||
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
||||
startDayGroup = startAsset = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
|
||||
const isEarlier = options?.direction === 'earlier';
|
||||
let startIndex = options?.startMonthGroup
|
||||
? this.months.indexOf(options.startMonthGroup)
|
||||
: isEarlier
|
||||
? 0
|
||||
: this.months.length - 1;
|
||||
|
||||
while (startIndex >= 0 && startIndex < this.months.length) {
|
||||
yield this.months[startIndex];
|
||||
startIndex += isEarlier ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.#websocketSupport) {
|
||||
throw new Error('TimelineManager already connected');
|
||||
}
|
||||
this.#websocketSupport = new WebsocketSupport(this);
|
||||
this.#websocketSupport.connectWebsocketEvents();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.#websocketSupport) {
|
||||
return;
|
||||
}
|
||||
this.#websocketSupport.disconnectWebsocketEvents();
|
||||
this.#websocketSupport = undefined;
|
||||
}
|
||||
|
||||
updateSlidingWindow(scrollTop: number) {
|
||||
if (this.#scrollTop !== scrollTop) {
|
||||
this.#scrollTop = scrollTop;
|
||||
this.updateIntersections();
|
||||
}
|
||||
}
|
||||
|
||||
clearScrollCompensation() {
|
||||
this.scrollCompensation = {
|
||||
heightDelta: undefined,
|
||||
scrollTop: undefined,
|
||||
monthGroup: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
updateIntersections() {
|
||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
return;
|
||||
}
|
||||
let topIntersectingMonthGroup = undefined;
|
||||
for (const month of this.months) {
|
||||
updateIntersectionMonthGroup(this, month);
|
||||
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
|
||||
topIntersectingMonthGroup = month;
|
||||
}
|
||||
}
|
||||
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
|
||||
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
|
||||
}
|
||||
for (const month of this.months) {
|
||||
if (month === this.topIntersectingMonthGroup) {
|
||||
this.topIntersectingMonthGroup.percent = clamp(
|
||||
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
} else {
|
||||
month.percent = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearDeferredLayout(month: MonthGroup) {
|
||||
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
|
||||
if (hasDeferred) {
|
||||
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
|
||||
for (const group of month.dayGroups) {
|
||||
group.deferredLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #initializeMonthGroups() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...this.#options,
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
this.months = timebuckets.map((timeBucket) => {
|
||||
const date = new Date(timeBucket.timeBucket);
|
||||
return new MonthGroup(
|
||||
this,
|
||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||
timeBucket.count,
|
||||
this.#options.order,
|
||||
);
|
||||
});
|
||||
this.albumAssets.clear();
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async updateOptions(options: TimelineManagerOptions) {
|
||||
if (options.deferInit) {
|
||||
return;
|
||||
}
|
||||
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.#updateViewportGeometry(false);
|
||||
}
|
||||
|
||||
async #init(options: TimelineManagerOptions) {
|
||||
this.isInitialized = false;
|
||||
this.months = [];
|
||||
this.albumAssets.clear();
|
||||
await this.initTask.execute(async () => {
|
||||
this.#options = options;
|
||||
await this.#initializeMonthGroups();
|
||||
}, true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.disconnect();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async updateViewport(viewport: Viewport) {
|
||||
if (viewport.height === 0 && viewport.width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.initTask.executed) {
|
||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
}
|
||||
|
||||
const changedWidth = viewport.width !== this.viewportWidth;
|
||||
this.viewportHeight = viewport.height;
|
||||
this.viewportWidth = viewport.width;
|
||||
this.#updateViewportGeometry(changedWidth);
|
||||
}
|
||||
|
||||
#updateViewportGeometry(changedWidth: boolean) {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
||||
return;
|
||||
}
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||
}
|
||||
this.updateIntersections();
|
||||
this.#createScrubberMonths();
|
||||
}
|
||||
|
||||
#createScrubberMonths() {
|
||||
this.scrubberMonths = this.months.map((month) => ({
|
||||
assetCount: month.assetsCount,
|
||||
year: month.yearMonth.year,
|
||||
month: month.yearMonth.month,
|
||||
title: month.monthGroupTitle,
|
||||
height: month.height,
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.timelineHeight;
|
||||
}
|
||||
|
||||
createLayoutOptions() {
|
||||
const viewportWidth = this.viewportWidth;
|
||||
|
||||
return {
|
||||
spacing: 2,
|
||||
heightTolerance: 0.15,
|
||||
rowHeight: this.#rowHeight,
|
||||
rowWidth: Math.floor(viewportWidth),
|
||||
};
|
||||
}
|
||||
|
||||
async loadMonthGroup(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||
let cancelable = true;
|
||||
if (options) {
|
||||
cancelable = options.cancelable;
|
||||
}
|
||||
const monthGroup = getMonthGroupByDate(this, yearMonth);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (monthGroup.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
||||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||
}, cancelable);
|
||||
if (result === 'LOADED') {
|
||||
updateIntersectionMonthGroup(this, monthGroup);
|
||||
}
|
||||
}
|
||||
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
||||
if (monthGroup) {
|
||||
return monthGroup;
|
||||
}
|
||||
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (monthGroup?.findAssetById({ id })) {
|
||||
return monthGroup;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadMonthGroupAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.loadMonthGroup(yearMonth, options);
|
||||
return getMonthGroupByDate(this, yearMonth);
|
||||
}
|
||||
|
||||
getMonthGroupByAssetId(assetId: string) {
|
||||
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
||||
return monthGroupInfo?.monthGroup;
|
||||
}
|
||||
|
||||
async getRandomMonthGroup() {
|
||||
const random = Math.floor(Math.random() * this.months.length);
|
||||
const month = this.months[random];
|
||||
await this.loadMonthGroup(month.yearMonth, { cancelable: false });
|
||||
return month;
|
||||
}
|
||||
|
||||
async getRandomAsset() {
|
||||
const month = await this.getRandomMonthGroup();
|
||||
return month?.getRandomAsset();
|
||||
}
|
||||
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
runAssetOperation(this, new Set(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
updateAssets(assets: TimelineAsset[]) {
|
||||
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new Set(lookup.keys()),
|
||||
(asset) => {
|
||||
updateObject(asset, lookup.get(asset.id));
|
||||
return { remove: false };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
return unprocessedIds.values().map((id) => lookup.get(id)!);
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
new Set(ids),
|
||||
() => {
|
||||
return { remove: true };
|
||||
},
|
||||
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||
);
|
||||
return [...unprocessedIds];
|
||||
}
|
||||
|
||||
refreshLayout() {
|
||||
for (const month of this.months) {
|
||||
updateGeometry(this, month, { invalidateHeight: true });
|
||||
}
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getFirstAsset(): TimelineAsset | undefined {
|
||||
return this.months[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
|
||||
const monthGroup = findMonthGroupForDate(this, dateTime);
|
||||
if (!monthGroup) {
|
||||
return;
|
||||
}
|
||||
await this.loadMonthGroup(dateTime, { cancelable: false });
|
||||
const asset = monthGroup.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.assetsIterator({ startMonthGroup: monthGroup })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
return retrieveRangeUtil(this, start, end);
|
||||
}
|
||||
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import type { AssetStackResponseDto, AssetVisibility } from '@immich/sdk';
|
|||
|
||||
export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sdk').getTimeBuckets>[0];
|
||||
|
||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||
export type TimelineManagerOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||
timelineAlbumId?: string;
|
||||
deferInit?: boolean;
|
||||
};
|
||||
|
|
@ -74,15 +74,15 @@ export interface UpdateStackAssets {
|
|||
|
||||
export type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
||||
|
||||
export type LiteBucket = {
|
||||
bucketHeight: number;
|
||||
export type ScrubberMonth = {
|
||||
height: number;
|
||||
assetCount: number;
|
||||
year: number;
|
||||
month: number;
|
||||
bucketDateFormattted: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type AssetStoreLayoutOptions = {
|
||||
export type TimelineManagerLayoutOptions = {
|
||||
rowHeight?: number;
|
||||
headerHeight?: number;
|
||||
gap?: number;
|
||||
|
|
|
|||
|
|
@ -1,34 +1,4 @@
|
|||
import type { TimelineAsset } from './types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
|
||||
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
||||
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
|
||||
|
|
|
|||
29
web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts
Normal file
29
web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
|
||||
import type { DayGroup } from './day-group.svelte';
|
||||
import { calculateViewerAssetIntersecting } from './internal/intersection-support.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export class ViewerAsset {
|
||||
readonly #group: DayGroup;
|
||||
|
||||
intersecting = $derived.by(() => {
|
||||
if (!this.position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = this.#group.monthGroup.timelineManager;
|
||||
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
||||
|
||||
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state();
|
||||
asset: TimelineAsset = <TimelineAsset>$state();
|
||||
id: string = $derived(this.asset.id);
|
||||
|
||||
constructor(group: DayGroup, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue