From 10f599bee1f35a5aeeff4a7e6678590c8278d43b Mon Sep 17 00:00:00 2001 From: midzelis Date: Thu, 18 Sep 2025 14:43:07 +0000 Subject: [PATCH] refactor(web): extract PhotostreamManager base class --- .../PhotostreamManager.svelte.ts | 293 ++++++++++++++++++ .../PhotostreamSegment.svelte.ts | 133 ++++++++ .../internal/intersection-support.svelte.ts | 15 +- .../internal/layout-support.svelte.ts | 13 +- .../internal/operations-support.svelte.ts | 3 +- .../timeline-manager/month-group.svelte.ts | 158 +++------- .../timeline-manager.svelte.spec.ts | 36 +-- .../timeline-manager.svelte.ts | 285 ++--------------- web/src/lib/utils/asset-utils.ts | 2 +- web/src/lib/utils/timeline-util.ts | 6 + 10 files changed, 545 insertions(+), 399 deletions(-) create mode 100644 web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts create mode 100644 web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts diff --git a/web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts b/web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts new file mode 100644 index 0000000000..497654cea9 --- /dev/null +++ b/web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts @@ -0,0 +1,293 @@ +import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; +import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; +import { CancellableTask } from '$lib/utils/cancellable-task'; +import { clamp, debounce } from 'lodash-es'; + +import type { PhotostreamSegment, SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; +import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; +import type { TimelineManagerLayoutOptions, TimelineManagerOptions, Viewport } from './types'; + +export abstract class PhotostreamManager { + isInitialized = $state(false); + topSectionHeight = $state(0); + bottomSectionHeight = $state(60); + abstract get months(): PhotostreamSegment[]; + timelineHeight = $derived.by( + () => this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight, + ); + assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0)); + + topIntersectingMonthGroup: PhotostreamSegment | undefined = $state(); + + visibleWindow = $derived.by(() => ({ + top: this.#scrollTop, + bottom: this.#scrollTop + this.viewportHeight, + })); + + protected initTask = new CancellableTask( + () => (this.isInitialized = true), + () => (this.isInitialized = false), + () => void 0, + ); + + protected options: TimelineManagerOptions = {}; + + #viewportHeight = $state(0); + #viewportWidth = $state(0); + #scrollTop = $state(0); + + #rowHeight = $state(235); + #headerHeight = $state(48); + #gap = $state(12); + + #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: PhotostreamSegment | 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; + } + + 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; + } + } + } + + protected async init(options: TimelineManagerOptions) { + this.isInitialized = true; + // this.months = []; + + await this.initTask.execute(async () => undefined, true); + } + + public destroy() { + 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); + } + + protected 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(); + } + + createLayoutOptions() { + const viewportWidth = this.viewportWidth; + + return { + spacing: 2, + heightTolerance: 0.15, + rowHeight: this.#rowHeight, + rowWidth: Math.floor(viewportWidth), + }; + } + + async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise { + let cancelable = true; + if (options) { + cancelable = options.cancelable; + } + const segment = this.getSegmentByIdentifier(identifier); + if (!segment) { + return; + } + + if (segment.loader?.executed) { + return; + } + + const result = await segment.loader?.execute(async (signal: AbortSignal) => { + await this.fetchSegment(segment, signal); + }, cancelable); + if (result === 'LOADED') { + updateIntersectionMonthGroup(this, segment); + } + } + + getSegmentMatcher(identifier: SegmentIdentifier) { + return (segment: MonthGroup) => { + return identifier; + }; + } + getSegmentByIdentifier(identifier: SegmentIdentifier) { + return this.months.find((segment) => identifier.matches(segment)); + } + + protected abstract fetchSegment(segment: PhotostreamSegment, signal: AbortSignal): Promise; + + refreshLayout() { + for (const month of this.months) { + updateGeometry(this, month, { invalidateHeight: true }); + } + this.updateIntersections(); + } + + getMaxScrollPercent() { + const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight; + return (totalHeight - this.viewportHeight) / totalHeight; + } + + getMaxScroll() { + return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight); + } +} \ No newline at end of file diff --git a/web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts b/web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts new file mode 100644 index 0000000000..fc257ec3fc --- /dev/null +++ b/web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts @@ -0,0 +1,133 @@ +import { CancellableTask } from '$lib/utils/cancellable-task'; +import { handleError } from '$lib/utils/handle-error'; +import { t } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; +import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + +export type SegmentIdentifier = { + matches(segment: PhotostreamSegment): boolean; +}; +export abstract class PhotostreamSegment { + #intersecting = $state(false); + actuallyIntersecting = $state(false); + #isLoaded = $state(false); + + #height = $state(0); + #top = $state(0); + + initialCount = $state(0); + percent = $state(0); + assetsCount = $derived(this.isLoaded ? this.getAssets().length : this.initialCount); + loader = new CancellableTask( + () => this.markLoaded(), + () => this.markCanceled, + () => this.handleLoadError, + ); + isHeightActual = $state(false); + + abstract get timelineManager(): PhotostreamManager; + + abstract get identifier(): SegmentIdentifier; + + get isLoaded() { + return this.#isLoaded; + } + + protected markLoaded() { + this.#isLoaded = true; + } + + protected markCanceled() { + this.#isLoaded = false; + } + + set intersecting(newValue: boolean) { + const old = this.#intersecting; + if (old === newValue) { + return; + } + this.#intersecting = newValue; + if (newValue) { + this.load(); + } else { + this.cancel(); + } + } + + get intersecting() { + return this.#intersecting; + } + + abstract load(): Promise; + + abstract getAssets(): TimelineAsset[]; + + set height(height: number) { + if (this.#height === height) { + return; + } + 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.months.length; cursor++) { + const monthGroup = this.timelineManager.months[cursor]; + const newTop = monthGroup.#top + heightDelta; + if (monthGroup.#top !== newTop) { + monthGroup.#top = newTop; + } + } + if (store.topIntersectingMonthGroup) { + const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup); + if (currentIndex > 0) { + if (index < currentIndex) { + store.scrollCompensation = { + heightDelta, + scrollTop: undefined, + monthGroup: this, + }; + } else if (percent > 0) { + const top = this.top + height * percent; + store.scrollCompensation = { + heightDelta: undefined, + scrollTop: top, + monthGroup: this, + }; + } + } + } + } + + get height() { + return this.#height; + } + + get top(): number { + return this.#top + this.timelineManager.topSectionHeight; + } + + protected handleLoadError(error: unknown) { + const _$t = get(t); + handleError(error, _$t('errors.failed_to_load_assets')); + } + + cancel() { + this.loader?.cancel(); + } + + layout(_noDefer: boolean) {} + + updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) { + this.intersecting = intersecting; + this.actuallyIntersecting = actuallyIntersecting; + } +} \ No newline at end of file diff --git a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts index db3d31fc84..6ee7bf9320 100644 --- a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts @@ -1,16 +1,17 @@ +import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; +import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; 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); +export function updateIntersectionMonthGroup(timelineManager: PhotostreamManager, month: PhotostreamSegment) { + const actuallyIntersecting = calculateSegmentIntersecting(timelineManager, month, 0, 0); let preIntersecting = false; if (!actuallyIntersecting) { - preIntersecting = calculateMonthGroupIntersecting( + preIntersecting = calculateSegmentIntersecting( timelineManager, month, INTERSECTION_EXPAND_TOP, @@ -36,9 +37,9 @@ export function isIntersecting(regionTop: number, regionBottom: number, windowTo ); } -export function calculateMonthGroupIntersecting( - timelineManager: TimelineManager, - monthGroup: MonthGroup, +export function calculateSegmentIntersecting( + timelineManager: PhotostreamManager, + monthGroup: PhotostreamSegment, expandTop: number, expandBottom: number, ) { diff --git a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts index 9ba09c5a45..70ffea2c49 100644 --- a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts @@ -1,8 +1,13 @@ +import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; +import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; 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) { +export function updateGeometry( + timelineManager: PhotostreamManager, + month: PhotostreamSegment, + options: UpdateGeometryOptions, +) { const { invalidateHeight, noDefer = false } = options; if (invalidateHeight) { month.isHeightActual = false; @@ -17,10 +22,10 @@ export function updateGeometry(timelineManager: TimelineManager, month: MonthGro } return; } - layoutMonthGroup(timelineManager, month, noDefer); + month.layout(noDefer); } -export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) { +export function layoutMonthGroup(timelineManager: PhotostreamManager, month: MonthGroup, noDefer: boolean = false) { let cumulativeHeight = 0; let cumulativeWidth = 0; let currentRowHeight = 0; diff --git a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts index 4bc99c0315..aff2dd9b65 100644 --- a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts @@ -25,8 +25,7 @@ export function addAssetsToMonthGroups( let month = getMonthGroupByDate(timelineManager, asset.localDateTime); if (!month) { - month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order); - month.isLoaded = true; + month = new MonthGroup(timelineManager, asset.localDateTime, 1, true, options.order); timelineManager.months.push(month); } diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 82eeaf8bd8..45cbeaba7a 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -1,7 +1,5 @@ import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk'; -import { CancellableTask } from '$lib/utils/cancellable-task'; -import { handleError } from '$lib/utils/handle-error'; import { formatGroupTitle, formatGroupTitleFull, @@ -9,15 +7,15 @@ import { fromTimelinePlainDate, fromTimelinePlainDateTime, fromTimelinePlainYearMonth, + getSegmentIdentifier, getTimes, setDifference, type TimelineDateTime, type TimelineYearMonth, } from '$lib/utils/timeline-util'; -import { t } from 'svelte-i18n'; -import { get } from 'svelte/store'; - +import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; +import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import { SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte'; @@ -25,73 +23,52 @@ 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); +export class MonthGroup extends PhotostreamSegment { dayGroups: DayGroup[] = $state([]); - readonly timelineManager: TimelineManager; - #height: number = $state(0); - #top: number = $state(0); - - #initialCount: number = 0; #sortOrder: AssetOrder = AssetOrder.Desc; - percent: number = $state(0); - - assetsCount: number = $derived( - this.isLoaded - ? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0) - : this.#initialCount, - ); - loader: CancellableTask | undefined; - isHeightActual: boolean = $state(false); + #yearMonth: TimelineYearMonth; + #identifier: SegmentIdentifier; + #timelineManager: TimelineManager; readonly monthGroupTitle: string; - readonly yearMonth: TimelineYearMonth; constructor( - store: TimelineManager, + timelineManager: TimelineManager, yearMonth: TimelineYearMonth, initialCount: number, + loaded: boolean, order: AssetOrder = AssetOrder.Desc, ) { - this.timelineManager = store; - this.#initialCount = initialCount; + super(); + this.initialCount = initialCount; + this.#yearMonth = yearMonth; + this.#identifier = getSegmentIdentifier(yearMonth); + this.#timelineManager = timelineManager; this.#sortOrder = order; - - this.yearMonth = yearMonth; this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth)); - - this.loader = new CancellableTask( - () => { - this.isLoaded = true; - }, - () => { - this.dayGroups = []; - this.isLoaded = false; - }, - this.#handleLoadError, - ); - } - - set intersecting(newValue: boolean) { - const old = this.#intersecting; - if (old === newValue) { - return; - } - this.#intersecting = newValue; - if (newValue) { - void this.timelineManager.loadSegment(this.yearMonth); - } else { - this.cancel(); + if (loaded) { + this.markLoaded(); } } - get intersecting() { - return this.#intersecting; + get identifier() { + return this.#identifier; } + get timelineManager() { + return this.#timelineManager; + } + + get yearMonth() { + return this.#yearMonth; + } + + load(): Promise { + return this.timelineManager.loadSegment(this.#identifier); + } + + get lastDayGroup() { return this.dayGroups.at(-1); } @@ -249,61 +226,6 @@ export class MonthGroup { return year + '-' + month; } - set height(height: number) { - if (this.#height === height) { - return; - } - 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.months.length; cursor++) { - const monthGroup = this.timelineManager.months[cursor]; - const newTop = monthGroup.#top + heightDelta; - if (monthGroup.#top !== newTop) { - monthGroup.#top = newTop; - } - } - if (store.topIntersectingMonthGroup) { - const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup); - if (currentIndex > 0) { - if (index < currentIndex) { - store.scrollCompensation = { - heightDelta, - scrollTop: undefined, - monthGroup: this, - }; - } else if (percent > 0) { - const top = this.top + height * percent; - store.scrollCompensation = { - heightDelta: undefined, - scrollTop: top, - monthGroup: this, - }; - } - } - } - } - - get height() { - return this.#height; - } - - get top(): number { - return this.#top + this.timelineManager.topSectionHeight; - } - - #handleLoadError(error: unknown) { - const _$t = get(t); - handleError(error, _$t('errors.failed_to_load_assets')); - } findDayGroupForAsset(asset: TimelineAsset) { for (const group of this.dayGroups) { @@ -318,7 +240,7 @@ export class MonthGroup { } findAssetAbsolutePosition(assetId: string) { - this.timelineManager.clearDeferredLayout(this); + this.#clearDeferredLayout(); for (const group of this.dayGroups) { const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId); if (viewerAsset) { @@ -377,11 +299,25 @@ export class MonthGroup { this.loader?.cancel(); } + layout(noDefer: boolean) { + layoutMonthGroup(this.timelineManager, this, noDefer); + } + + #clearDeferredLayout() { + const hasDeferred = this.dayGroups.some((group) => group.deferredLayout); + if (hasDeferred) { + updateGeometry(this.timelineManager, this, { invalidateHeight: true, noDefer: true }); + for (const group of this.dayGroups) { + group.deferredLayout = false; + } + } + } + updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) { this.intersecting = intersecting; this.actuallyIntersecting = actuallyIntersecting; if (intersecting) { - this.timelineManager.clearDeferredLayout(this); + this.#clearDeferredLayout(); } } } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 632b0efd81..55edaafe86 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -1,7 +1,7 @@ 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 { fromISODateTimeUTCToObject, getSegmentIdentifier } 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'; @@ -129,46 +129,46 @@ describe('TimelineManager', () => { it('loads a month', async () => { expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); - await timelineManager.loadSegment({ year: 2024, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ 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.loadSegment({ year: 2023, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 })); expect(sdkMock.getTimeBucket).toBeCalledTimes(0); }); it('cancels month loading', async () => { const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; - void timelineManager.loadSegment({ year: 2024, month: 1 }); + void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort'); month?.cancel(); expect(abortSpy).toBeCalledTimes(1); - await timelineManager.loadSegment({ year: 2024, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ 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.loadSegment({ year: 2024, month: 1 }), - timelineManager.loadSegment({ year: 2024, month: 1 }), + timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })), + timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })), ]); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - await timelineManager.loadSegment({ year: 2024, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ 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.loadSegment({ year: 2024, month: 1 }); + const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); month.cancel(); await loadPromise; expect(month?.getAssets().length).toEqual(0); - await timelineManager.loadSegment({ year: 2024, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); expect(month!.getAssets().length).toEqual(3); }); }); @@ -477,7 +477,7 @@ describe('TimelineManager', () => { }); it('returns previous assetId', async () => { - await timelineManager.loadSegment({ year: 2024, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); const a = month!.getAssets()[0]; @@ -487,8 +487,8 @@ describe('TimelineManager', () => { }); it('returns previous assetId spanning multiple months', async () => { - await timelineManager.loadSegment({ year: 2024, month: 2 }); - await timelineManager.loadSegment({ year: 2024, month: 3 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); @@ -499,7 +499,7 @@ describe('TimelineManager', () => { }); it('loads previous month', async () => { - await timelineManager.loadSegment({ year: 2024, month: 2 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); const a = month!.getFirstAsset(); @@ -513,9 +513,9 @@ describe('TimelineManager', () => { }); it('skips removed assets', async () => { - await timelineManager.loadSegment({ year: 2024, month: 1 }); - await timelineManager.loadSegment({ year: 2024, month: 2 }); - await timelineManager.loadSegment({ year: 2024, month: 3 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager); timelineManager.removeAssets([assetTwo.id]); @@ -523,7 +523,7 @@ describe('TimelineManager', () => { }); it('returns null when no more assets', async () => { - await timelineManager.loadSegment({ year: 2024, month: 3 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined(); }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 404d50f12f..f7d2b560f3 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -3,12 +3,16 @@ 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 TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; +import { + getSegmentIdentifier, + toTimelineAsset, + type TimelineDateTime, + type TimelineYearMonth, +} from '$lib/utils/timeline-util'; -import { clamp, debounce, isEqual } from 'lodash-es'; +import { isEqual } from 'lodash-es'; import { SvelteDate, SvelteMap, 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 { @@ -23,6 +27,8 @@ import { retrieveRange as retrieveRangeUtil, } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; +import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; +import { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import { DayGroup } from './day-group.svelte'; import { isMismatched, updateObject } from './internal/utils.svelte'; import { MonthGroup } from './month-group.svelte'; @@ -32,30 +38,14 @@ import type { Direction, ScrubberMonth, TimelineAsset, - TimelineManagerLayoutOptions, TimelineManagerOptions, - Viewport, } from './types'; -export class TimelineManager { - isInitialized = $state(false); - months: MonthGroup[] = $state([]); - topSectionHeight = $state(0); - bottomSectionHeight = $state(60); - timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight); - assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0)); - +export class TimelineManager extends PhotostreamManager { albumAssets: Set = new SvelteSet(); - scrubberMonths: ScrubberMonth[] = $state([]); scrubberTimelineHeight: number = $state(0); - - topIntersectingMonthGroup: MonthGroup | undefined = $state(); - - visibleWindow = $derived.by(() => ({ - top: this.#scrollTop, - bottom: this.#scrollTop + this.viewportHeight, - })); + #months: MonthGroup[] = $state([]); initTask = new CancellableTask( () => { @@ -73,121 +63,12 @@ export class TimelineManager { ); 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; + get months() { + return this.#months; } async *assetsIterator(options?: { @@ -199,7 +80,7 @@ export class TimelineManager { const direction = options?.direction ?? 'earlier'; let { startDayGroup, startAsset } = options ?? {}; for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) { - await this.loadSegment(monthGroup.yearMonth, { cancelable: false }); + await this.loadSegment(monthGroup.identifier, { cancelable: false }); yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction }); startDayGroup = startAsset = undefined; } @@ -235,75 +116,24 @@ export class TimelineManager { 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({ ...authManager.params, ...this.#options, }); - this.months = timebuckets.map((timeBucket) => { + this.#months = timebuckets.map((timeBucket) => { const date = new SvelteDate(timeBucket.timeBucket); return new MonthGroup( this, { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, timeBucket.count, + false, this.#options.order, ); }); this.albumAssets.clear(); - this.#updateViewportGeometry(false); + this.updateViewportGeometry(false); } async updateOptions(options: TimelineManagerOptions) { @@ -314,13 +144,13 @@ export class TimelineManager { return; } await this.initTask.reset(); - await this.#init(options); - this.#updateViewportGeometry(false); + await this.init(options); + this.updateViewportGeometry(false); } - async #init(options: TimelineManagerOptions) { + async init(options: TimelineManagerOptions) { this.isInitialized = false; - this.months = []; + this.#months = []; this.albumAssets.clear(); await this.initTask.execute(async () => { this.#options = options; @@ -333,36 +163,8 @@ export class TimelineManager { 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(); + updateViewportGeometry(changedWidth: boolean) { + super.updateViewportGeometry(changedWidth); this.#createScrubberMonths(); } @@ -377,37 +179,8 @@ export class TimelineManager { this.scrubberTimelineHeight = this.timelineHeight; } - createLayoutOptions() { - const viewportWidth = this.viewportWidth; - - return { - spacing: 2, - heightTolerance: 0.15, - rowHeight: this.#rowHeight, - rowWidth: Math.floor(viewportWidth), - }; - } - - async loadSegment(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise { - 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); - } + protected fetchSegment(segment: PhotostreamSegment, signal: AbortSignal): Promise { + return loadFromTimeBuckets(this, segment as MonthGroup, this.#options, signal); } addAssets(assets: TimelineAsset[]) { @@ -443,7 +216,7 @@ export class TimelineManager { } async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { - await this.loadSegment(yearMonth, options); + await this.loadSegment(getSegmentIdentifier(yearMonth), options); return getMonthGroupByDate(this, yearMonth); } @@ -455,7 +228,7 @@ export class TimelineManager { async getRandomMonthGroup() { const random = Math.floor(Math.random() * this.months.length); const month = this.months[random]; - await this.loadSegment(month.yearMonth, { cancelable: false }); + await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false }); return month; } @@ -528,7 +301,7 @@ export class TimelineManager { if (!monthGroup) { return; } - await this.loadSegment(dateTime, { cancelable: false }); + await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false }); const asset = monthGroup.findClosest(dateTime); if (asset) { return asset; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index b24e7782e4..682aedaad4 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -513,7 +513,7 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt try { for (const monthGroup of timelineManager.months) { - await timelineManager.loadSegment(monthGroup.yearMonth); + await timelineManager.loadSegment(monthGroup.identifier); if (!get(isSelectingAllAssets)) { assetInteraction.clearMultiselect(); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 6eec79b414..699b98f29c 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -30,6 +30,12 @@ export type ScrubberListener = (scrubberData: { scrollToFunction?: (top: number) => void; }) => void | Promise; +export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({ + matches(segment: any) { + return segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month; + }, +}); + // used for AssetResponseDto.dateTimeOriginal, amongst others export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime => DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime;