Review comments, minus renames

pull/22272/head
midzelis 2025-10-08 17:01:31 +00:00
parent fb5a0089af
commit 14dad831ee
10 changed files with 121 additions and 156 deletions

View File

@ -11,7 +11,7 @@
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getSegmentIdentifier, TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@ -19,12 +19,7 @@
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { navigate } from '$lib/utils/navigation';
import {
getSegmentIdentifier,
getTimes,
type ScrubberListener,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { 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';
@ -272,11 +267,6 @@
}
});
const getMaxScrollPercent = () => {
const totalHeight = timelineManager.timelineHeight + bottomSectionHeight + timelineManager.topSectionHeight;
return (totalHeight - timelineManager.viewportHeight) / totalHeight;
};
const getMaxScroll = () => {
if (!element || !timelineElement) {
return 0;
@ -288,7 +278,7 @@
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
const topOffset = monthGroup.top;
const maxScrollPercent = getMaxScrollPercent();
const maxScrollPercent = timelineManager.maxScrollPercent;
const delta = monthGroup.height * monthGroupScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent;
@ -343,7 +333,7 @@
return;
}
let maxScrollPercent = getMaxScrollPercent();
let maxScrollPercent = timelineManager.maxScrollPercent;
let found = false;
const monthsLength = timelineManager.months.length;
@ -640,7 +630,7 @@
{@const display = monthGroup.intersecting}
{@const absoluteHeight = monthGroup.top}
{#if !monthGroup.isLoaded}
{#if !monthGroup.loaded}
<div
style:height={monthGroup.height + 'px'}
style:position="absolute"

View File

@ -1,24 +1,22 @@
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 { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import type {
AssetDescriptor,
TimelineAsset,
TimelineManagerLayoutOptions,
Viewport,
} from '$lib/managers/timeline-manager/types';
import { CancellableTask, TaskStatus } from '$lib/utils/cancellable-task';
import { clamp, debounce } from 'lodash-es';
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,
);
@ -71,6 +69,17 @@ export abstract class PhotostreamManager {
}
}
abstract get months(): PhotostreamSegment[];
get maxScrollPercent() {
const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight;
return (totalHeight - this.viewportHeight) / totalHeight;
}
get maxScroll() {
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
}
#setHeaderHeight(value: number) {
if (this.#headerHeight == value) {
return false;
@ -151,6 +160,10 @@ export abstract class PhotostreamManager {
return this.#viewportHeight;
}
get hasEmptyViewport() {
return this.viewportWidth === 0 || this.viewportHeight === 0;
}
updateSlidingWindow(scrollTop: number) {
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
@ -198,34 +211,12 @@ export abstract class PhotostreamManager {
await this.initTask.execute(() => Promise.resolve(undefined), true);
}
public destroy() {
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) {
if (!this.isInitialized || this.hasEmptyViewport) {
return;
}
for (const month of this.months) {
@ -246,29 +237,18 @@ export abstract class PhotostreamManager {
}
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true;
if (options) {
cancelable = options.cancelable;
}
const segment = this.getSegmentByIdentifier(identifier);
if (!segment) {
return;
}
if (segment.loader?.executed) {
const { cancelable = true } = options ?? {};
const segment = this.months.find((segment) => identifier.matches(segment));
if (!segment || segment.loader?.executed) {
return;
}
const result = await segment.load(cancelable);
if (result === 'LOADED') {
if (result === TaskStatus.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);
@ -285,15 +265,6 @@ export abstract class PhotostreamManager {
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<TimelineAsset[]> {
const range: TimelineAsset[] = [];
let collecting = false;

View File

@ -1,13 +1,13 @@
import { CancellableTask } from '$lib/utils/cancellable-task';
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';
import { CancellableTask, TaskStatus } 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 = {
id(): string;
matches(segment: PhotostreamSegment): boolean;
};
export abstract class PhotostreamSegment {
@ -22,10 +22,10 @@ export abstract class PhotostreamSegment {
initialCount = $state(0);
percent = $state(0);
assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount));
assetsCount = $derived.by(() => (this.loaded ? this.viewerAssets.length : this.initialCount));
loader = new CancellableTask(
() => this.markLoaded(),
() => this.markCanceled,
() => (this.loaded = true),
() => (this.loaded = false),
() => this.handleLoadError,
);
isHeightActual = $state(false);
@ -34,18 +34,18 @@ export abstract class PhotostreamSegment {
abstract get identifier(): SegmentIdentifier;
abstract get id(): string;
abstract get viewerAssets(): ViewerAsset[];
get isLoaded() {
abstract findAssetAbsolutePosition(assetId: string): number;
protected abstract fetch(signal: AbortSignal): Promise<void>;
get loaded() {
return this.#isLoaded;
}
protected markLoaded() {
this.#isLoaded = true;
}
protected markCanceled() {
this.#isLoaded = false;
protected set loaded(newValue: boolean) {
this.#isLoaded = newValue;
}
set intersecting(newValue: boolean) {
@ -65,22 +65,11 @@ export abstract class PhotostreamSegment {
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<void>;
get assets(): TimelineAsset[] {
return this.#assets;
}
abstract get viewerAssets(): ViewerAsset[];
set height(height: number) {
console.log('height', height);
if (this.#height === height) {
return;
}
@ -131,6 +120,12 @@ export abstract class PhotostreamSegment {
return this.#top + this.timelineManager.topSectionHeight;
}
async load(cancelable: boolean): Promise<TaskStatus> {
return await this.loader?.execute(async (signal: AbortSignal) => {
await this.fetch(signal);
}, cancelable);
}
protected handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_assets'));
@ -146,6 +141,4 @@ export abstract class PhotostreamSegment {
this.intersecting = intersecting;
this.actuallyIntersecting = actuallyIntersecting;
}
abstract findAssetAbsolutePosition(assetId: string): number;
}

View File

@ -13,7 +13,7 @@ export function updateGeometry(
if (invalidateHeight) {
month.isHeightActual = false;
}
if (!month.isLoaded) {
if (!month.loaded) {
const viewportWidth = timelineManager.viewportWidth;
if (!month.isHeightActual) {
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);

View File

@ -1,5 +1,9 @@
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import {
PhotostreamSegment,
type SegmentIdentifier,
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import {
formatGroupTitle,
formatGroupTitleFull,
@ -7,24 +11,16 @@ import {
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
getSegmentIdentifier,
getTimes,
setDifference,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
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 { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import type { TimelineManager } from './timeline-manager.svelte';
import { getSegmentIdentifier, type TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
@ -52,9 +48,7 @@ export class MonthGroup extends PhotostreamSegment {
this.#timelineManager = timelineManager;
this.#sortOrder = order;
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
if (loaded) {
this.markLoaded();
}
this.loaded = loaded;
}
get identifier() {

View File

@ -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, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { TimelineManager } from './timeline-manager.svelte';
@ -22,6 +22,14 @@ function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset
};
}
const initViewport = async (timelineManager: TimelineManager, viewportHeight = 1000, viewportWidth = 1588) => {
timelineManager.viewportHeight = viewportHeight;
timelineManager.viewportWidth = viewportWidth;
await timelineManager.init();
timelineManager.updateSlidingWindow(0);
timelineManager.updateIntersections();
};
describe('TimelineManager', () => {
beforeEach(() => {
vi.resetAllMocks();
@ -63,12 +71,12 @@ describe('TimelineManager', () => {
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 1000 });
await initViewport(timelineManager);
});
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3);
});
it('calculates month height', () => {
@ -82,13 +90,13 @@ describe('TimelineManager', () => {
expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
expect.objectContaining({ year: 2024, month: 1, height: 404.5 }),
]),
);
});
it('calculates timeline height', () => {
expect(timelineManager.timelineHeight).toBe(12_447.5);
expect(timelineManager.timelineHeight).toBe(12566);
});
});
@ -124,19 +132,19 @@ describe('TimelineManager', () => {
}
return bucketAssetsResponse[timeBucket];
});
await timelineManager.updateViewport({ width: 1588, height: 0 });
await initViewport(timelineManager);
});
it('loads a month', async () => {
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(sdkMock.getTimeBucket).toBeCalledTimes(2);
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
expect(sdkMock.getTimeBucket).toBeCalledTimes(2);
});
it('cancels month loading', async () => {
@ -154,10 +162,10 @@ describe('TimelineManager', () => {
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toBeCalledTimes(2);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toBeCalledTimes(2);
});
it('allows loading a canceled month', async () => {
@ -180,7 +188,8 @@ describe('TimelineManager', () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
timelineManager.viewportHeight = 1000;
timelineManager.viewportHeight = 1588;
});
it('is empty initially', () => {
@ -304,7 +313,8 @@ describe('TimelineManager', () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
timelineManager.viewportHeight = 1000;
timelineManager.viewportHeight = 1588;
});
it('ignores non-existing assets', () => {
@ -359,7 +369,8 @@ describe('TimelineManager', () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
timelineManager.viewportHeight = 1000;
timelineManager.viewportHeight = 1588;
});
it('ignores invalid IDs', () => {
@ -411,7 +422,8 @@ describe('TimelineManager', () => {
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 0, height: 0 });
await initViewport(timelineManager, 0, 0);
});
it('empty store returns null', () => {
@ -468,7 +480,8 @@ describe('TimelineManager', () => {
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 1000 });
initViewport(timelineManager);
});
it('returns null for invalid assetId', async () => {
@ -535,7 +548,7 @@ describe('TimelineManager', () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 0, height: 0 });
await initViewport(timelineManager, 0, 0);
});
it('returns null for invalid months', () => {
@ -618,7 +631,7 @@ describe('TimelineManager', () => {
]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 0 });
await initViewport(timelineManager);
});
it('gets all assets once', async () => {

View File

@ -3,12 +3,7 @@ import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import {
getSegmentIdentifier,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
@ -39,6 +34,17 @@ import type {
TimelineManagerOptions,
} from './types';
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({
id: () => {
return yearMonth.year + '-' + yearMonth.month;
},
matches: (segment: MonthGroup) => {
return (
segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month
);
},
});
export class TimelineManager extends PhotostreamManager {
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);

View File

@ -513,7 +513,7 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
try {
for (const monthGroup of timelineManager.months) {
await timelineManager.loadSegment(monthGroup.identifier);
await monthGroup.load(false);
if (!get(isSelectingAllAssets)) {
assetInteraction.clearMultiselect();

View File

@ -1,3 +1,10 @@
export enum TaskStatus {
DONE,
WAITED,
CANCELED,
LOADED,
ERRORED,
}
export class CancellableTask {
cancelToken: AbortController | null = null;
cancellable: boolean = true;
@ -32,18 +39,18 @@ export class CancellableTask {
async waitUntilCompletion() {
if (this.executed) {
return 'DONE';
return TaskStatus.DONE;
}
// if there is a cancel token, task is currently executing, so wait on the promise. If it
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
// in either case, we wait on the promise.
await this.complete;
return 'WAITED';
return TaskStatus.WAITED;
}
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
if (this.executed) {
return 'DONE';
return TaskStatus.DONE;
}
// if promise is pending, wait on previous request instead.
@ -54,7 +61,7 @@ export class CancellableTask {
this.cancellable = cancellable;
}
await this.complete;
return 'WAITED';
return TaskStatus.WAITED;
}
this.cancellable = cancellable;
const cancelToken = (this.cancelToken = new AbortController());
@ -62,18 +69,18 @@ export class CancellableTask {
try {
await f(cancelToken.signal);
if (cancelToken.signal.aborted) {
return 'CANCELED';
return TaskStatus.CANCELED;
}
this.#transitionToExecuted();
return 'LOADED';
return TaskStatus.LOADED;
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any).name === 'AbortError') {
// abort error is not treated as an error, but as a cancellation.
return 'CANCELED';
return TaskStatus.CANCELED;
}
this.#transitionToErrored(error);
return 'ERRORED';
return TaskStatus.ERRORED;
} finally {
this.cancelToken = null;
}

View File

@ -1,4 +1,3 @@
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';
@ -243,11 +242,3 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
}
return result;
}
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({
matches(segment: MonthGroup) {
return (
segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month
);
},
});