mirror of
https://github.com/immich-app/immich.git
synced 2025-11-02 02:27:09 -05:00
Improves scroll indicator positioning when scrubbing through timelines with limited scrollable content (e.g., small albums). When a timeline's scrollable height is less than 50% of the viewport height, the scroll position is now properly distributed across the entire scrubber height, making the indicator more responsive and accurate. Changes: - Add `limitedScroll` state to detect scroll-constrained timelines (threshold: 50%) - Introduce `ViewportTopMonth` type to handle lead-in/lead-out sections - Calculate `totalViewerHeight` including top/bottom sections for accurate positioning - Refactor scrubber to treat lead-in and lead-out as distinct scroll segments - Update scroll position calculations to use relative percentages on constrained timelines
619 lines
18 KiB
TypeScript
619 lines
18 KiB
TypeScript
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 { clamp, debounce, isEqual } from 'lodash-es';
|
|
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
|
|
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
|
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
|
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
|
import {
|
|
addAssetsToMonthGroups,
|
|
runAssetOperation,
|
|
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
|
import {
|
|
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
|
findMonthGroupForDate,
|
|
getAssetWithOffset,
|
|
getMonthGroupByDate,
|
|
retrieveRange as retrieveRangeUtil,
|
|
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
|
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
|
import { DayGroup } from './day-group.svelte';
|
|
import { isMismatched, updateObject } from './internal/utils.svelte';
|
|
import { MonthGroup } from './month-group.svelte';
|
|
import type {
|
|
AssetDescriptor,
|
|
AssetOperation,
|
|
Direction,
|
|
ScrubberMonth,
|
|
TimelineAsset,
|
|
TimelineManagerLayoutOptions,
|
|
TimelineManagerOptions,
|
|
Viewport,
|
|
} from './types';
|
|
|
|
type ViewportTopMonthIntersection = {
|
|
month: MonthGroup | undefined;
|
|
// Where viewport top intersects month (0 = month top, 1 = month bottom)
|
|
viewportTopRatioInMonth: number;
|
|
// Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom)
|
|
monthBottomViewportRatio: number;
|
|
};
|
|
export class TimelineManager {
|
|
isInitialized = $state(false);
|
|
months: MonthGroup[] = $state([]);
|
|
topSectionHeight = $state(0);
|
|
bottomSectionHeight = $state(60);
|
|
assetsHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0));
|
|
totalViewerHeight = $derived(this.topSectionHeight + this.assetsHeight + this.bottomSectionHeight);
|
|
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
|
|
|
albumAssets: Set<string> = new SvelteSet();
|
|
|
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
|
scrubberTimelineHeight: number = $state(0);
|
|
|
|
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
|
|
|
visibleWindow = $derived.by(() => ({
|
|
top: this.#scrollTop,
|
|
bottom: this.#scrollTop + this.viewportHeight,
|
|
}));
|
|
limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
|
|
|
initTask = new CancellableTask(
|
|
() => {
|
|
this.isInitialized = true;
|
|
if (this.#options.albumId || this.#options.personId) {
|
|
return;
|
|
}
|
|
this.connect();
|
|
},
|
|
() => {
|
|
this.disconnect();
|
|
this.isInitialized = false;
|
|
},
|
|
() => void 0,
|
|
);
|
|
|
|
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);
|
|
#updatingIntersections = false;
|
|
#scrollableElement: HTMLElement | undefined = $state();
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
set scrollableElement(element: HTMLElement | undefined) {
|
|
this.#scrollableElement = element;
|
|
}
|
|
|
|
scrollTo(top: number) {
|
|
this.#scrollableElement?.scrollTo({ top });
|
|
this.updateSlidingWindow();
|
|
}
|
|
|
|
scrollBy(y: number) {
|
|
this.#scrollableElement?.scrollBy(0, y);
|
|
this.updateSlidingWindow();
|
|
}
|
|
|
|
#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;
|
|
this.#updateViewportGeometry(changed);
|
|
this.updateSlidingWindow();
|
|
}
|
|
|
|
get viewportWidth() {
|
|
return this.#viewportWidth;
|
|
}
|
|
|
|
set viewportHeight(value: number) {
|
|
this.#viewportHeight = value;
|
|
this.#suspendTransitions = true;
|
|
void this.#updateViewportGeometry(false);
|
|
}
|
|
|
|
get viewportHeight() {
|
|
return this.#viewportHeight;
|
|
}
|
|
|
|
async *assetsIterator(options?: {
|
|
startMonthGroup?: MonthGroup;
|
|
startDayGroup?: DayGroup;
|
|
startAsset?: TimelineAsset;
|
|
direction?: Direction;
|
|
}) {
|
|
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 });
|
|
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
|
startDayGroup = startAsset = undefined;
|
|
}
|
|
}
|
|
|
|
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
|
|
const isEarlier = options?.direction === 'earlier';
|
|
let startIndex = options?.startMonthGroup
|
|
? this.months.indexOf(options.startMonthGroup)
|
|
: isEarlier
|
|
? 0
|
|
: this.months.length - 1;
|
|
|
|
while (startIndex >= 0 && startIndex < this.months.length) {
|
|
yield this.months[startIndex];
|
|
startIndex += isEarlier ? 1 : -1;
|
|
}
|
|
}
|
|
|
|
connect() {
|
|
if (this.#websocketSupport) {
|
|
throw new Error('TimelineManager already connected');
|
|
}
|
|
this.#websocketSupport = new WebsocketSupport(this);
|
|
this.#websocketSupport.connectWebsocketEvents();
|
|
}
|
|
|
|
disconnect() {
|
|
if (!this.#websocketSupport) {
|
|
return;
|
|
}
|
|
this.#websocketSupport.disconnectWebsocketEvents();
|
|
this.#websocketSupport = undefined;
|
|
}
|
|
|
|
updateSlidingWindow() {
|
|
const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
|
|
if (this.#scrollTop !== scrollTop) {
|
|
this.#scrollTop = scrollTop;
|
|
this.updateIntersections();
|
|
}
|
|
}
|
|
|
|
#calculateMonthBottomViewportRatio(month: MonthGroup | undefined) {
|
|
if (!month) {
|
|
return 0;
|
|
}
|
|
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
|
|
const bottomOfMonth = month.top + month.height;
|
|
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
|
|
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
|
|
}
|
|
|
|
#calculateVewportTopRatioInMonth(month: MonthGroup | undefined) {
|
|
if (!month) {
|
|
return 0;
|
|
}
|
|
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
|
|
}
|
|
|
|
updateIntersections() {
|
|
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
|
return;
|
|
}
|
|
this.#updatingIntersections = true;
|
|
|
|
for (const month of this.months) {
|
|
updateIntersectionMonthGroup(this, month);
|
|
}
|
|
|
|
const month = this.months.find((month) => month.actuallyIntersecting);
|
|
const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month);
|
|
const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month);
|
|
|
|
this.viewportTopMonthIntersection = {
|
|
month,
|
|
monthBottomViewportRatio,
|
|
viewportTopRatioInMonth,
|
|
};
|
|
|
|
this.#updatingIntersections = false;
|
|
}
|
|
|
|
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) => {
|
|
const date = new SvelteDate(timeBucket.timeBucket);
|
|
return new MonthGroup(
|
|
this,
|
|
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
|
timeBucket.count,
|
|
this.#options.order,
|
|
);
|
|
});
|
|
this.albumAssets.clear();
|
|
this.#updateViewportGeometry(false);
|
|
}
|
|
|
|
async updateOptions(options: TimelineManagerOptions) {
|
|
if (options.deferInit) {
|
|
return;
|
|
}
|
|
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
|
return;
|
|
}
|
|
await this.initTask.reset();
|
|
await this.#init(options);
|
|
this.#updateViewportGeometry(false);
|
|
}
|
|
|
|
async #init(options: TimelineManagerOptions) {
|
|
this.isInitialized = false;
|
|
this.months = [];
|
|
this.albumAssets.clear();
|
|
await this.initTask.execute(async () => {
|
|
this.#options = options;
|
|
await this.#initializeMonthGroups();
|
|
}, true);
|
|
}
|
|
|
|
public destroy() {
|
|
this.disconnect();
|
|
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();
|
|
if (changedWidth) {
|
|
this.#createScrubberMonths();
|
|
}
|
|
}
|
|
|
|
#createScrubberMonths() {
|
|
this.scrubberMonths = this.months.map((month) => ({
|
|
assetCount: month.assetsCount,
|
|
year: month.yearMonth.year,
|
|
month: month.yearMonth.month,
|
|
title: month.monthGroupTitle,
|
|
height: month.height,
|
|
}));
|
|
this.scrubberTimelineHeight = this.totalViewerHeight;
|
|
}
|
|
|
|
createLayoutOptions() {
|
|
const viewportWidth = this.viewportWidth;
|
|
|
|
return {
|
|
spacing: 2,
|
|
heightTolerance: 0.15,
|
|
rowHeight: this.#rowHeight,
|
|
rowWidth: Math.floor(viewportWidth),
|
|
};
|
|
}
|
|
|
|
get maxScrollPercent() {
|
|
const totalHeight = this.totalViewerHeight;
|
|
const max = (totalHeight - this.viewportHeight) / totalHeight;
|
|
return max;
|
|
}
|
|
|
|
get maxScroll() {
|
|
return this.totalViewerHeight - this.viewportHeight;
|
|
}
|
|
|
|
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
|
let cancelable = true;
|
|
if (options) {
|
|
cancelable = options.cancelable;
|
|
}
|
|
const monthGroup = getMonthGroupByDate(this, yearMonth);
|
|
if (!monthGroup) {
|
|
return;
|
|
}
|
|
|
|
if (monthGroup.loader?.executed) {
|
|
return;
|
|
}
|
|
|
|
const executionStatus = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
|
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
|
}, cancelable);
|
|
if (executionStatus === 'LOADED') {
|
|
updateGeometry(this, monthGroup, { invalidateHeight: false });
|
|
this.updateIntersections();
|
|
}
|
|
}
|
|
|
|
addAssets(assets: TimelineAsset[]) {
|
|
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
|
const notUpdated = this.updateAssets(assetsToUpdate);
|
|
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
|
}
|
|
|
|
async findMonthGroupForAsset(id: string) {
|
|
if (!this.isInitialized) {
|
|
await this.initTask.waitUntilCompletion();
|
|
}
|
|
|
|
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
|
|
if (monthGroup) {
|
|
return monthGroup;
|
|
}
|
|
|
|
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
|
if (!response) {
|
|
return;
|
|
}
|
|
|
|
const asset = toTimelineAsset(response);
|
|
if (!asset || this.isExcluded(asset)) {
|
|
return;
|
|
}
|
|
|
|
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
|
|
if (monthGroup?.findAssetById({ id })) {
|
|
return monthGroup;
|
|
}
|
|
}
|
|
|
|
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
|
await this.loadMonthGroup(yearMonth, options);
|
|
return getMonthGroupByDate(this, yearMonth);
|
|
}
|
|
|
|
getMonthGroupByAssetId(assetId: string) {
|
|
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
|
return monthGroupInfo?.monthGroup;
|
|
}
|
|
|
|
// 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: MonthGroup | undefined = undefined;
|
|
for (const month of this.months) {
|
|
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
|
randomMonth = month;
|
|
break;
|
|
}
|
|
|
|
accumulatedCount += month.assetsCount;
|
|
}
|
|
if (!randomMonth) {
|
|
return;
|
|
}
|
|
await this.loadMonthGroup(randomMonth.yearMonth, { cancelable: false });
|
|
|
|
let randomDay: DayGroup | undefined = undefined;
|
|
for (const day of randomMonth.dayGroups) {
|
|
if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
|
|
randomDay = day;
|
|
break;
|
|
}
|
|
|
|
accumulatedCount += day.viewerAssets.length;
|
|
}
|
|
if (!randomDay) {
|
|
return;
|
|
}
|
|
|
|
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
|
|
}
|
|
|
|
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
|
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
|
}
|
|
|
|
updateAssets(assets: TimelineAsset[]) {
|
|
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
|
const { unprocessedIds } = runAssetOperation(
|
|
this,
|
|
new SvelteSet(lookup.keys()),
|
|
(asset) => {
|
|
updateObject(asset, lookup.get(asset.id));
|
|
return { remove: false };
|
|
},
|
|
{ order: this.#options.order ?? AssetOrder.Desc },
|
|
);
|
|
const result: TimelineAsset[] = [];
|
|
for (const id of unprocessedIds.values()) {
|
|
result.push(lookup.get(id)!);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
removeAssets(ids: string[]) {
|
|
const { unprocessedIds } = runAssetOperation(
|
|
this,
|
|
new SvelteSet(ids),
|
|
() => {
|
|
return { remove: true };
|
|
},
|
|
{ order: this.#options.order ?? AssetOrder.Desc },
|
|
);
|
|
return [...unprocessedIds];
|
|
}
|
|
|
|
refreshLayout() {
|
|
for (const month of this.months) {
|
|
updateGeometry(this, month, { invalidateHeight: true });
|
|
}
|
|
this.updateIntersections();
|
|
}
|
|
|
|
getFirstAsset(): TimelineAsset | undefined {
|
|
return this.months[0]?.getFirstAsset();
|
|
}
|
|
|
|
async getLaterAsset(
|
|
assetDescriptor: AssetDescriptor,
|
|
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
|
): Promise<TimelineAsset | undefined> {
|
|
return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
|
|
}
|
|
|
|
async getEarlierAsset(
|
|
assetDescriptor: AssetDescriptor,
|
|
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
|
): Promise<TimelineAsset | undefined> {
|
|
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
|
|
}
|
|
|
|
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
|
const monthGroup = findMonthGroupForDate(this, dateTime);
|
|
if (!monthGroup) {
|
|
return;
|
|
}
|
|
await this.loadMonthGroup(dateTime, { cancelable: false });
|
|
const asset = monthGroup.findClosest(dateTime);
|
|
if (asset) {
|
|
return asset;
|
|
}
|
|
for await (const asset of this.assetsIterator({ startMonthGroup: monthGroup })) {
|
|
return asset;
|
|
}
|
|
}
|
|
|
|
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
|
return retrieveRangeUtil(this, start, end);
|
|
}
|
|
|
|
isExcluded(asset: TimelineAsset) {
|
|
return (
|
|
isMismatched(this.#options.visibility, asset.visibility) ||
|
|
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
|
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
|
);
|
|
}
|
|
|
|
getAssetOrder() {
|
|
return this.#options.order ?? AssetOrder.Desc;
|
|
}
|
|
}
|