push top level functions from timelinemanager into extensions

This commit is contained in:
midzelis 2025-10-28 13:15:48 +00:00
parent 6d3dda7e2e
commit de84e46f62
12 changed files with 199 additions and 246 deletions

View File

@ -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;

View File

@ -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 });

View File

@ -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;

View File

@ -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;

View File

@ -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<string> = 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();

View File

@ -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<string> = 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<TimelineAsset | undefined> {
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<TimelineAsset | undefined> {
return await this.search.getAssetWithOffset(assetDescriptor, interval, 'later');
}
async getEarlierAsset(
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> {
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;

View File

@ -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 });
});

View File

@ -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<TimelineAsset | undefined> {
return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
}
async getEarlierAsset(
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> {
return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
}
async 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<TimelineAsset | undefined> {
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;
}
}
}

View File

@ -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();
}

View File

@ -28,7 +28,7 @@
// Get the local date/time components from the selected string using neutral timezone
const dateTime = toDatetime(selectedDate, selectedOption) as DateTime<true>;
const asset = await timelineManager.getClosestAssetToDate(dateTime.toObject());
const asset = await timelineManager.search.getClosestAssetToDate(dateTime.toObject());
onClose(asset);
};

View File

@ -223,31 +223,6 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelineDateTime, b:
return aDateTime.millisecond - bDateTime.millisecond;
};
export function setDifference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
// Check if native Set.prototype.difference is available (ES2025)
const setWithDifference = setA as unknown as Set<T> & { difference?: (other: Set<T>) => Set<T> };
if (setWithDifference.difference && typeof setWithDifference.difference === 'function') {
return setWithDifference.difference(setB);
}
const result = new Set<T>();
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<T>(setA: Set<T>, setB: Set<T>): Set<T> {
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;
};

View File

@ -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)));