From 3a468a3f50478fc766bf49bc07ab6b6b799637ab Mon Sep 17 00:00:00 2001 From: midzelis Date: Sun, 21 Sep 2025 16:19:30 +0000 Subject: [PATCH] refactor(web): extract common timeline functionality into PhotostreamManager base classes Create abstract PhotostreamManager and PhotostreamSegment base classes to enable reusable timeline-like components. This refactoring extracts common viewport management, scroll handling, and segment operations from TimelineManager and MonthGroup into reusable abstractions. Changes: - Add PhotostreamManager.svelte.ts with viewport and scroll management - Add PhotostreamSegment.svelte.ts with segment positioning and intersection logic - Refactor TimelineManager to extend PhotostreamManager - Refactor MonthGroup to extend PhotostreamSegment - Add utility functions for segment identification and date formatting - Update tests to reflect new inheritance structure --- .../lib/components/timeline/Timeline.svelte | 11 +- .../timeline/TimelineDateGroup.svelte | 13 +- .../PhotostreamManager.svelte.ts | 316 ++++++++++++++++++ .../PhotostreamSegment.svelte.ts | 150 +++++++++ .../timeline-manager/day-group.svelte.ts | 4 +- .../internal/intersection-support.svelte.ts | 24 +- .../internal/layout-support.svelte.ts | 12 +- .../internal/operations-support.svelte.ts | 3 +- .../timeline-manager/month-group.svelte.ts | 179 ++++------ .../timeline-manager.svelte.spec.ts | 82 ++--- .../timeline-manager.svelte.ts | 286 ++-------------- web/src/lib/utils/asset-utils.ts | 2 +- web/src/lib/utils/timeline-util.ts | 17 + 13 files changed, 652 insertions(+), 447 deletions(-) create mode 100644 web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts create mode 100644 web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index f323235a13..b883b5c489 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -19,7 +19,12 @@ import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { navigate } from '$lib/utils/navigation'; - import { getTimes, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; + import { + getSegmentIdentifier, + getTimes, + type ScrubberListener, + type TimelineYearMonth, + } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; import { onMount, type Snippet } from 'svelte'; @@ -478,7 +483,7 @@ break; } if (started) { - await timelineManager.loadMonthGroup(monthGroup.yearMonth); + await timelineManager.loadSegment(monthGroup.identifier); for (const asset of monthGroup.assetsIterator()) { if (deselect) { assetInteraction.removeAssetFromMultiselectGroup(asset.id); @@ -553,7 +558,7 @@ $effect(() => { if ($showAssetViewer) { const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60); - void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month }); + void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month })); } }); diff --git a/web/src/lib/components/timeline/TimelineDateGroup.svelte b/web/src/lib/components/timeline/TimelineDateGroup.svelte index 30f6e65edb..0a58d3f191 100644 --- a/web/src/lib/components/timeline/TimelineDateGroup.svelte +++ b/web/src/lib/components/timeline/TimelineDateGroup.svelte @@ -12,7 +12,6 @@ import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; - import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util'; import { Icon } from '@immich/ui'; import type { Snippet } from 'svelte'; import { flip } from 'svelte/animate'; @@ -125,16 +124,6 @@ return intersectable.filter((int) => int.intersecting); } - const getDayGroupFullDate = (dayGroup: DayGroup): string => { - const { month, year } = dayGroup.monthGroup.yearMonth; - const date = fromTimelinePlainDate({ - year, - month, - day: dayGroup.day, - }); - return getDateLocaleString(date); - }; - $effect.root(() => { if (timelineManager.scrollCompensation.monthGroup === monthGroup) { onScrollCompensation(timelineManager.scrollCompensation); @@ -185,7 +174,7 @@ {/if} - + {dayGroup.groupTitle} diff --git a/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts b/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts new file mode 100644 index 0000000000..842f700473 --- /dev/null +++ b/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts @@ -0,0 +1,316 @@ +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/photostream-manager/PhotostreamSegment.svelte'; +import type { + AssetDescriptor, + TimelineAsset, + TimelineManagerLayoutOptions, + Viewport, +} from '$lib/managers/timeline-manager/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, + ); + + #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; + } + } + } + + async init() { + this.isInitialized = false; + await this.initTask.execute(() => Promise.resolve(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()); + } + + 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.load(cancelable); + if (result === 'LOADED') { + updateIntersectionMonthGroup(this, segment); + } + } + + getSegmentByIdentifier(identifier: SegmentIdentifier) { + return this.months.find((segment) => identifier.matches(segment)); + } + + getSegmentForAssetId(assetId: string) { + for (const month of this.months) { + const asset = month.assets.find((asset) => asset.id === assetId); + if (asset) { + return month; + } + } + } + + 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); + } + + retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise { + const range: TimelineAsset[] = []; + let collecting = false; + + for (const month of this.months) { + for (const asset of month.assets) { + if (asset.id === start.id) { + collecting = true; + } + if (collecting) { + range.push(asset); + } + if (asset.id === end.id) { + return Promise.resolve(range); + } + } + } + return Promise.resolve(range); + } +} diff --git a/web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts b/web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts new file mode 100644 index 0000000000..6b999b98be --- /dev/null +++ b/web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts @@ -0,0 +1,150 @@ +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/photostream-manager/PhotostreamManager.svelte'; +import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; +import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; + +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); + #assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset)); + + initialCount = $state(0); + percent = $state(0); + + assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount)); + loader = new CancellableTask( + () => this.markLoaded(), + () => this.markCanceled, + () => this.handleLoadError, + ); + isHeightActual = $state(false); + + abstract get timelineManager(): PhotostreamManager; + + abstract get identifier(): SegmentIdentifier; + + abstract get id(): string; + + 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) { + void this.load(true); + } else { + this.cancel(); + } + } + + get intersecting() { + return this.#intersecting; + } + + async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> { + return await this.loader?.execute(async (signal: AbortSignal) => { + await this.fetch(signal); + }, cancelable); + } + + protected abstract fetch(signal: AbortSignal): Promise; + + get assets(): TimelineAsset[] { + return this.#assets; + } + + abstract get viewerAssets(): ViewerAsset[]; + + 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(_: boolean) {} + + updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) { + this.intersecting = intersecting; + this.actuallyIntersecting = actuallyIntersecting; + } + + abstract findAssetAbsolutePosition(assetId: string): number; +} diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts index 9d5008bf83..384bc15af3 100644 --- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts @@ -13,6 +13,7 @@ export class DayGroup { readonly monthGroup: MonthGroup; readonly index: number; readonly groupTitle: string; + readonly groupTitleFull: string; readonly day: number; viewerAssets: ViewerAsset[] = $state([]); @@ -26,11 +27,12 @@ export class DayGroup { #col = $state(0); #deferredLayout = false; - constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) { + constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string, groupTitleFull: string) { this.index = index; this.monthGroup = monthGroup; this.day = day; this.groupTitle = groupTitle; + this.groupTitleFull = groupTitleFull; } get top() { 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 185d74e0b0..dc1d00ae6a 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,27 +1,23 @@ +import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte'; +import type { PhotostreamSegment } from '$lib/managers/photostream-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, INTERSECTION_EXPAND_BOTTOM, ); } - month.intersecting = actuallyIntersecting || preIntersecting; - month.actuallyIntersecting = actuallyIntersecting; - if (preIntersecting || actuallyIntersecting) { - timelineManager.clearDeferredLayout(month); - } + month.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting }); } /** @@ -40,9 +36,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, ) { @@ -58,7 +54,7 @@ export function calculateMonthGroupIntersecting( * Calculate intersection for viewer assets with additional parameters like header height and scroll compensation */ export function calculateViewerAssetIntersecting( - timelineManager: TimelineManager, + timelineManager: PhotostreamManager, positionTop: number, positionHeight: number, expandTop: number = INTERSECTION_EXPAND_TOP, 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..6b11b8800d 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,14 @@ +import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte'; +import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte'; +import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.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,7 +23,7 @@ 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) { 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 e406972900..4149577181 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -1,22 +1,26 @@ import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk'; -import { CancellableTask } from '$lib/utils/cancellable-task'; -import { handleError } from '$lib/utils/handle-error'; import { formatGroupTitle, + formatGroupTitleFull, formatMonthGroupTitle, 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 { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; +import { + PhotostreamSegment, + type SegmentIdentifier, +} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte'; import { SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte'; @@ -24,71 +28,49 @@ 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.loadMonthGroup(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; + } + + fetch(signal: AbortSignal): Promise { + return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal); } get lastDayGroup() { @@ -99,9 +81,9 @@ export class MonthGroup { return this.dayGroups[0]?.getFirstAsset(); } - getAssets() { + get viewerAssets() { // eslint-disable-next-line unicorn/no-array-reduce - return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []); + return this.dayGroups.reduce((accumulator: ViewerAsset[], g: DayGroup) => accumulator.concat(g.viewerAssets), []); } sortDayGroups() { @@ -222,7 +204,8 @@ export class MonthGroup { addContext.setDayGroup(dayGroup, localDateTime); } else { const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime)); - dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle); + const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime)); + dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle, groupTitleFull); this.dayGroups.push(dayGroup); addContext.setDayGroup(dayGroup, localDateTime); addContext.newDayGroups.add(dayGroup); @@ -242,67 +225,15 @@ export class MonthGroup { return this.getRandomDayGroup()?.getRandomAsset()?.asset; } + get id() { + return this.viewId; + } + get viewId() { const { year, month } = this.yearMonth; 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) { if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) { @@ -316,7 +247,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) { @@ -374,4 +305,26 @@ export class MonthGroup { cancel() { 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.#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 f845caa119..1640cb79a1 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'; @@ -92,7 +92,7 @@ describe('TimelineManager', () => { }); }); - describe('loadMonthGroup', () => { + describe('loadSegment', () => { let timelineManager: TimelineManager; const bucketAssets: Record = { '2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) => @@ -128,48 +128,48 @@ describe('TimelineManager', () => { }); it('loads a month', async () => { - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); - await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3); }); it('ignores invalid months', async () => { - await timelineManager.loadMonthGroup({ 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.loadMonthGroup({ 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.loadMonthGroup({ year: 2024, month: 1 }); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.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 }), + timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })), + timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })), ]); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - await timelineManager.loadMonthGroup({ 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.loadMonthGroup({ year: 2024, month: 1 }); + const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); month.cancel(); await loadPromise; - expect(month?.getAssets().length).toEqual(0); + expect(month?.assets.length).toEqual(0); - await timelineManager.loadMonthGroup({ year: 2024, month: 1 }); - expect(month!.getAssets().length).toEqual(3); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); + expect(month!.assets.length).toEqual(3); }); }); @@ -198,7 +198,7 @@ describe('TimelineManager', () => { expect(timelineManager.months.length).toEqual(1); expect(timelineManager.assetCount).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(1); + expect(timelineManager.months[0].assets.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); @@ -215,7 +215,7 @@ describe('TimelineManager', () => { expect(timelineManager.months.length).toEqual(1); expect(timelineManager.assetCount).toEqual(2); - expect(timelineManager.months[0].getAssets().length).toEqual(2); + expect(timelineManager.months[0].assets.length).toEqual(2); expect(timelineManager.months[0].yearMonth.year).toEqual(2024); expect(timelineManager.months[0].yearMonth.month).toEqual(1); }); @@ -240,10 +240,10 @@ describe('TimelineManager', () => { 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); + expect(month?.assets.length).toEqual(3); + expect(month?.assets[0].id).toEqual(assetOne.id); + expect(month?.assets[1].id).toEqual(assetThree.id); + expect(month?.assets[2].id).toEqual(assetTwo.id); }); it('orders months by descending date', () => { @@ -341,14 +341,14 @@ describe('TimelineManager', () => { 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); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.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: 1 })?.assets.length).toEqual(0); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1); }); }); @@ -374,7 +374,7 @@ describe('TimelineManager', () => { expect(timelineManager.assetCount).toEqual(2); expect(timelineManager.months.length).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(2); + expect(timelineManager.months[0].assets.length).toEqual(2); }); it('removes asset from month', () => { @@ -388,7 +388,7 @@ describe('TimelineManager', () => { expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.months.length).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(1); + expect(timelineManager.months[0].assets.length).toEqual(1); }); it('does not remove month when empty', () => { @@ -477,45 +477,45 @@ describe('TimelineManager', () => { }); it('returns previous assetId', async () => { - await timelineManager.loadMonthGroup({ 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]; - const b = month!.getAssets()[1]; + const a = month!.assets[0]; + const b = month!.assets[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 }); + 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 }); - const a = month!.getAssets()[0]; - const b = previousMonth!.getAssets()[0]; + const a = month!.assets[0]; + const b = previousMonth!.assets[0]; const previous = await timelineManager.getLaterAsset(a); expect(previous).toEqual(b); }); it('loads previous month', async () => { - await timelineManager.loadMonthGroup({ 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(); const b = previousMonth!.getFirstAsset(); - const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute'); + const loadSegmentSpy = 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(loadSegmentSpy).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 }); + 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.loadMonthGroup({ 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 172cd07a02..54ec7d5a9a 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -3,14 +3,18 @@ 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 { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.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, @@ -32,29 +36,14 @@ import type { 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)); - +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( () => { @@ -72,121 +61,16 @@ 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(); - } + get months() { + return this.#months; } - #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 options() { + return this.#options; } async *assetsIterator(options?: { @@ -198,7 +82,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.loadMonthGroup(monthGroup.yearMonth, { cancelable: false }); + await this.loadSegment(monthGroup.identifier, { cancelable: false }); yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction }); startDayGroup = startAsset = undefined; } @@ -234,75 +118,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) { @@ -313,16 +146,16 @@ export class TimelineManager { return; } await this.initTask.reset(); - await this.#init(options); - this.#updateViewportGeometry(false); + this.#options = options; + await this.init(); + this.updateViewportGeometry(false); } - async #init(options: TimelineManagerOptions) { + async init() { this.isInitialized = false; - this.months = []; + this.#months = []; this.albumAssets.clear(); await this.initTask.execute(async () => { - this.#options = options; await this.#initializeMonthGroups(); }, true); } @@ -332,36 +165,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(); } @@ -376,39 +181,6 @@ 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 loadMonthGroup(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); - } - } - addAssets(assets: TimelineAsset[]) { const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset)); const notUpdated = this.updateAssets(assetsToUpdate); @@ -442,7 +214,7 @@ export class TimelineManager { } async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { - await this.loadMonthGroup(yearMonth, options); + await this.loadSegment(getSegmentIdentifier(yearMonth), options); return getMonthGroupByDate(this, yearMonth); } @@ -454,7 +226,7 @@ export class TimelineManager { async getRandomMonthGroup() { const random = Math.floor(Math.random() * this.months.length); const month = this.months[random]; - await this.loadMonthGroup(month.yearMonth, { cancelable: false }); + await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false }); return month; } @@ -527,7 +299,7 @@ export class TimelineManager { if (!monthGroup) { return; } - await this.loadMonthGroup(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 25e045c8a1..e9ae7931f2 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.loadMonthGroup(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 80cac0b738..0a25040510 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,3 +1,4 @@ +import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { locale } from '$lib/stores/preferences.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; @@ -151,6 +152,14 @@ export function formatGroupTitle(_date: DateTime): string { return getDateLocaleString(date, { locale: get(locale) }); } +export const formatGroupTitleFull = (_date: DateTime): string => { + if (!_date.isValid) { + return _date.toString(); + } + const date = _date as DateTime; + return getDateLocaleString(date); +}; + export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); @@ -234,3 +243,11 @@ export function setDifference(setA: Set, setB: Set): SvelteSet { } return result; } + +export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({ + matches(segment: MonthGroup) { + return ( + segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month + ); + }, +});