diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 07196c3b22..90c8815633 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -440,10 +440,8 @@ assetInteraction.clearAssetSelectionCandidates(); if (assetInteraction.assetSelectionStart && rangeSelection) { - let startBucket = timelineManager.getSegmentForAssetId(assetInteraction.assetSelectionStart.id) as - | TimelineMonth - | undefined; - let endBucket = timelineManager.getSegmentForAssetId(asset.id) as TimelineMonth | undefined; + let startBucket = await timelineManager.search.getMonthForAsset(assetInteraction.assetSelectionStart.id); + let endBucket = await timelineManager.search.getMonthForAsset(asset.id); if (!startBucket || !endBucket) { return; diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 43ab6a094f..351f0f023b 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -40,10 +40,10 @@ const handlePrevious = async () => { const release = await mutex.acquire(); - const laterAsset = await timelineManager.getLaterAsset($viewingAsset); + const laterAsset = await timelineManager.search.getLaterAsset($viewingAsset); if (laterAsset) { - const preloadAsset = await timelineManager.getLaterAsset(laterAsset); + const preloadAsset = await timelineManager.search.getLaterAsset(laterAsset); const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id }); assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); await navigate({ targetRoute: 'current', assetId: laterAsset.id }); @@ -55,10 +55,10 @@ const handleNext = async () => { const release = await mutex.acquire(); - const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset); + const earlierAsset = await timelineManager.search.getEarlierAsset($viewingAsset); if (earlierAsset) { - const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset); + const preloadAsset = await timelineManager.search.getEarlierAsset(earlierAsset); const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id }); assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []); await navigate({ targetRoute: 'current', assetId: earlierAsset.id }); @@ -69,7 +69,7 @@ }; const handleRandom = async () => { - const randomAsset = await timelineManager.getRandomAsset(); + const randomAsset = await timelineManager.search.getRandomAsset(); if (randomAsset) { const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id }); diff --git a/web/src/lib/components/timeline/actions/focus-actions.ts b/web/src/lib/components/timeline/actions/focus-actions.ts index 22cf536954..1599f009b3 100644 --- a/web/src/lib/components/timeline/actions/focus-actions.ts +++ b/web/src/lib/components/timeline/actions/focus-actions.ts @@ -31,7 +31,7 @@ export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean export const setFocusTo = async ( scrollToAsset: (asset: TimelineAsset) => boolean, - store: TimelineManager, + timelineManager: TimelineManager, direction: 'earlier' | 'later', interval: 'day' | 'month' | 'year' | 'asset', ) => { @@ -53,8 +53,8 @@ export const setFocusTo = async ( const asset = direction === 'earlier' - ? await store.getEarlierAsset({ id }, interval) - : await store.getLaterAsset({ id }, interval); + ? await timelineManager.search.getEarlierAsset({ id }, interval) + : await timelineManager.search.getLaterAsset({ id }, interval); if (!invocation.isStillValid()) { return; diff --git a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts index 0224b8ca47..35a956f604 100644 --- a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts @@ -278,15 +278,6 @@ export abstract class VirtualScrollManager { await segment.load(cancelable); } - - getSegmentForAssetId(assetId: string) { - for (const segment of this.segments) { - const asset = segment.assets.find((asset) => asset.id === assetId); - if (asset) { - return segment; - } - } - } } export const isEmptyViewport = (viewport: Viewport) => viewport.width === 0 || viewport.height === 0; diff --git a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts index 7ca6b9902e..40caeb99ca 100644 --- a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts @@ -19,6 +19,10 @@ async function getAssets(timelineManager: TimelineManager) { return assets; } +function getMonthForAssetId(timelineManager: TimelineManager, id: string) { + return timelineManager.search.findMonthForAsset(id)?.month; +} + function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset { return { ...arg, @@ -586,8 +590,8 @@ describe('TimelineManager', () => { }); it('returns null for invalid assetId', async () => { - expect(() => timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow(); - expect(await timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined(); + expect(() => timelineManager.search.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow(); + expect(await timelineManager.search.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined(); }); it('returns previous assetId', async () => { @@ -596,7 +600,7 @@ describe('TimelineManager', () => { const a = month!.assets[0]; const b = month!.assets[1]; - const previous = await timelineManager.getLaterAsset(b); + const previous = await timelineManager.search.getLaterAsset(b); expect(previous).toEqual(a); }); @@ -608,7 +612,7 @@ describe('TimelineManager', () => { const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 }); const a = month!.assets[0]; const b = previousMonth!.assets[0]; - const previous = await timelineManager.getLaterAsset(a); + const previous = await timelineManager.search.getLaterAsset(a); expect(previous).toEqual(b); }); @@ -620,7 +624,7 @@ describe('TimelineManager', () => { const b = previousMonth!.getFirstAsset(); const loadmonthSpy = vi.spyOn(month!.loader!, 'execute'); const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute'); - const previous = await timelineManager.getLaterAsset(a); + const previous = await timelineManager.search.getLaterAsset(a); expect(previous).toEqual(b); expect(loadmonthSpy).toBeCalledTimes(0); expect(previousMonthSpy).toBeCalledTimes(0); @@ -633,12 +637,12 @@ describe('TimelineManager', () => { const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager); timelineManager.removeAssets([assetTwo.id]); - expect(await timelineManager.getLaterAsset(assetThree)).toEqual(assetOne); + expect(await timelineManager.search.getLaterAsset(assetThree)).toEqual(assetOne); }); it('returns null when no more assets', async () => { await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); - expect(await timelineManager.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined(); + expect(await timelineManager.search.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined(); }); }); @@ -670,10 +674,10 @@ describe('TimelineManager', () => { ); timelineManager.upsertAssets([assetOne, assetTwo]); - expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.year).toEqual(2024); - expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.month).toEqual(2); - expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024); - expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1); + expect(getMonthForAssetId(timelineManager, assetTwo.id)?.yearMonth.year).toEqual(2024); + expect(getMonthForAssetId(timelineManager, assetTwo.id)?.yearMonth.month).toEqual(2); + expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.year).toEqual(2024); + expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.month).toEqual(1); }); it('ignores removed months', () => { @@ -690,8 +694,8 @@ describe('TimelineManager', () => { timelineManager.upsertAssets([assetOne, assetTwo]); timelineManager.removeAssets([assetTwo.id]); - expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024); - expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1); + expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.year).toEqual(2024); + expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.month).toEqual(1); }); }); @@ -740,7 +744,7 @@ describe('TimelineManager', () => { expect(assetCount).toBe(14); const discoveredAssets: Set = new Set(); for (let idx = 0; idx < assetCount; idx++) { - const asset = await timelineManager.getRandomAsset(idx); + const asset = await timelineManager.search.getRandomAsset(idx); expect(asset).toBeDefined(); const id = asset!.id; expect(discoveredAssets.has(id)).toBeFalsy(); diff --git a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts index d39621b518..c8a8db8774 100644 --- a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts @@ -6,7 +6,6 @@ import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svel import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte'; import { TimelineWebsocketExtension } from '$lib/managers/timeline-manager/TimelineWebsocketExtension'; import type { - AssetDescriptor, AssetOperation, Direction, ScrubberMonth, @@ -16,19 +15,15 @@ import type { } from '$lib/managers/timeline-manager/types'; import { isMismatched, setDifferenceInPlace, updateObject } from '$lib/managers/timeline-manager/utils.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { - getSegmentIdentifier, - toTimelineAsset, - type TimelineDateTime, - type TimelineYearMonth, -} from '$lib/utils/timeline-util'; -import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; +import { getSegmentIdentifier } from '$lib/utils/timeline-util'; +import { AssetOrder, getTimeBuckets } from '@immich/sdk'; import { isEqual } from 'lodash-es'; import { SvelteDate, SvelteSet } from 'svelte/reactivity'; export class TimelineManager extends VirtualScrollManager { override bottomSectionHeight = $state(60); readonly search = new TimelineSearchExtension(this); + readonly websocket = new TimelineWebsocketExtension(this); readonly albumAssets: Set = new SvelteSet(); readonly limitedScroll = $derived(this.maxScrollPercent < 0.5); readonly initTask = new CancellableTask( @@ -37,10 +32,10 @@ export class TimelineManager extends VirtualScrollManager { if (this.#options.albumId || this.#options.personId) { return; } - this.connect(); + this.websocket.connect(); }, () => { - this.disconnect(); + this.websocket.disconnect(); this.isInitialized = false; }, () => void 0, @@ -50,7 +45,6 @@ export class TimelineManager extends VirtualScrollManager { scrubberMonths: ScrubberMonth[] = $state([]); scrubberTimelineHeight: number = $state(0); - #websocketSupport: TimelineWebsocketExtension | undefined; #options: TimelineManagerOptions = {}; #scrollableElement: HTMLElement | undefined = $state(); @@ -88,7 +82,7 @@ export class TimelineManager extends VirtualScrollManager { } public override destroy() { - this.disconnect(); + this.websocket.disconnect(); super.destroy(); } @@ -127,102 +121,12 @@ export class TimelineManager extends VirtualScrollManager { this.onUpdateViewport(oldViewport, viewport); } - connect() { - if (this.#websocketSupport) { - throw new Error('TimelineManager already connected'); - } - this.#websocketSupport = new TimelineWebsocketExtension(this); - this.#websocketSupport.connectWebsocketEvents(); - } - - disconnect() { - if (!this.#websocketSupport) { - return; - } - this.#websocketSupport.disconnectWebsocketEvents(); - this.#websocketSupport = undefined; - } - upsertAssets(assets: TimelineAsset[]) { const notExcluded = assets.filter((asset) => !this.isExcluded(asset)); const notUpdated = this.#updateAssets(notExcluded); this.addAssetsToSegments(notUpdated); } - async findMonthForAsset(id: string) { - if (!this.isInitialized) { - await this.initTask.waitUntilCompletion(); - } - - let { month } = this.search.findMonthForAsset(id) ?? {}; - if (month) { - return month; - } - - const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null); - if (!response) { - return; - } - - const asset = toTimelineAsset(response); - if (!asset || this.isExcluded(asset)) { - return; - } - - month = await this.#loadMonthAtTime(asset.localDateTime, { cancelable: false }); - if (month?.findAssetById({ id })) { - return month; - } - } - - async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { - await this.loadSegment(getSegmentIdentifier(yearMonth), options); - return this.search.findMonthByDate(yearMonth); - } - - getMonthByAssetId(assetId: string) { - const monthInfo = this.search.findMonthForAsset(assetId); - return monthInfo?.month; - } - - // note: the `index` input is expected to be in the range [0, assetCount). This - // value can be passed to make the method deterministic, which is mainly useful - // for testing. - async getRandomAsset(index?: number): Promise { - const randomAssetIndex = index ?? Math.floor(Math.random() * this.assetCount); - - let accumulatedCount = 0; - - let randomMonth: TimelineMonth | undefined = undefined; - for (const month of this.segments) { - if (randomAssetIndex < accumulatedCount + month.assetsCount) { - randomMonth = month; - break; - } - - accumulatedCount += month.assetsCount; - } - if (!randomMonth) { - return; - } - await this.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false }); - - let randomDay: TimelineDay | undefined = undefined; - for (const day of randomMonth.days) { - if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) { - randomDay = day; - break; - } - - accumulatedCount += day.viewerAssets.length; - } - if (!randomDay) { - return; - } - - return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset; - } - /** * Executes the given operation against every passed in asset id. * @@ -347,42 +251,6 @@ export class TimelineManager extends VirtualScrollManager { return this.segments[0]?.getFirstAsset(); } - async getLaterAsset( - assetDescriptor: AssetDescriptor, - interval: 'asset' | 'day' | 'month' | 'year' = 'asset', - ): Promise { - return await this.search.getAssetWithOffset(assetDescriptor, interval, 'later'); - } - - async getEarlierAsset( - assetDescriptor: AssetDescriptor, - interval: 'asset' | 'day' | 'month' | 'year' = 'asset', - ): Promise { - return await this.search.getAssetWithOffset(assetDescriptor, interval, 'earlier'); - } - - async getClosestAssetToDate(dateTime: TimelineDateTime) { - let month = this.search.findMonthForDate(dateTime); - if (!month) { - month = this.search.findClosestGroupForDate(this.segments, dateTime); - if (!month) { - return; - } - } - await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false }); - const asset = month.findClosest(dateTime); - if (asset) { - return asset; - } - for await (const asset of this.assetsIterator({ startMonth: month })) { - return asset; - } - } - - async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) { - return this.search.retrieveRange(start, end); - } - async *assetsIterator(options?: { startMonth?: TimelineMonth; startDay?: TimelineDay; diff --git a/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.spec.ts b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.spec.ts index b406161b5d..3884b8fb21 100644 --- a/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.spec.ts @@ -1,6 +1,5 @@ -import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte'; +import { findClosestMonthToDate } from '$lib/utils/timeline-util'; import { describe, expect, it } from 'vitest'; function createMockMonthGroup(year: number, month: number): TimelineMonth { @@ -9,37 +8,33 @@ function createMockMonthGroup(year: number, month: number): TimelineMonth { } as TimelineMonth; } -describe('findClosestGroupForDate', () => { - let search: TimelineSearchExtension; - beforeEach(() => { - search = new TimelineSearchExtension(new TimelineManager()); - }); +describe('findClosestMonthToDate', () => { it('should return undefined for empty months array', () => { - const result = search.findClosestGroupForDate([], { year: 2024, month: 1 }); + const result = findClosestMonthToDate([], { year: 2024, month: 1 }); expect(result).toBeUndefined(); }); it('should return the only month when there is only one month', () => { const months = [createMockMonthGroup(2024, 6)]; - const result = search.findClosestGroupForDate(months, { year: 2025, month: 1 }); + const result = findClosestMonthToDate(months, { year: 2025, month: 1 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should return exact match when available', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; - const result = search.findClosestGroupForDate(months, { year: 2024, month: 6 }); + const result = findClosestMonthToDate(months, { year: 2024, month: 6 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should find closest month when target is between two months', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; - const result = search.findClosestGroupForDate(months, { year: 2024, month: 4 }); + const result = findClosestMonthToDate(months, { year: 2024, month: 4 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => { const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)]; - const result = search.findClosestGroupForDate(months, { year: 2024, month: 1 }); + const result = findClosestMonthToDate(months, { year: 2024, month: 1 }); // 2024-01 is 1 month from 2023-12 and 1 month from 2024-02 // Should return first encountered with min distance (2023-12) expect(result?.yearMonth).toEqual({ year: 2023, month: 12 }); @@ -47,33 +42,33 @@ describe('findClosestGroupForDate', () => { it('should correctly calculate distance across years', () => { const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)]; - const result = search.findClosestGroupForDate(months, { year: 2023, month: 6 }); + const result = findClosestMonthToDate(months, { year: 2023, month: 6 }); // Both are exactly 12 months away, should return first encountered expect(result?.yearMonth).toEqual({ year: 2022, month: 6 }); }); it('should handle target before all months', () => { const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)]; - const result = search.findClosestGroupForDate(months, { year: 2024, month: 1 }); + const result = findClosestMonthToDate(months, { year: 2024, month: 1 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should handle target after all months', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)]; - const result = search.findClosestGroupForDate(months, { year: 2025, month: 1 }); + const result = findClosestMonthToDate(months, { year: 2025, month: 1 }); expect(result?.yearMonth).toEqual({ year: 2024, month: 6 }); }); it('should handle multiple years correctly', () => { const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)]; - const result = search.findClosestGroupForDate(months, { year: 2023, month: 1 }); + const result = findClosestMonthToDate(months, { year: 2023, month: 1 }); // 2023-01 is 12 months from 2022-01 and 12 months from 2024-01 expect(result?.yearMonth).toEqual({ year: 2022, month: 1 }); }); it('should prefer closer month when one is clearly closer', () => { const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)]; - const result = search.findClosestGroupForDate(months, { year: 2024, month: 11 }); + const result = findClosestMonthToDate(months, { year: 2024, month: 11 }); // 2024-11 is 1 month from 2024-10 and 10 months from 2024-01 expect(result?.yearMonth).toEqual({ year: 2024, month: 10 }); }); diff --git a/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.ts index 02f2f1f5dd..af7404d5aa 100644 --- a/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineSearchExtension.svelte.ts @@ -1,16 +1,70 @@ +import { authManager } from '$lib/managers/auth-manager.svelte'; +import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; -import { AssetOrder } from '@immich/sdk'; -import { DateTime } from 'luxon'; +import { + findClosestMonthToDate, + getSegmentIdentifier, + plainDateTimeCompare, + toTimelineAsset, + type TimelineDateTime, + type TimelineYearMonth, +} from '$lib/utils/timeline-util'; +import { AssetOrder, getAssetInfo } from '@immich/sdk'; export class TimelineSearchExtension { #timelineManager: TimelineManager; constructor(timelineManager: TimelineManager) { this.#timelineManager = timelineManager; } - async getAssetWithOffset( + + async getLaterAsset( + assetDescriptor: AssetDescriptor, + interval: 'asset' | 'day' | 'month' | 'year' = 'asset', + ): Promise { + return await this.#getAssetWithOffset(assetDescriptor, interval, 'later'); + } + + async getEarlierAsset( + assetDescriptor: AssetDescriptor, + interval: 'asset' | 'day' | 'month' | 'year' = 'asset', + ): Promise { + return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier'); + } + + async getMonthForAsset(id: string) { + if (!this.#timelineManager.isInitialized) { + await this.#timelineManager.initTask.waitUntilCompletion(); + } + + let { month } = this.findMonthForAsset(id) ?? {}; + if (month) { + return month; + } + + const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null); + if (!response) { + return; + } + + const asset = toTimelineAsset(response); + if (!asset || this.#timelineManager.isExcluded(asset)) { + return; + } + + month = await this.#loadMonthAtTime(asset.localDateTime, { cancelable: false }); + if (month?.findAssetById({ id })) { + return month; + } + } + + async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { + await this.#timelineManager.loadSegment(getSegmentIdentifier(yearMonth), options); + return this.findMonthByDate(yearMonth); + } + + async #getAssetWithOffset( assetDescriptor: AssetDescriptor, interval: 'asset' | 'day' | 'month' | 'year' = 'asset', direction: Direction, @@ -22,16 +76,16 @@ export class TimelineSearchExtension { switch (interval) { case 'asset': { - return this.getAssetByAssetOffset(asset, month, direction); + return this.#getAssetByAssetOffset(asset, month, direction); } case 'day': { - return this.getAssetByDayOffset(asset, month, direction); + return this.#getAssetByDayOffset(asset, month, direction); } case 'month': { - return this.getAssetByMonthOffset(month, direction); + return this.#getAssetByMonthOffset(month, direction); } case 'year': { - return this.getAssetByYearOffset(month, direction); + return this.#getAssetByYearOffset(month, direction); } } } @@ -51,7 +105,45 @@ export class TimelineSearchExtension { ); } - async getAssetByAssetOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) { + // note: the `index` input is expected to be in the range [0, assetCount). This + // value can be passed to make the method deterministic, which is mainly useful + // for testing. + async getRandomAsset(index?: number): Promise { + const randomAssetIndex = index ?? Math.floor(Math.random() * this.#timelineManager.assetCount); + + let accumulatedCount = 0; + + let randomMonth: TimelineMonth | undefined = undefined; + for (const month of this.#timelineManager.segments) { + if (randomAssetIndex < accumulatedCount + month.assetsCount) { + randomMonth = month; + break; + } + + accumulatedCount += month.assetsCount; + } + if (!randomMonth) { + return; + } + await this.#timelineManager.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false }); + + let randomDay: TimelineDay | undefined = undefined; + for (const day of randomMonth.days) { + if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) { + randomDay = day; + break; + } + + accumulatedCount += day.viewerAssets.length; + } + if (!randomDay) { + return; + } + + return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset; + } + + async #getAssetByAssetOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) { const day = month.findDayForAsset(asset); for await (const targetAsset of this.#timelineManager.assetsIterator({ startMonth: month, @@ -65,7 +157,7 @@ export class TimelineSearchExtension { } } - async getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) { + async #getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) { const day = month.findDayForAsset(asset); for await (const targetAsset of this.#timelineManager.assetsIterator({ startMonth: month, @@ -79,7 +171,7 @@ export class TimelineSearchExtension { } } - async getAssetByMonthOffset(month: TimelineMonth, direction: Direction) { + async #getAssetByMonthOffset(month: TimelineMonth, direction: Direction) { for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) { if (targetMonth.yearMonth.month !== month.yearMonth.month) { const { value, done } = await this.#timelineManager @@ -90,7 +182,7 @@ export class TimelineSearchExtension { } } - async getAssetByYearOffset(month: TimelineMonth, direction: Direction) { + async #getAssetByYearOffset(month: TimelineMonth, direction: Direction) { for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) { if (targetMonth.yearMonth.year !== month.yearMonth.year) { const { value, done } = await this.#timelineManager @@ -140,22 +232,21 @@ export class TimelineSearchExtension { } } - findClosestGroupForDate(months: TimelineMonth[], targetYearMonth: TimelineYearMonth) { - const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month }); - - let closestMonth: TimelineMonth | undefined; - let minDifference = Number.MAX_SAFE_INTEGER; - - for (const month of months) { - const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month }); - const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months); - - if (totalDiff < minDifference) { - minDifference = totalDiff; - closestMonth = month; + async getClosestAssetToDate(dateTime: TimelineDateTime) { + let month = this.findMonthForDate(dateTime); + if (!month) { + month = findClosestMonthToDate(this.#timelineManager.segments, dateTime); + if (!month) { + return; } } - - return closestMonth; + await this.#timelineManager.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false }); + const asset = month.findClosest(dateTime); + if (asset) { + return asset; + } + for await (const asset of this.#timelineManager.assetsIterator({ startMonth: month })) { + return asset; + } } } diff --git a/web/src/lib/managers/timeline-manager/TimelineWebsocketExtension.ts b/web/src/lib/managers/timeline-manager/TimelineWebsocketExtension.ts index 7b9e446bd3..7158723c3d 100644 --- a/web/src/lib/managers/timeline-manager/TimelineWebsocketExtension.ts +++ b/web/src/lib/managers/timeline-manager/TimelineWebsocketExtension.ts @@ -23,12 +23,24 @@ export class TimelineWebsocketExtension { #pendingChanges: PendingChange[] = []; #unsubscribers: Unsubscriber[] = []; + #connected = false; constructor(timeineManager: TimelineManager) { this.#timelineManager = timeineManager; } - connectWebsocketEvents() { + connect() { + if (this.#connected) { + throw new Error('TimelineManager already connected'); + } + this.#connectWebsocketEvents(); + } + + disconnect() { + this.#disconnectWebsocketEvents(); + } + + #connectWebsocketEvents() { this.#unsubscribers.push( websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }), @@ -41,7 +53,7 @@ export class TimelineWebsocketExtension { ); } - disconnectWebsocketEvents() { + #disconnectWebsocketEvents() { for (const unsubscribe of this.#unsubscribers) { unsubscribe(); } diff --git a/web/src/lib/modals/NavigateToDateModal.svelte b/web/src/lib/modals/NavigateToDateModal.svelte index e3bd3cd5b0..3a99f45a07 100644 --- a/web/src/lib/modals/NavigateToDateModal.svelte +++ b/web/src/lib/modals/NavigateToDateModal.svelte @@ -28,7 +28,7 @@ // Get the local date/time components from the selected string using neutral timezone const dateTime = toDatetime(selectedDate, selectedOption) as DateTime; - const asset = await timelineManager.getClosestAssetToDate(dateTime.toObject()); + const asset = await timelineManager.search.getClosestAssetToDate(dateTime.toObject()); onClose(asset); }; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 2820843c20..f9a33de02d 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -223,31 +223,6 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelineDateTime, b: return aDateTime.millisecond - bDateTime.millisecond; }; -export function setDifference(setA: Set, setB: Set): Set { - // Check if native Set.prototype.difference is available (ES2025) - const setWithDifference = setA as unknown as Set & { difference?: (other: Set) => Set }; - if (setWithDifference.difference && typeof setWithDifference.difference === 'function') { - return setWithDifference.difference(setB); - } - const result = new Set(); - for (const value of setA) { - if (!setB.has(value)) { - result.add(value); - } - } - return result; -} - -/** - * Removes all elements of setB from setA in-place (mutates setA). - */ -export function setDifferenceInPlace(setA: Set, setB: Set): Set { - for (const value of setB) { - setA.delete(value); - } - return setA; -} - export const formatGroupTitleFull = (_date: DateTime): string => { if (!_date.isValid) { return _date.toString(); @@ -273,3 +248,22 @@ export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDate ); }, }); + +export const findClosestMonthToDate = (months: TimelineMonth[], targetYearMonth: TimelineYearMonth) => { + const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month }); + + let closestMonth: TimelineMonth | undefined; + let minDifference = Number.MAX_SAFE_INTEGER; + + for (const month of months) { + const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month }); + const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months); + + if (totalDiff < minDifference) { + minDifference = totalDiff; + closestMonth = month; + } + } + + return closestMonth; +}; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5c10c5aaac..6adc5ac8e2 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -140,7 +140,7 @@ const handleStartSlideshow = async () => { const asset = $slideshowNavigation === SlideshowNavigation.Shuffle - ? await timelineManager.getRandomAsset() + ? await timelineManager.search.getRandomAsset() : timelineManager.getFirstAsset(); if (asset) { handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));