Compare commits

..

4 Commits

Author SHA1 Message Date
midzelis
12a59f8c68 Push up operations to VirtualScrollManager 2025-11-02 19:50:55 +00:00
midzelis
de84e46f62 push top level functions from timelinemanager into extensions 2025-11-02 19:44:41 +00:00
midzelis
6d3dda7e2e Relocate/rename internal timeline functions to follow TimelineExtensions pattern 2025-11-02 19:44:41 +00:00
midzelis
4ca76b24e9 refactor: extract scrollsegment
keeper
2025-11-02 16:59:57 +00:00
34 changed files with 1587 additions and 1485 deletions

View File

@@ -91,7 +91,7 @@
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
});
const toScrollFromMonthPercentage = (
const toScrollFromMonthGroupPercentage = (
scrubberMonth: ViewportTopMonth,
scrubberMonthPercent: number,
scrubOverallPercent: number,
@@ -124,7 +124,7 @@
}
};
const scrollY = $derived(
toScrollFromMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
);
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
@@ -280,12 +280,12 @@
const boundingClientRect = bestElement.boundingClientRect;
const sy = boundingClientRect.y;
const relativeY = y - sy;
const monthPercentY = relativeY / boundingClientRect.height;
const monthGroupPercentY = relativeY / boundingClientRect.height;
return {
isOnPaddingTop: false,
isOnPaddingBottom: false,
segment,
monthPercentY,
monthGroupPercentY,
};
}
@@ -308,7 +308,7 @@
isOnPaddingTop,
isOnPaddingBottom,
segment: undefined,
monthPercentY: 0,
monthGroupPercentY: 0,
};
};
@@ -327,7 +327,7 @@
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
const x = rect!.left + rect!.width / 2;
const { segment, monthPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
const { segment, monthGroupPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
activeSegment = segment;
isHoverOnPaddingTop = isOnPaddingTop;
isHoverOnPaddingBottom = isOnPaddingBottom;
@@ -335,7 +335,7 @@
const scrubData = {
scrubberMonth: segmentDate,
overallScrollPercent: toTimelineY(hoverY),
scrubberMonthScrollPercent: monthPercentY,
scrubberMonthScrollPercent: monthGroupPercentY,
};
if (wasDragging === false && isDragging) {
void startScrub?.(scrubData);

View File

@@ -2,17 +2,17 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -20,7 +20,7 @@
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { getSegmentIdentifier, getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
@@ -62,7 +62,7 @@
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
dayTitle: string,
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
@@ -109,7 +109,7 @@
let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0);
const maxMd = $derived(mobileDevice.maxMd);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
@@ -130,10 +130,8 @@
timelineManager.scrollableElement = scrollableElement;
});
const getAssetPosition = (assetId: string, month: TimelineMonth) => month.findAssetAbsolutePosition(assetId);
const scrollToAssetPosition = (assetId: string, month: TimelineMonth) => {
const position = getAssetPosition(assetId, month);
const position = month.findAssetAbsolutePosition(assetId);
if (!position) {
return;
@@ -141,7 +139,7 @@
// Need to update window positions/intersections because <Portal> may have
// gone from invisible to visible.
timelineManager.updateSlidingWindow();
timelineManager.updateVisibleWindow();
const assetTop = position.top;
const assetBottom = position.top + position.height;
@@ -176,7 +174,7 @@
};
const scrollAndLoadAsset = async (assetId: string) => {
const month = await timelineManager.findMonthForAsset(assetId);
const month = await timelineManager.search.getMonthForAsset(assetId);
if (!month) {
return false;
}
@@ -185,7 +183,7 @@
};
const scrollToAsset = (asset: TimelineAsset) => {
const month = timelineManager.getMonthByAssetId(asset.id);
const month = timelineManager.search.findMonthForAsset(asset.id)?.month;
if (!month) {
return false;
}
@@ -296,7 +294,7 @@
scrubberMonthScrollPercent,
);
} else {
const month = timelineManager.months.find(
const month = timelineManager.segments.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
);
if (!month) {
@@ -325,7 +323,7 @@
let top = scrollableElement.scrollTop;
let maxScrollPercent = timelineManager.maxScrollPercent;
const monthsLength = timelineManager.months.length;
const monthsLength = timelineManager.segments.length;
for (let i = -1; i < monthsLength + 1; i++) {
let month: ViewportTopMonth;
let monthHeight = 0;
@@ -338,8 +336,8 @@
month = 'lead-out';
monthHeight = timelineManager.bottomSectionHeight;
} else {
month = timelineManager.months[i].yearMonth;
monthHeight = timelineManager.months[i].height;
month = timelineManager.segments[i].yearMonth;
monthHeight = timelineManager.segments[i].height;
}
let next = top - monthHeight * maxScrollPercent;
@@ -352,7 +350,7 @@
// compensate for lost precision/rounding errors advance to the next bucket, if present
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
viewportTopMonth = timelineManager.segments[i + 1].yearMonth;
viewportTopMonthScrollPercent = 0;
}
break;
@@ -442,21 +440,21 @@
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthByAssetId(asset.id);
let startBucket = await timelineManager.search.getMonthForAsset(assetInteraction.assetSelectionStart.id);
let endBucket = await timelineManager.search.getMonthForAsset(asset.id);
if (startBucket === null || endBucket === null) {
if (!startBucket || !endBucket) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const month of timelineManager.months) {
for (const month of timelineManager.segments) {
if (month === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonth(month.yearMonth);
await timelineManager.loadSegment(getSegmentIdentifier(month.yearMonth));
for (const asset of month.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
@@ -472,7 +470,7 @@
// Update date group selection in range [start,end]
started = false;
for (const month of timelineManager.months) {
for (const month of timelineManager.segments) {
if (month === startBucket) {
started = true;
}
@@ -506,7 +504,7 @@
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
const assets = assetsSnapshot(await timelineManager.search.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
@@ -531,7 +529,7 @@
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonth({ year: localDateTime.year, month: localDateTime.month });
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
}
});
</script>
@@ -562,7 +560,7 @@
{onEscape}
/>
{#if timelineManager.months.length > 0}
{#if timelineManager.segments.length > 0}
<Scrubber
{timelineManager}
height={timelineManager.viewportHeight}
@@ -600,7 +598,7 @@
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={timelineManager.viewportWidth}
bind:this={scrollableElement}
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
onscroll={() => (handleTimelineScroll(), timelineManager.updateVisibleWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
@@ -622,11 +620,11 @@
{/if}
</section>
{#each timelineManager.months as month (month.viewId)}
{#each timelineManager.segments as month (month.identifier.id)}
{@const display = month.intersecting}
{@const absoluteHeight = month.top}
{#if !month.isLoaded}
{#if !month.loaded}
<div
style:height={month.height + 'px'}
style:position="absolute"

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

@@ -1,8 +1,8 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.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 { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
@@ -38,7 +38,7 @@
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
dayTitle: string,
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
@@ -49,7 +49,7 @@
singleSelect,
withStacked,
showArchiveIcon,
month = $bindable(),
month: monthGroup = $bindable(),
assetInteraction,
timelineManager,
customLayout,
@@ -61,18 +61,20 @@
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDay = $state();
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() => (month.timelineManager.suspendTransitions && !$isUploading ? 0 : 150));
const transitionDuration = $derived.by(() =>
monthGroup.scrollManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
dayTitle: string,
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, dayTitle);
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
@@ -83,19 +85,21 @@
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDay: TimelineAsset[],
dayTitle: string,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInDayCount = assetsInDay.filter((asset) => assetInteraction.hasSelectedAsset(asset.id)).length;
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInDayCount == assetsInDay.length) {
assetInteraction.addGroupToMultiselectGroup(dayTitle);
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayTitle);
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
@@ -105,9 +109,9 @@
}
};
const assetMouseEventHandler = (dayTitle: string, asset: TimelineAsset | null) => {
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group
hoveredDay = dayTitle;
hoveredDayGroup = groupTitle;
if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset);
@@ -118,7 +122,7 @@
return intersectable.filter((int) => int.intersecting);
}
const getDayFullDate = (day: TimelineDay): string => {
const getDayGroupFullDate = (day: TimelineDay): string => {
const { month, year } = day.month.yearMonth;
const date = fromTimelinePlainDate({
year,
@@ -129,14 +133,14 @@
};
</script>
{#each filterIntersecting(month.days) as day, groupIndex (day.day)}
{#each filterIntersecting(monthGroup.days) as day, groupIndex (day.day)}
{@const absoluteWidth = day.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !month.timelineManager.suspendTransitions },
!month.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
{ 'transition-all': !monthGroup.scrollManager.suspendTransitions },
!monthGroup.scrollManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
@@ -158,7 +162,7 @@
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDay === day.dayTitle && isMouseOverGroup) ||
class:w-8={(hoveredDayGroup === day.dayTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(day.dayTitle)}
onclick={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
onkeydown={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
@@ -171,7 +175,7 @@
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayFullDate(day)}>
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(day)}>
{day.dayTitle}
</span>
</div>
@@ -208,10 +212,9 @@
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)}
onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) ||
day.month.timelineManager.albumAssets.has(asset.id)}
selected={assetInteraction.hasSelectedAsset(asset.id) || day.month.scrollManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
disabled={day.month.timelineManager.albumAssets.has(asset.id)}
disabled={day.month.scrollManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>

View File

@@ -122,7 +122,7 @@
};
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0);
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;

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

@@ -1 +1,243 @@
export class ScrollSegment {}
import type { TimelineAsset, UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type {
AssetOperation,
VirtualScrollManager,
VisibleWindow,
} from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { CancellableTask, TaskStatus } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import { TUNABLES } from '$lib/utils/tunables';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export type SegmentIdentifier = {
get id(): string;
matches(segment: ScrollSegment): boolean;
};
export abstract class ScrollSegment {
#intersecting = $state(false);
#actuallyIntersecting = $state(false);
#isLoaded = $state(false);
#height = $state(0);
#top = $state(0);
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
initialCount = $state(0);
percent = $state(0);
isHeightActual = $state(false);
assetsCount = $derived.by(() => (this.loaded ? this.viewerAssets.length : this.initialCount));
loader = new CancellableTask(
() => (this.loaded = true),
() => (this.loaded = false),
() => this.handleLoadError,
);
abstract get scrollManager(): VirtualScrollManager;
abstract get identifier(): SegmentIdentifier;
abstract get viewerAssets(): ViewerAsset[];
abstract findAssetAbsolutePosition(assetId: string): { top: number; height: number } | undefined;
protected abstract fetch(signal: AbortSignal): Promise<unknown>;
get loaded() {
return this.#isLoaded;
}
protected set loaded(newValue: boolean) {
this.#isLoaded = newValue;
}
get intersecting() {
return this.#intersecting;
}
set intersecting(newValue: boolean) {
const old = this.#intersecting;
if (old === newValue) {
return;
}
this.#intersecting = newValue;
if (newValue) {
void this.load(true);
} else {
this.cancel();
}
}
get actuallyIntersecting() {
return this.#actuallyIntersecting;
}
get assets(): TimelineAsset[] {
return this.#assets;
}
get height() {
return this.#height;
}
set height(height: number) {
if (this.#height === height) {
return;
}
const scrollManager = this.scrollManager;
const index = scrollManager.segments.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const prevSegment = scrollManager.segments[index - 1];
if (prevSegment) {
const newTop = prevSegment.#top + prevSegment.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
if (heightDelta === 0) {
return;
}
for (let cursor = index + 1; cursor < scrollManager.segments.length; cursor++) {
const segment = this.scrollManager.segments[cursor];
const newTop = segment.#top + heightDelta;
if (segment.#top !== newTop) {
segment.#top = newTop;
}
}
if (!scrollManager.viewportTopSegmentIntersection) {
return;
}
const { segment, viewportTopSegmentRatio, segmentBottomViewportRatio } =
scrollManager.viewportTopSegmentIntersection;
const currentIndex = segment ? scrollManager.segments.indexOf(segment) : -1;
if (!segment || currentIndex <= 0 || index > currentIndex) {
return;
}
if (index < currentIndex || segmentBottomViewportRatio < 1) {
scrollManager.scrollBy(heightDelta);
} else if (index === currentIndex) {
const scrollTo = this.top + heightDelta * viewportTopSegmentRatio;
scrollManager.scrollTo(scrollTo);
}
}
get top(): number {
return this.#top + this.scrollManager.topSectionHeight;
}
async load(cancelable: boolean): Promise<TaskStatus> {
const result = await this.loader?.execute(async (signal: AbortSignal) => {
await this.fetch(signal);
}, cancelable);
if (result === TaskStatus.LOADED) {
this.updateGeometry({ invalidateHeight: false });
this.calculateAndUpdateIntersection(this.scrollManager.visibleWindow);
}
return result;
}
protected handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_assets'));
}
cancel() {
this.loader?.cancel();
}
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
this.intersecting = intersecting;
this.#actuallyIntersecting = actuallyIntersecting;
}
updateGeometry(options: UpdateGeometryOptions) {
const { invalidateHeight = true, noDefer = false } = options;
if (invalidateHeight) {
this.isHeightActual = false;
}
if (!this.loaded) {
const viewportWidth = this.scrollManager.viewportWidth;
if (!this.isHeightActual) {
const unwrappedWidth = (3 / 2) * this.assetsCount * this.scrollManager.rowHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = 51 + Math.max(1, rows) * this.scrollManager.rowHeight;
this.height = height;
}
return;
}
this.layout(noDefer);
}
layout(_: boolean) {}
protected calculateSegmentIntersecting(visibleWindow: VisibleWindow, expandTop: number, expandBottom: number) {
const segmentTop = this.top;
const segmentBottom = segmentTop + this.height;
const topWindow = visibleWindow.top - expandTop;
const bottomWindow = visibleWindow.bottom + expandBottom;
return isIntersecting(segmentTop, segmentBottom, topWindow, bottomWindow);
}
calculateAndUpdateIntersection(visibleWindow: VisibleWindow) {
const actuallyIntersecting = this.calculateSegmentIntersecting(visibleWindow, 0, 0);
let preIntersecting = false;
if (!actuallyIntersecting) {
preIntersecting = this.calculateSegmentIntersecting(
visibleWindow,
INTERSECTION_EXPAND_TOP,
INTERSECTION_EXPAND_BOTTOM,
);
}
this.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting });
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const unprocessedIds = new Set<string>(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const processedIds = new Set<string>();
const moveAssets: TimelineAsset[] = [];
let changedGeometry = false;
for (const assetId of unprocessedIds) {
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
if (index === -1) {
continue;
}
const asset = this.viewerAssets[index].asset!;
const opResult = operation(asset);
let remove = false;
if (opResult) {
remove = (opResult as { remove: boolean }).remove ?? false;
}
unprocessedIds.delete(assetId);
processedIds.add(assetId);
if (remove || this.scrollManager.isExcluded(asset)) {
this.viewerAssets.splice(index, 1);
changedGeometry = true;
}
}
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
}
}
/**
* General function to check if a segment region intersects with a window region.
* @param regionTop - Top position of the region to check
* @param regionBottom - Bottom position of the region to check
* @param windowTop - Top position of the window
* @param windowBottom - Bottom position of the window
* @returns true if the region intersects with the window
*/
export const isIntersecting = (regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) =>
(regionTop >= windowTop && regionTop < windowBottom) ||
(regionBottom >= windowTop && regionBottom < windowBottom) ||
(regionTop < windowTop && regionBottom >= windowBottom);

View File

@@ -1,20 +1,60 @@
import { debounce } from 'lodash-es';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { setDifferenceInPlace } from '$lib/managers/timeline-manager/utils.svelte';
import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
import { updateObject } from '$lib/managers/VirtualScrollManager/utils.svelte';
import { clamp, debounce } from 'lodash-es';
export type VisibleWindow = {
top: number;
bottom: number;
};
export type AssetOperation = (asset: TimelineAsset) => unknown;
type LayoutOptions = {
headerHeight: number;
rowHeight: number;
gap: number;
};
type ViewportTopSegmentIntersection = {
segment: ScrollSegment | null;
// Where viewport top intersects segment (0 = segment top, 1 = segment bottom)
viewportTopSegmentRatio: number;
// Where first segment bottom is in viewport (0 = viewport top, 1 = viewport bottom)
segmentBottomViewportRatio: number;
};
export abstract class VirtualScrollManager {
topSectionHeight = $state(0);
bodySectionHeight = $state(0);
bodySectionHeight = $derived.by(() => {
let height = 0;
for (const segment of this.segments) {
height += segment.height;
}
return height;
});
bottomSectionHeight = $state(0);
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
visibleWindow = $derived.by(() => ({
isInitialized = $state(false);
streamViewerHeight = $derived.by(() => {
let height = this.topSectionHeight;
for (const segment of this.segments) {
height += segment.height;
}
return height;
});
assetCount = $derived.by(() => {
let count = 0;
for (const segment of this.segments) {
count += segment.assetsCount;
}
return count;
});
visibleWindow: VisibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
viewportTopSegmentIntersection: ViewportTopSegmentIntersection | undefined;
#viewportHeight = $state(0);
#viewportWidth = $state(0);
@@ -24,14 +64,15 @@ export abstract class VirtualScrollManager {
#gap = $state(12);
#scrolling = $state(false);
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
#resumeTransitionsAfterDelay = debounce(() => (this.suspendTransitions = false), 1000);
#resumeScrollingStatusAfterDelay = debounce(() => (this.#scrolling = false), 1000);
#justifiedLayoutOptions = $derived({
spacing: 2,
heightTolerance: 0.5,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(this.viewportWidth),
});
#updatingIntersections = false;
constructor() {
this.setLayoutOptions();
@@ -45,6 +86,8 @@ export abstract class VirtualScrollManager {
return this.#justifiedLayoutOptions;
}
abstract get segments(): ScrollSegment[];
get maxScrollPercent() {
const totalHeight = this.totalViewerHeight;
return (totalHeight - this.viewportHeight) / totalHeight;
@@ -94,7 +137,7 @@ export abstract class VirtualScrollManager {
this.#scrolling = value;
if (value) {
this.suspendTransitions = true;
this.#resetScrolling();
this.#resumeScrollingStatusAfterDelay();
}
}
@@ -105,7 +148,7 @@ export abstract class VirtualScrollManager {
set suspendTransitions(value: boolean) {
this.#suspendTransitions = value;
if (value) {
this.#resetSuspendTransitions();
this.#resumeTransitionsAfterDelay();
}
}
@@ -114,10 +157,11 @@ export abstract class VirtualScrollManager {
}
set viewportWidth(value: number) {
const changed = value !== this.#viewportWidth;
const oldViewport = this.viewportSnapshot;
this.#viewportWidth = value;
this.suspendTransitions = true;
void this.updateViewportGeometry(changed);
const newViewport = this.viewportSnapshot;
void this.onUpdateViewport(oldViewport, newViewport);
}
get viewportWidth() {
@@ -125,22 +169,78 @@ export abstract class VirtualScrollManager {
}
set viewportHeight(value: number) {
const oldViewport = this.viewportSnapshot;
this.#viewportHeight = value;
this.#suspendTransitions = true;
void this.updateViewportGeometry(false);
const newViewport = this.viewportSnapshot;
void this.onUpdateViewport(oldViewport, newViewport);
}
get viewportHeight() {
return this.#viewportHeight;
}
get hasEmptyViewport() {
return this.viewportWidth === 0 || this.viewportHeight === 0;
get viewportSnapshot(): Viewport {
return {
width: $state.snapshot(this.#viewportWidth),
height: $state.snapshot(this.#viewportHeight),
};
}
protected updateIntersections(): void {}
scrollTo(_: number) {}
protected updateViewportGeometry(_: boolean) {}
scrollBy(_: number) {}
#calculateSegmentBottomViewportRatio(segment: ScrollSegment | null) {
if (!segment) {
return 0;
}
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
const bottomOfSegment = segment.top + segment.height;
const bottomOfSegmentInViewport = bottomOfSegment - this.visibleWindow.top;
return clamp(bottomOfSegmentInViewport / windowHeight, 0, 1);
}
#calculateViewportTopRatioInMonth(month: ScrollSegment | null) {
if (!month) {
return 0;
}
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
}
protected updateIntersections() {
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
this.#updatingIntersections = true;
let topSegment: ScrollSegment | null = null;
for (const segment of this.segments) {
segment.calculateAndUpdateIntersection(this.visibleWindow);
if (segment.actuallyIntersecting && topSegment === null) {
topSegment = segment;
}
}
const viewportTopSegmentRatio = this.#calculateViewportTopRatioInMonth(topSegment);
const segmentBottomViewportRatio = this.#calculateSegmentBottomViewportRatio(topSegment);
this.viewportTopSegmentIntersection = {
segment: topSegment,
viewportTopSegmentRatio,
segmentBottomViewportRatio,
};
this.#updatingIntersections = false;
}
protected onUpdateViewport(oldViewport: Viewport, newViewport: Viewport) {
if (!this.isInitialized || isEmptyViewport(newViewport)) {
return;
}
const changedWidth = oldViewport.width !== newViewport.width || isEmptyViewport(oldViewport);
this.refreshLayout({ invalidateHeight: changedWidth });
}
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: Partial<LayoutOptions> = {}) {
let changed = false;
@@ -152,7 +252,7 @@ export abstract class VirtualScrollManager {
}
}
updateSlidingWindow() {
updateVisibleWindow() {
const scrollTop = this.scrollTop;
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
@@ -160,9 +260,132 @@ export abstract class VirtualScrollManager {
}
}
refreshLayout() {
protected refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) {
for (const segment of this.segments) {
segment.updateGeometry({ invalidateHeight });
}
this.updateIntersections();
}
destroy(): void {}
destroy() {
this.isInitialized = false;
}
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
const { cancelable = true } = options ?? {};
const segment = this.segments.find((segment) => identifier.matches(segment));
if (!segment || segment.loader?.executed) {
return;
}
await segment.load(cancelable);
}
upsertAssets(assets: TimelineAsset[]) {
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.#updateAssets(notExcluded);
this.addAssetsToSegments(notUpdated);
}
removeAssets(ids: string[]) {
this.#runAssetOperation(ids, () => ({ remove: true }));
}
/**
* Executes the given operation against every passed in asset id.
*
* @returns An object with the changed ids, unprocessed ids, and if this resulted
* in changes of the timeline geometry.
*/
updateAssetOperation(ids: string[], operation: AssetOperation) {
return this.#runAssetOperation(ids, operation);
}
isExcluded(_: TimelineAsset) {
return false;
}
protected addAssetsToSegments(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const context = this.createUpsertContext();
const monthCount = this.segments.length;
for (const asset of assets) {
this.upsertAssetIntoSegment(asset, context);
}
if (this.segments.length !== monthCount) {
this.postCreateSegments();
}
this.postUpsert(context);
this.updateIntersections();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void {}
protected createUpsertContext(): unknown {
return undefined;
}
protected postUpsert(_: unknown): void {}
protected postCreateSegments(): void {}
/**
* Looks up the specified asset from the TimelineAsset using its id, and then updates the
* existing object to match the rest of the TimelineAsset parameter.
* @returns list of assets that were updated (not found)
*/
#updateAssets(updatedAssets: TimelineAsset[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const lookup = new Map<string, TimelineAsset>();
const ids = [];
for (const asset of updatedAssets) {
ids.push(asset.id);
lookup.set(asset.id, asset);
}
const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id)));
const result: TimelineAsset[] = [];
for (const id of unprocessedIds) {
result.push(lookup.get(id)!);
}
return result;
}
#runAssetOperation(ids: string[], operation: AssetOperation) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonths = new Set<ScrollSegment>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsProcessed = new Set<string>();
const combinedMoveAssets: TimelineAsset[] = [];
for (const month of this.segments) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(...moveAssets);
}
setDifferenceInPlace(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonths.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
this.addAssetsToSegments(combinedMoveAssets);
}
const changedGeometry = changedMonths.size > 0;
for (const month of changedMonths) {
month.updateGeometry({ invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}
}
export const isEmptyViewport = (viewport: Viewport) => viewport.width === 0 || viewport.height === 0;

View File

@@ -0,0 +1,52 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function updateObject(target: any, source: any): boolean {
if (!target) {
return false;
}
let updated = false;
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
if (key === '__proto__' || key === 'constructor') {
continue;
}
const isDate = target[key] instanceof Date;
if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]);
} else {
if (target[key] !== source[key]) {
target[key] = source[key];
updated = true;
}
}
}
return updated;
}
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);
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
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;
}

View File

@@ -1,7 +1,7 @@
import { onCreateDay } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { onCreateTimelineDay } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
import type { AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
@@ -11,6 +11,7 @@ export class TimelineDay {
readonly month: TimelineMonth;
readonly index: number;
readonly dayTitle: string;
readonly dayTitleFull: string;
readonly day: number;
viewerAssets: ViewerAsset[] = $state([]);
@@ -24,13 +25,14 @@ export class TimelineDay {
#col = $state(0);
#deferredLayout = false;
constructor(month: TimelineMonth, index: number, day: number, dayTitle: string) {
constructor(month: TimelineMonth, index: number, day: number, groupTitle: string, groupTitleFull: string) {
this.index = index;
this.month = month;
this.day = day;
this.dayTitle = dayTitle;
this.dayTitle = groupTitle;
this.dayTitleFull = groupTitleFull;
if (import.meta.env.DEV) {
onCreateDay(this);
onCreateTimelineDay(this);
}
}
@@ -103,15 +105,6 @@ export class TimelineDay {
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as TimelineAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const unprocessedIds = new Set<string>(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
@@ -142,7 +135,7 @@ export class TimelineDay {
}
unprocessedIds.delete(assetId);
processedIds.add(assetId);
if (remove || this.month.timelineManager.isExcluded(asset)) {
if (remove || this.month.scrollManager.isExcluded(asset)) {
this.viewerAssets.splice(index, 1);
changedGeometry = true;
}
@@ -165,7 +158,7 @@ export class TimelineDay {
}
}
get absoluteTop() {
get topAbsolute() {
return this.month.top + this.#top;
}
}

View File

@@ -1,7 +1,8 @@
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { setDifference } from '$lib/managers/timeline-manager/utils.svelte';
import { type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
export class GroupInsertionCache {
@@ -18,14 +19,14 @@ export class GroupInsertionCache {
return this.#lookupCache[year]?.[month]?.[day];
}
setDay(day: TimelineDay, { year, month, day: dayNumber }: TimelineDate) {
setDay(day: TimelineDay, { year, month, day: dayNum }: TimelineDate) {
if (!this.#lookupCache[year]) {
this.#lookupCache[year] = {};
}
if (!this.#lookupCache[year][month]) {
this.#lookupCache[year][month] = {};
}
this.#lookupCache[year][month][dayNumber] = day;
this.#lookupCache[year][month][dayNum] = day;
}
get existingDays() {

View File

@@ -1,13 +1,12 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { setTestHooks } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { setTestHooks } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { tick } from 'svelte';
import type { MockInstance } from 'vitest';
@@ -20,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,
@@ -78,7 +81,7 @@ describe('TimelineManager', () => {
});
it('calculates month height', () => {
const plainMonths = timelineManager.months.map((month) => ({
const plainMonths = timelineManager.segments.map((month) => ({
year: month.yearMonth.year,
month: month.yearMonth.month,
height: month.height,
@@ -98,7 +101,7 @@ describe('TimelineManager', () => {
});
});
describe('loadMonth', () => {
describe('loadSegment', () => {
let timelineManager: TimelineManager;
const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
@@ -134,48 +137,48 @@ describe('TimelineManager', () => {
});
it('loads a month', async () => {
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadMonth({ year: 2023, month: 1 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels month loading', async () => {
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
void timelineManager.loadMonth({ year: 2024, month: 1 });
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!;
void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3);
});
it('prevents loading months multiple times', async () => {
await Promise.all([
timelineManager.loadMonth({ year: 2024, month: 1 }),
timelineManager.loadMonth({ year: 2024, month: 1 }),
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
await timelineManager.loadMonth({ year: 2024, month: 1 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
});
it('allows loading a canceled month', async () => {
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
const loadPromise = timelineManager.loadMonth({ year: 2024, month: 1 });
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!;
const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
month.cancel();
await loadPromise;
expect(month?.getAssets().length).toEqual(0);
expect(month?.assets.length).toEqual(0);
await timelineManager.loadMonth({ year: 2024, month: 1 });
expect(month!.getAssets().length).toEqual(3);
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
expect(month!.assets.length).toEqual(3);
});
});
@@ -190,7 +193,7 @@ describe('TimelineManager', () => {
});
it('is empty initially', () => {
expect(timelineManager.months.length).toEqual(0);
expect(timelineManager.segments.length).toEqual(0);
expect(timelineManager.assetCount).toEqual(0);
});
@@ -202,12 +205,12 @@ describe('TimelineManager', () => {
);
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.segments.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
expect(timelineManager.segments[0].assets.length).toEqual(1);
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
expect(timelineManager.segments[0].getFirstAsset().id).toEqual(asset.id);
});
it('adds assets to existing month', () => {
@@ -219,11 +222,11 @@ describe('TimelineManager', () => {
timelineManager.upsertAssets([assetOne]);
timelineManager.upsertAssets([assetTwo]);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.segments.length).toEqual(1);
expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months[0].getAssets().length).toEqual(2);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
expect(timelineManager.segments[0].assets.length).toEqual(2);
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
});
it('orders assets in months by descending date', () => {
@@ -244,12 +247,12 @@ describe('TimelineManager', () => {
);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 });
expect(month).not.toBeNull();
expect(month?.getAssets().length).toEqual(3);
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
expect(month?.assets.length).toEqual(3);
expect(month?.assets[0].id).toEqual(assetOne.id);
expect(month?.assets[1].id).toEqual(assetThree.id);
expect(month?.assets[2].id).toEqual(assetTwo.id);
});
it('orders months by descending date', () => {
@@ -270,15 +273,15 @@ describe('TimelineManager', () => {
);
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
expect(timelineManager.months.length).toEqual(3);
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
expect(timelineManager.segments.length).toEqual(3);
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
expect(timelineManager.segments[0].yearMonth.month).toEqual(4);
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
expect(timelineManager.segments[1].yearMonth.year).toEqual(2024);
expect(timelineManager.segments[1].yearMonth.month).toEqual(1);
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
expect(timelineManager.segments[2].yearMonth.year).toEqual(2023);
expect(timelineManager.segments[2].yearMonth.month).toEqual(1);
});
it('updates existing asset', () => {
@@ -322,19 +325,19 @@ describe('TimelineManager', () => {
sortDaysFn: MockInstance;
};
const days = new Map<TimelineDay, DayMocks>();
const dayGroups = new Map<TimelineDay, DayMocks>();
const months = new Map<TimelineMonth, MonthMocks>();
beforeEach(async () => {
timelineManager = new TimelineManager();
setTestHooks({
onCreateDay: (day: TimelineDay) => {
days.set(day, {
onCreateTimelineDay: (day: TimelineDay) => {
dayGroups.set(day, {
layoutFn: vi.spyOn(day, 'layout'),
sortAssetsFn: vi.spyOn(day, 'sortAssets'),
});
},
onCreateMonth: (month: TimelineMonth) => {
onCreateTimelineMonth: (month: TimelineMonth) => {
months.set(month, {
sortDaysFn: vi.spyOn(month, 'sortDays'),
});
@@ -394,7 +397,7 @@ describe('TimelineManager', () => {
timelineManager.updateAssetOperation([month1day2asset1.id], (asset) => {
asset.localDateTime.day = asset.localDateTime.day + 1;
});
for (const [day, mocks] of days) {
for (const [day, mocks] of dayGroups) {
if (day.day === 15 && day.month.yearMonth.month === 1) {
// source - should be layout once
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
@@ -435,11 +438,11 @@ describe('TimelineManager', () => {
timelineManager.upsertAssets([asset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(false);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(true);
});
it('asset moves months when asset date changes', () => {
@@ -454,79 +457,16 @@ describe('TimelineManager', () => {
});
timelineManager.upsertAssets([asset]);
expect(timelineManager.months.length).toEqual(1);
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
expect(timelineManager.segments.length).toEqual(1);
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined();
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(1);
timelineManager.upsertAssets([updatedAsset]);
expect(timelineManager.months.length).toEqual(2);
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
await timelineManager.updateOptions({
visibility: AssetVisibility.Archive,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
visibility: AssetVisibility.Archive,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, visibility: AssetVisibility.Timeline });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, visibility: AssetVisibility.Archive }]);
expect(timelineManager.assetCount).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if isFavorite changes', async () => {
await timelineManager.updateOptions({
isFavorite: true,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
isFavorite: true,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, isFavorite: false });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, isFavorite: true }]);
expect(timelineManager.assetCount).toEqual(1);
});
it('asset is removed during upsert when TimelineManager if isTrashed changes', async () => {
await timelineManager.updateOptions({
isTrashed: true,
});
const fixture = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
isTrashed: true,
}),
);
timelineManager.upsertAssets([fixture]);
expect(timelineManager.assetCount).toEqual(1);
const updated = Object.freeze({ ...fixture, isTrashed: false });
timelineManager.upsertAssets([updated]);
expect(timelineManager.assetCount).toEqual(0);
timelineManager.upsertAssets([{ ...fixture, isTrashed: true }]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.segments.length).toEqual(2);
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined();
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0);
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).not.toBeUndefined();
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })?.assets.length).toEqual(1);
});
});
@@ -551,8 +491,8 @@ describe('TimelineManager', () => {
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
expect(timelineManager.assetCount).toEqual(2);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(2);
expect(timelineManager.segments.length).toEqual(1);
expect(timelineManager.segments[0].assets.length).toEqual(2);
});
it('removes asset from month', () => {
@@ -565,8 +505,8 @@ describe('TimelineManager', () => {
timelineManager.removeAssets([assetOne.id]);
expect(timelineManager.assetCount).toEqual(1);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.months[0].getAssets().length).toEqual(1);
expect(timelineManager.segments.length).toEqual(1);
expect(timelineManager.segments[0].assets.length).toEqual(1);
});
it('does not remove month when empty', () => {
@@ -579,7 +519,7 @@ describe('TimelineManager', () => {
timelineManager.removeAssets(assets.map((asset) => asset.id));
expect(timelineManager.assetCount).toEqual(0);
expect(timelineManager.months.length).toEqual(1);
expect(timelineManager.segments.length).toEqual(1);
});
});
@@ -650,63 +590,63 @@ 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 () => {
await timelineManager.loadMonth({ year: 2024, month: 1 });
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 });
const a = month!.getAssets()[0];
const b = month!.getAssets()[1];
const previous = await timelineManager.getLaterAsset(b);
const a = month!.assets[0];
const b = month!.assets[1];
const previous = await timelineManager.search.getLaterAsset(b);
expect(previous).toEqual(a);
});
it('returns previous assetId spanning multiple months', async () => {
await timelineManager.loadMonth({ year: 2024, month: 2 });
await timelineManager.loadMonth({ year: 2024, month: 3 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
const a = month!.getAssets()[0];
const b = previousMonth!.getAssets()[0];
const previous = await timelineManager.getLaterAsset(a);
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 });
const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 });
const a = month!.assets[0];
const b = previousMonth!.assets[0];
const previous = await timelineManager.search.getLaterAsset(a);
expect(previous).toEqual(b);
});
it('loads previous month', async () => {
await timelineManager.loadMonth({ year: 2024, month: 2 });
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 });
const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 });
const a = month!.getFirstAsset();
const b = previousMonth!.getFirstAsset();
const loadMonthSpy = vi.spyOn(month!.loader!, 'execute');
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(loadmonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
await timelineManager.loadMonth({ year: 2024, month: 1 });
await timelineManager.loadMonth({ year: 2024, month: 2 });
await timelineManager.loadMonth({ year: 2024, month: 3 });
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
timelineManager.removeAssets([assetTwo.id]);
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.loadMonth({ year: 2024, month: 3 });
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
expect(await timelineManager.search.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined();
});
});
describe('getMonthIndexByAssetId', () => {
describe('getmonthIndexByAssetId', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
@@ -717,8 +657,8 @@ describe('TimelineManager', () => {
});
it('returns null for invalid months', () => {
expect(getMonthByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
expect(timelineManager.search.findMonthByDate({ year: -1, month: -1 })).toBeUndefined();
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).toBeUndefined();
});
it('returns the month index', () => {
@@ -734,10 +674,10 @@ describe('TimelineManager', () => {
);
timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.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', () => {
@@ -754,8 +694,8 @@ describe('TimelineManager', () => {
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.year).toEqual(2024);
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.month).toEqual(1);
});
});
@@ -804,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

@@ -1,99 +1,60 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonth } 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 {
findClosestGroupForDate,
findMonthForAsset as findMonthForAssetUtil,
findMonthForDate,
getAssetWithOffset,
getMonthByDate,
retrieveRange as retrieveRangeUtil,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { isMismatched, updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte';
import { TimelineWebsocketExtension } from '$lib/managers/timeline-manager/TimelineWebsocketExtension';
import type {
AssetDescriptor,
AssetOperation,
Direction,
ScrubberMonth,
TimelineAsset,
TimelineManagerOptions,
Viewport,
} from '$lib/managers/timeline-manager/types';
import { isMismatched } from '$lib/managers/timeline-manager/utils.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import {
setDifferenceInPlace,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { getSegmentIdentifier } from '$lib/utils/timeline-util';
import { AssetOrder, getTimeBuckets } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
type ViewportTopMonthIntersection = {
month: TimelineMonth | 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 extends VirtualScrollManager {
override bottomSectionHeight = $state(60);
override bodySectionHeight = $derived.by(() => {
let height = 0;
for (const month of this.months) {
height += month.height;
}
return height;
});
assetCount = $derived.by(() => {
let count = 0;
for (const month of this.months) {
count += month.assetsCount;
}
return count;
});
isInitialized = $state(false);
months: TimelineMonth[] = $state([]);
albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0);
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
limitedScroll = $derived(this.maxScrollPercent < 0.5);
initTask = new CancellableTask(
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(
() => {
this.isInitialized = true;
if (this.#options.albumId || this.#options.personId) {
return;
}
this.connect();
this.websocket.connect();
},
() => {
this.disconnect();
this.websocket.disconnect();
this.isInitialized = false;
},
() => void 0,
);
static #INIT_OPTIONS = {};
#websocketSupport: WebsocketSupport | undefined;
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#updatingIntersections = false;
segments: TimelineMonth[] = $state([]);
scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0);
#options: TimelineManagerOptions = {};
#scrollableElement: HTMLElement | undefined = $state();
constructor() {
super();
}
get options() {
return this.#options;
}
override get scrollTop(): number {
return this.#scrollableElement?.scrollTop ?? 0;
}
@@ -102,158 +63,38 @@ export class TimelineManager extends VirtualScrollManager {
this.#scrollableElement = element;
}
scrollTo(top: number) {
override scrollTo(top: number) {
this.#scrollableElement?.scrollTo({ top });
this.updateSlidingWindow();
this.updateVisibleWindow();
}
scrollBy(y: number) {
override scrollBy(y: number) {
this.#scrollableElement?.scrollBy(0, y);
this.updateSlidingWindow();
this.updateVisibleWindow();
}
async *assetsIterator(options?: {
startMonth?: TimelineMonth;
startDay?: TimelineDay;
startAsset?: TimelineAsset;
direction?: Direction;
}) {
const direction = options?.direction ?? 'earlier';
let { startDay, startAsset } = options ?? {};
for (const month of this.monthIterator({ direction, startMonth: options?.startMonth })) {
await this.loadMonth(month.yearMonth, { cancelable: false });
yield* month.assetsIterator({ startDay, startAsset, direction });
startDay = startAsset = undefined;
protected override refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) {
super.refreshLayout({ invalidateHeight });
if (invalidateHeight) {
this.#createScrubberMonths();
}
}
*monthIterator(options?: { direction?: Direction; startMonth?: TimelineMonth }) {
const isEarlier = options?.direction === 'earlier';
let startIndex = options?.startMonth
? this.months.indexOf(options.startMonth)
: 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;
}
#calculateMonthBottomViewportRatio(month: TimelineMonth | 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: TimelineMonth | undefined) {
if (!month) {
return 0;
}
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
}
override updateIntersections() {
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
this.#updatingIntersections = true;
for (const month of this.months) {
updateIntersectionMonth(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: TimelineMonth) {
const hasDeferred = month.days.some((group) => group.deferredLayout);
if (hasDeferred) {
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
for (const group of month.days) {
group.deferredLayout = false;
}
}
}
async #initializeMonths() {
const timebuckets = await getTimeBuckets({
...authManager.params,
...this.#options,
});
this.months = timebuckets.map((timeBucket) => {
const date = new SvelteDate(timeBucket.timeBucket);
return new TimelineMonth(
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count,
false,
this.#options.order,
);
});
this.albumAssets.clear();
this.updateViewportGeometry(false);
public override destroy() {
this.websocket.disconnect();
super.destroy();
}
async updateOptions(options: TimelineManagerOptions) {
if (options.deferInit) {
return;
}
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
if (isEqual(this.#options, options)) {
return;
}
await this.initTask.reset();
await this.#init(options);
this.updateViewportGeometry(false);
this.#createScrubberMonths();
}
async #init(options: TimelineManagerOptions) {
this.isInitialized = false;
this.months = [];
this.albumAssets.clear();
await this.initTask.execute(async () => {
this.#options = options;
await this.#initializeMonths();
}, true);
}
public override destroy() {
this.disconnect();
this.isInitialized = false;
super.destroy();
this.refreshLayout();
}
async updateViewport(viewport: Viewport) {
@@ -269,296 +110,55 @@ export class TimelineManager extends VirtualScrollManager {
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
}
const changedWidth = viewport.width !== this.viewportWidth;
const oldViewport: Viewport = {
width: this.viewportWidth,
height: this.viewportHeight,
};
this.viewportHeight = viewport.height;
this.viewportWidth = viewport.width;
this.updateViewportGeometry(changedWidth);
this.onUpdateViewport(oldViewport, viewport);
}
protected override updateViewportGeometry(changedWidth: boolean) {
if (!this.isInitialized || this.hasEmptyViewport) {
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.monthTitle,
height: month.height,
}));
this.scrubberTimelineHeight = this.totalViewerHeight;
}
async loadMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true;
if (options) {
cancelable = options.cancelable;
}
const month = getMonthByDate(this, yearMonth);
if (!month) {
return;
}
if (month.loader?.executed) {
return;
}
const executionStatus = await month.loader?.execute(async (signal: AbortSignal) => {
await loadFromTimeBuckets(this, month, this.#options, signal);
}, cancelable);
if (executionStatus === 'LOADED') {
updateGeometry(this, month, { invalidateHeight: false });
this.updateIntersections();
}
}
upsertAssets(assets: TimelineAsset[]) {
const notUpdated = this.#updateAssets(assets);
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
this.addAssetsToSegments(notExcluded);
}
async findMonthForAsset(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
let { month } = findMonthForAssetUtil(this, 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.loadMonth(yearMonth, options);
return getMonthByDate(this, yearMonth);
}
getMonthByAssetId(assetId: string) {
const monthInfo = findMonthForAssetUtil(this, 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.months) {
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
randomMonth = month;
break;
}
accumulatedCount += month.assetsCount;
}
if (!randomMonth) {
return;
}
await this.loadMonth(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.
*
* @returns An object with the changed ids, unprocessed ids, and if this resulted
* in changes of the timeline geometry.
*/
updateAssetOperation(ids: string[], operation: AssetOperation) {
return this.#runAssetOperation(ids, operation);
}
/**
* Looks up the specified asset from the TimelineAsset using its id, and then updates the
* existing object to match the rest of the TimelineAsset parameter.
* @returns list of assets that were updated (not found)
*/
#updateAssets(updatedAssets: TimelineAsset[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const lookup = new Map<string, TimelineAsset>();
const ids = [];
for (const asset of updatedAssets) {
ids.push(asset.id);
lookup.set(asset.id, asset);
}
const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id)));
const result: TimelineAsset[] = [];
for (const id of unprocessedIds) {
result.push(lookup.get(id)!);
}
return result;
}
removeAssets(ids: string[]) {
this.#runAssetOperation(ids, () => ({ remove: true }));
}
protected createUpsertContext(): GroupInsertionCache {
protected override createUpsertContext(): GroupInsertionCache {
return new GroupInsertionCache();
}
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
let month = getMonthByDate(this, asset.localDateTime);
protected override upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
let month = this.search.findMonthByDate(asset.localDateTime);
if (!month) {
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
this.months.push(month);
this.segments.push(month);
}
month.addTimelineAsset(asset, context);
}
protected addAssetsToSegments(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const context = this.createUpsertContext();
const monthCount = this.months.length;
for (const asset of assets) {
this.upsertAssetIntoSegment(asset, context);
}
if (this.months.length !== monthCount) {
this.postCreateSegments();
}
this.postUpsert(context);
this.updateIntersections();
protected override postCreateSegments(): void {
this.segments.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
#runAssetOperation(ids: string[], operation: AssetOperation) {
if (ids.length === 0) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return { processedIds: new Set<string>(), unprocessedIds: new Set<string>(), changedGeometry: false };
protected override postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDays) {
group.sortAssets(this.#options.order);
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonths = new Set<TimelineMonth>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToProcess = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsProcessed = new Set<string>();
const combinedMoveAssets: TimelineAsset[] = [];
for (const month of this.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(...moveAssets);
}
setDifferenceInPlace(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonths.add(month);
}
}
for (const month of context.monthsWithNewDays) {
month.sortDays();
}
if (combinedMoveAssets.length > 0) {
this.addAssetsToSegments(combinedMoveAssets);
}
const changedGeometry = changedMonths.size > 0;
for (const month of changedMonths) {
updateGeometry(this, month, { invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}
override 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) {
let month = findMonthForDate(this, dateTime);
if (!month) {
// if exact match not found, find closest
month = findClosestGroupForDate(this.months, dateTime);
if (!month) {
return;
}
}
await this.loadMonth(dateTime, { cancelable: false });
const asset = month.findClosest(dateTime);
if (asset) {
return asset;
}
for await (const asset of this.assetsIterator({ startMonth: month })) {
return asset;
for (const month of context.updatedMonths) {
month.sortDays();
month.updateGeometry({ invalidateHeight: true });
}
}
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
return retrieveRangeUtil(this, start, end);
}
isExcluded(asset: TimelineAsset) {
override isExcluded(asset: TimelineAsset) {
return (
isMismatched(this.#options.visibility, asset.visibility) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
@@ -570,26 +170,75 @@ export class TimelineManager extends VirtualScrollManager {
return this.#options.order ?? AssetOrder.Desc;
}
protected postCreateSegments(): void {
this.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
getFirstAsset(): TimelineAsset | undefined {
return this.segments[0]?.getFirstAsset();
}
protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDays) {
group.sortAssets(this.#options.order);
async *assetsIterator(options?: {
startMonth?: TimelineMonth;
startDay?: TimelineDay;
startAsset?: TimelineAsset;
direction?: Direction;
}) {
const direction = options?.direction ?? 'earlier';
let { startDay, startAsset } = options ?? {};
for (const month of this.monthIterator({ direction, startMonth: options?.startMonth })) {
await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false });
yield* month.assetsIterator({ startDay, startAsset, direction });
startDay = startAsset = undefined;
}
}
for (const month of context.monthsWithNewDays) {
month.sortDays();
}
*monthIterator(options?: { direction?: Direction; startMonth?: TimelineMonth }) {
const isEarlier = options?.direction === 'earlier';
let startIndex = options?.startMonth
? this.segments.indexOf(options.startMonth)
: isEarlier
? 0
: this.segments.length - 1;
for (const month of context.updatedMonths) {
month.sortDays();
updateGeometry(this, month, { invalidateHeight: true });
while (startIndex >= 0 && startIndex < this.segments.length) {
yield this.segments[startIndex];
startIndex += isEarlier ? 1 : -1;
}
}
async #init(options: TimelineManagerOptions) {
this.isInitialized = false;
this.segments = [];
this.albumAssets.clear();
await this.initTask.execute(async () => {
this.#options = options;
const timebuckets = await getTimeBuckets({
...authManager.params,
...this.#options,
});
for (const timeBucket of timebuckets) {
const date = new SvelteDate(timeBucket.timeBucket);
this.segments.push(
new TimelineMonth(
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count,
false,
this.#options.order,
),
);
}
this.albumAssets.clear();
}, true);
this.refreshLayout();
}
#createScrubberMonths() {
this.scrubberMonths = this.segments.map((month) => ({
assetCount: month.assetsCount,
year: month.yearMonth.year,
month: month.yearMonth.month,
title: month.monthTitle,
height: month.height,
}));
this.scrubberTimelineHeight = this.totalViewerHeight;
}
}

View File

@@ -1,49 +1,37 @@
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { onCreateMonth } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { onCreateTimelineMonth } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import { ScrollSegment } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error';
import { ScrollSegment, type SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
import { setDifferenceInPlace } from '$lib/managers/VirtualScrollManager/utils.svelte';
import type { AssetOperation } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import {
formatDayTitle,
formatMonthTitle,
formatGroupTitle,
formatGroupTitleFull,
formatMonthGroupTitle,
fromTimelinePlainDate,
fromTimelinePlainDateTime,
fromTimelinePlainYearMonth,
getSegmentIdentifier,
getTimes,
setDifferenceInPlace,
toISOYearMonthUTC,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { AssetOrder, getTimeBucket, type TimeBucketAssetResponseDto } from '@immich/sdk';
export class TimelineMonth extends ScrollSegment {
#intersecting: boolean = $state(false);
actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false);
days: TimelineDay[] = $state([]);
readonly timelineManager: TimelineManager;
#height: number = $state(0);
#top: number = $state(0);
#initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0);
assetsCount: number = $derived(
this.isLoaded ? this.days.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0) : this.#initialCount,
);
loader: CancellableTask | undefined;
isHeightActual: boolean = $state(false);
#yearMonth: TimelineYearMonth;
#identifier: SegmentIdentifier;
#timelineManager: TimelineManager;
readonly monthTitle: string;
readonly yearMonth: TimelineYearMonth;
constructor(
timelineManager: TimelineManager,
@@ -53,46 +41,162 @@ export class TimelineMonth extends ScrollSegment {
order: AssetOrder = AssetOrder.Desc,
) {
super();
this.timelineManager = timelineManager;
this.#initialCount = initialCount;
this.initialCount = initialCount;
this.#yearMonth = yearMonth;
this.#identifier = getSegmentIdentifier(yearMonth);
this.#timelineManager = timelineManager;
this.#sortOrder = order;
this.yearMonth = yearMonth;
this.monthTitle = formatMonthTitle(fromTimelinePlainYearMonth(yearMonth));
this.loader = new CancellableTask(
() => {
this.isLoaded = true;
},
() => {
this.days = [];
this.isLoaded = false;
},
this.#handleLoadError,
);
if (loaded) {
this.isLoaded = true;
}
this.monthTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
this.loaded = loaded;
if (import.meta.env.DEV) {
onCreateMonth(this);
onCreateTimelineMonth(this);
}
}
set intersecting(newValue: boolean) {
const old = this.#intersecting;
if (old === newValue) {
get identifier() {
return this.#identifier;
}
get scrollManager(): TimelineManager {
return this.#timelineManager;
}
get viewerAssets() {
const assets: ViewerAsset[] = [];
for (const day of this.days) {
assets.push(...day.viewerAssets);
}
return assets;
}
override findAssetAbsolutePosition(assetId: string) {
this.#clearDeferredLayout();
for (const day of this.days) {
const viewerAsset = day.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
if (!viewerAsset.position) {
console.warn('No position for asset');
return;
}
return {
top: this.top + day.top + viewerAsset.position.top + this.#timelineManager.headerHeight,
height: viewerAsset.position.height,
};
}
}
}
protected async fetch(signal: AbortSignal): Promise<unknown> {
if (this.getFirstAsset()) {
return;
}
this.#intersecting = newValue;
if (newValue) {
void this.timelineManager.loadMonth(this.yearMonth);
} else {
this.cancel();
const timelineManager = this.#timelineManager;
const options = timelineManager.options;
const timeBucket = toISOYearMonthUTC(this.yearMonth);
const bucketResponse = await getTimeBucket(
{
...authManager.params,
...options,
timeBucket,
},
{ signal },
);
if (!bucketResponse) {
return;
}
if (options.timelineAlbumId) {
const albumAssets = await getTimeBucket(
{
...authManager.params,
albumId: options.timelineAlbumId,
timeBucket,
},
{ signal },
);
for (const id of albumAssets.id) {
timelineManager.albumAssets.add(id);
}
}
const unprocessedAssets = this.addAssets(bucketResponse, true);
if (unprocessedAssets.length > 0) {
console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${this.yearMonth.month}, ${JSON.stringify(
unprocessedAssets.map((unprocessed) => ({
id: unprocessed.id,
localDateTime: unprocessed.localDateTime,
})),
)}`,
);
}
}
get intersecting() {
return this.#intersecting;
override layout(noDefer: boolean) {
let cumulativeHeight = 0;
let cumulativeWidth = 0;
let currentRowHeight = 0;
let dayGroupRow = 0;
let dayGroupCol = 0;
const options = this.scrollManager.justifiedLayoutOptions;
for (const dayGroup of this.days) {
dayGroup.layout(options, noDefer);
// Calculate space needed for this item (including gap if not first in row)
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? this.scrollManager.gap : 0);
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= this.scrollManager.viewportWidth;
if (fitsInCurrentRow) {
dayGroup.row = dayGroupRow;
dayGroup.col = dayGroupCol++;
dayGroup.left = cumulativeWidth;
dayGroup.top = cumulativeHeight;
cumulativeWidth += dayGroup.width + this.scrollManager.gap;
} else {
// Move to next row
cumulativeHeight += currentRowHeight;
cumulativeWidth = 0;
dayGroupRow++;
dayGroupCol = 0;
// Position at start of new row
dayGroup.row = dayGroupRow;
dayGroup.col = dayGroupCol;
dayGroup.left = 0;
dayGroup.top = cumulativeHeight;
dayGroupCol++;
cumulativeWidth += dayGroup.width + this.scrollManager.gap;
}
currentRowHeight = dayGroup.height + this.scrollManager.headerHeight;
}
// Add the height of the final row
cumulativeHeight += currentRowHeight;
this.height = cumulativeHeight;
this.isHeightActual = true;
}
override updateIntersection({
intersecting,
actuallyIntersecting,
}: {
intersecting: boolean;
actuallyIntersecting: boolean;
}) {
super.updateIntersection({ intersecting, actuallyIntersecting });
if (intersecting) {
this.#clearDeferredLayout();
}
}
get yearMonth() {
return this.#yearMonth;
}
get lastDay() {
@@ -103,29 +207,7 @@ export class TimelineMonth extends ScrollSegment {
return this.days[0]?.getFirstAsset();
}
getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce
return this.days.reduce((accumulator: TimelineAsset[], g: TimelineDay) => accumulator.concat(g.getAssets()), []);
}
sortDays() {
if (this.#sortOrder === AssetOrder.Asc) {
return this.days.sort((a, b) => a.day - b.day);
}
return this.days.sort((a, b) => b.day - a.day);
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as TimelineAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
override runAssetOperation(ids: Set<string>, operation: AssetOperation) {
const { days } = this;
let combinedChangedGeometry = false;
// eslint-disable-next-line svelte/prefer-svelte-reactivity
@@ -138,6 +220,7 @@ export class TimelineMonth extends ScrollSegment {
if (idsToProcess.size > 0) {
const group = days[index];
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(...moveAssets);
}
@@ -215,6 +298,14 @@ export class TimelineMonth extends ScrollSegment {
return addContext.unprocessedAssets;
}
sortDays() {
if (this.#sortOrder === AssetOrder.Asc) {
return this.days.sort((a, b) => a.day - b.day);
}
return this.days.sort((a, b) => b.day - a.day);
}
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
const { localDateTime } = timelineAsset;
@@ -228,8 +319,9 @@ export class TimelineMonth extends ScrollSegment {
if (day) {
addContext.setDay(day, localDateTime);
} else {
const dayTitle = formatDayTitle(fromTimelinePlainDate(localDateTime));
day = new TimelineDay(this, this.days.length, localDateTime.day, dayTitle);
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
day = new TimelineDay(this, this.days.length, localDateTime.day, groupTitle, groupTitleFull);
this.days.push(day);
addContext.setDay(day, localDateTime);
addContext.newDays.add(day);
@@ -240,94 +332,6 @@ export class TimelineMonth extends ScrollSegment {
addContext.changedDays.add(day);
}
get viewId() {
const { year, month } = this.yearMonth;
return year + '-' + month;
}
set height(height: number) {
if (this.#height === height) {
return;
}
const timelineManager = this.timelineManager;
const index = timelineManager.months.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const prevMonth = timelineManager.months[index - 1];
if (prevMonth) {
const newTop = prevMonth.#top + prevMonth.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
if (heightDelta === 0) {
return;
}
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
const month = this.timelineManager.months[cursor];
const newTop = month.#top + heightDelta;
if (month.#top !== newTop) {
month.#top = newTop;
}
}
if (!timelineManager.viewportTopMonthIntersection) {
return;
}
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
if (!month || currentIndex <= 0 || index > currentIndex) {
return;
}
if (index < currentIndex || monthBottomViewportRatio < 1) {
timelineManager.scrollBy(heightDelta);
} else if (index === currentIndex) {
const scrollTo = this.top + height * viewportTopRatioInMonth;
timelineManager.scrollTo(scrollTo);
}
}
get height() {
return this.#height;
}
get top(): number {
return this.#top + this.timelineManager.topSectionHeight;
}
#handleLoadError(error: unknown) {
const _$t = get(t);
handleError(error, _$t('errors.failed_to_load_assets'));
}
findDayForAsset(asset: TimelineAsset) {
for (const group of this.days) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
return group;
}
}
}
findDayByDay(day: number) {
return this.days.find((group) => group.day === day);
}
findAssetAbsolutePosition(assetId: string) {
this.timelineManager.clearDeferredLayout(this);
for (const group of this.days) {
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
if (viewerAsset) {
if (!viewerAsset.position) {
console.warn('No position for asset');
return;
}
return {
top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
height: viewerAsset.position.height,
};
}
}
}
*assetsIterator(options?: { startDay?: TimelineDay; startAsset?: TimelineAsset; direction?: Direction }) {
const direction = options?.direction ?? 'earlier';
let { startAsset } = options ?? {};
@@ -342,6 +346,18 @@ export class TimelineMonth extends ScrollSegment {
}
}
findDayForAsset(asset: TimelineAsset) {
for (const group of this.days) {
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
return group;
}
}
}
findDayByDay(day: number) {
return this.days.find((group) => group.day === day);
}
findAssetById(assetDescriptor: AssetDescriptor) {
for (const asset of this.assetsIterator()) {
if (asset.id === assetDescriptor.id) {
@@ -365,7 +381,13 @@ export class TimelineMonth extends ScrollSegment {
return closest;
}
cancel() {
this.loader?.cancel();
#clearDeferredLayout() {
const hasDeferred = this.days.some((group) => group.deferredLayout);
if (hasDeferred) {
this.updateGeometry({ invalidateHeight: true, noDefer: true });
for (const group of this.days) {
group.deferredLayout = false;
}
}
}
}

View File

@@ -0,0 +1,75 @@
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { findClosestMonthToDate } from '$lib/utils/timeline-util';
import { describe, expect, it } from 'vitest';
function createMockMonthGroup(year: number, month: number): TimelineMonth {
return {
yearMonth: { year, month },
} as TimelineMonth;
}
describe('findClosestMonthToDate', () => {
it('should return undefined for empty months array', () => {
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 = 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 = 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 = 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 = 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 });
});
it('should correctly calculate distance across years', () => {
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 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 = 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 = 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 = 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 = 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

@@ -0,0 +1,252 @@
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 {
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 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,
): Promise<TimelineAsset | undefined> {
const { asset, month } = this.findMonthForAsset(assetDescriptor.id) ?? {};
if (!month || !asset) {
return;
}
switch (interval) {
case 'asset': {
return this.#getAssetByAssetOffset(asset, month, direction);
}
case 'day': {
return this.#getAssetByDayOffset(asset, month, direction);
}
case 'month': {
return this.#getAssetByMonthOffset(month, direction);
}
case 'year': {
return this.#getAssetByYearOffset(month, direction);
}
}
}
findMonthForAsset(id: string) {
for (const month of this.#timelineManager.segments) {
const asset = month.findAssetById({ id });
if (asset) {
return { month, asset };
}
}
}
findMonthByDate(targetYearMonth: TimelineYearMonth): TimelineMonth | undefined {
return this.#timelineManager.segments.find(
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.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.#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,
startDay: day,
startAsset: asset,
direction,
})) {
if (asset.id !== targetAsset.id) {
return targetAsset;
}
}
}
async #getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
const day = month.findDayForAsset(asset);
for await (const targetAsset of this.#timelineManager.assetsIterator({
startMonth: month,
startDay: day,
startAsset: asset,
direction,
})) {
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
return targetAsset;
}
}
}
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
.assetsIterator({ startMonth: targetMonth, direction })
.next();
return done ? undefined : value;
}
}
}
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
.assetsIterator({ startMonth: targetMonth, direction })
.next();
return done ? undefined : value;
}
}
}
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
let { asset: startAsset, month: startMonth } = this.findMonthForAsset(start.id) ?? {};
if (!startMonth || !startAsset) {
return [];
}
let { asset: endAsset, month: endMonth } = this.findMonthForAsset(end.id) ?? {};
if (!endMonth || !endAsset) {
return [];
}
const assetOrder: AssetOrder = this.#timelineManager.getAssetOrder();
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
[startAsset, endAsset] = [endAsset, startAsset];
[startMonth, endMonth] = [endMonth, startMonth];
}
const range: TimelineAsset[] = [];
const startDay = startMonth.findDayForAsset(startAsset);
for await (const targetAsset of this.#timelineManager.assetsIterator({
startMonth,
startDay,
startAsset,
})) {
range.push(targetAsset);
if (targetAsset.id === endAsset.id) {
break;
}
}
return range;
}
findMonthForDate(targetYearMonth: TimelineYearMonth) {
for (const month of this.#timelineManager.segments) {
const { year, month: monthNum } = month.yearMonth;
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
return month;
}
}
}
async getClosestAssetToDate(dateTime: TimelineDateTime) {
let month = this.findMonthForDate(dateTime);
if (!month) {
month = findClosestMonthToDate(this.#timelineManager.segments, dateTime);
if (!month) {
return;
}
}
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

@@ -0,0 +1,16 @@
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
export type TestHooks = {
onCreateTimelineMonth(month: TimelineMonth): unknown;
onCreateTimelineDay(day: TimelineDay): unknown;
};
let testHooks: TestHooks | undefined = undefined;
export const setTestHooks = (hooks: TestHooks) => {
testHooks = hooks;
};
export const onCreateTimelineMonth = (month: TimelineMonth) => testHooks?.onCreateTimelineMonth(month);
export const onCreateTimelineDay = (day: TimelineDay) => testHooks?.onCreateTimelineDay(day);

View File

@@ -1,16 +1,13 @@
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { throttle } from 'lodash-es';
import type { Unsubscriber } from 'svelte/store';
export class WebsocketSupport {
#pendingChanges: PendingChange[] = [];
#unsubscribers: Unsubscriber[] = [];
#timelineManager: TimelineManager;
#processPendingChanges = throttle(() => {
export class TimelineWebsocketExtension {
readonly #timelineManager: TimelineManager;
readonly #processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) {
this.#timelineManager.upsertAssets(add);
@@ -24,11 +21,26 @@ export class WebsocketSupport {
this.#pendingChanges = [];
}, 2500);
#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 WebsocketSupport {
);
}
disconnectWebsocketEvents() {
#disconnectWebsocketEvents() {
for (const unsubscribe of this.#unsubscribers) {
unsubscribe();
}

View File

@@ -1,16 +0,0 @@
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
let testHooks: TestHooks | undefined = undefined;
export type TestHooks = {
onCreateMonth(month: TimelineMonth): unknown;
onCreateDay(day: TimelineDay): unknown;
};
export const setTestHooks = (hooks: TestHooks) => {
testHooks = hooks;
};
export const onCreateMonth = (month: TimelineMonth) => testHooks?.onCreateMonth(month);
export const onCreateDay = (day: TimelineDay) => testHooks?.onCreateDay(day);

View File

@@ -1,73 +0,0 @@
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { TUNABLES } from '$lib/utils/tunables';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export function updateIntersectionMonth(timelineManager: TimelineManager, month: TimelineMonth) {
const actuallyIntersecting = calculateMonthIntersecting(timelineManager, month, 0, 0);
let preIntersecting = false;
if (!actuallyIntersecting) {
preIntersecting = calculateMonthIntersecting(
timelineManager,
month,
INTERSECTION_EXPAND_TOP,
INTERSECTION_EXPAND_BOTTOM,
);
}
month.intersecting = actuallyIntersecting || preIntersecting;
month.actuallyIntersecting = actuallyIntersecting;
if (preIntersecting || actuallyIntersecting) {
timelineManager.clearDeferredLayout(month);
}
}
/**
* General function to check if a rectangular region intersects with a window.
* @param regionTop - Top position of the region to check
* @param regionBottom - Bottom position of the region to check
* @param windowTop - Top position of the window
* @param windowBottom - Bottom position of the window
* @returns true if the region intersects with the window
*/
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
return (
(regionTop >= windowTop && regionTop < windowBottom) ||
(regionBottom >= windowTop && regionBottom < windowBottom) ||
(regionTop < windowTop && regionBottom >= windowBottom)
);
}
export function calculateMonthIntersecting(
timelineManager: TimelineManager,
month: TimelineMonth,
expandTop: number,
expandBottom: number,
) {
const monthTop = month.top;
const monthBottom = monthTop + month.height;
const topWindow = timelineManager.visibleWindow.top - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
return isIntersecting(monthTop, monthBottom, topWindow, bottomWindow);
}
/**
* Calculate intersection for viewer assets with additional parameters like header height
*/
export function calculateViewerAssetIntersecting(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
expandTop: number = INTERSECTION_EXPAND_TOP,
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
) {
const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom;
const positionBottom = positionTop + positionHeight;
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
}

View File

@@ -1,70 +0,0 @@
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
export function updateGeometry(timelineManager: TimelineManager, month: TimelineMonth, options: UpdateGeometryOptions) {
const { invalidateHeight, noDefer = false } = options;
if (invalidateHeight) {
month.isHeightActual = false;
}
if (!month.isLoaded) {
const viewportWidth = timelineManager.viewportWidth;
if (!month.isHeightActual) {
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewportWidth);
const height = 51 + Math.max(1, rows) * timelineManager.rowHeight;
month.height = height;
}
return;
}
layoutMonth(timelineManager, month, noDefer);
}
export function layoutMonth(timelineManager: TimelineManager, month: TimelineMonth, noDefer: boolean = false) {
let cumulativeHeight = 0;
let cumulativeWidth = 0;
let currentRowHeight = 0;
let dayRow = 0;
let dayCol = 0;
const options = timelineManager.justifiedLayoutOptions;
for (const day of month.days) {
day.layout(options, noDefer);
// Calculate space needed for this item (including gap if not first in row)
const spaceNeeded = day.width + (dayCol > 0 ? timelineManager.gap : 0);
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth;
if (fitsInCurrentRow) {
day.row = dayRow;
day.col = dayCol++;
day.left = cumulativeWidth;
day.top = cumulativeHeight;
cumulativeWidth += day.width + timelineManager.gap;
} else {
// Move to next row
cumulativeHeight += currentRowHeight;
cumulativeWidth = 0;
dayRow++;
dayCol = 0;
// Position at start of new row
day.row = dayRow;
day.col = dayCol;
day.left = 0;
day.top = cumulativeHeight;
dayCol++;
cumulativeWidth += day.width + timelineManager.gap;
}
currentRowHeight = day.height + timelineManager.headerHeight;
}
// Add the height of the final row
cumulativeHeight += currentRowHeight;
month.height = cumulativeHeight;
month.isHeightActual = true;
}

View File

@@ -1,57 +0,0 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineManagerOptions } from '$lib/managers/timeline-manager/types';
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { getTimeBucket } from '@immich/sdk';
export async function loadFromTimeBuckets(
timelineManager: TimelineManager,
month: TimelineMonth,
options: TimelineManagerOptions,
signal: AbortSignal,
): Promise<void> {
if (month.getFirstAsset()) {
return;
}
const timeBucket = toISOYearMonthUTC(month.yearMonth);
const bucketResponse = await getTimeBucket(
{
...authManager.params,
...options,
timeBucket,
},
{ signal },
);
if (!bucketResponse) {
return;
}
if (options.timelineAlbumId) {
const albumAssets = await getTimeBucket(
{
...authManager.params,
albumId: options.timelineAlbumId,
timeBucket,
},
{ signal },
);
for (const id of albumAssets.id) {
timelineManager.albumAssets.add(id);
}
}
const unprocessedAssets = month.addAssets(bucketResponse, true);
if (unprocessedAssets.length > 0) {
console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${month.yearMonth.month}, ${JSON.stringify(
unprocessedAssets.map((unprocessed) => ({
id: unprocessed.id,
localDateTime: unprocessed.localDateTime,
})),
)}`,
);
}
}

View File

@@ -1,75 +0,0 @@
import { findClosestGroupForDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import { describe, expect, it } from 'vitest';
function createMockMonth(year: number, month: number): TimelineMonth {
return {
yearMonth: { year, month },
} as TimelineMonth;
}
describe('findClosestGroupForDate', () => {
it('should return undefined for empty months array', () => {
const result = findClosestGroupForDate([], { year: 2024, month: 1 });
expect(result).toBeUndefined();
});
it('should return the only month when there is only one month', () => {
const months = [createMockMonth(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should return exact match when available', () => {
const months = [createMockMonth(2024, 1), createMockMonth(2024, 6), createMockMonth(2024, 12)];
const result = findClosestGroupForDate(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 = [createMockMonth(2024, 1), createMockMonth(2024, 6), createMockMonth(2024, 12)];
const result = findClosestGroupForDate(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 = [createMockMonth(2023, 12), createMockMonth(2024, 2)];
const result = findClosestGroupForDate(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 });
});
it('should correctly calculate distance across years', () => {
const months = [createMockMonth(2022, 6), createMockMonth(2024, 6)];
const result = findClosestGroupForDate(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 = [createMockMonth(2024, 6), createMockMonth(2024, 12)];
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should handle target after all months', () => {
const months = [createMockMonth(2024, 1), createMockMonth(2024, 6)];
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
});
it('should handle multiple years correctly', () => {
const months = [createMockMonth(2020, 1), createMockMonth(2022, 1), createMockMonth(2024, 1)];
const result = findClosestGroupForDate(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 = [createMockMonth(2024, 1), createMockMonth(2024, 10)];
const result = findClosestGroupForDate(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,165 +0,0 @@
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import { 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';
export async function getAssetWithOffset(
timelineManager: TimelineManager,
assetDescriptor: AssetDescriptor,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
direction: Direction,
): Promise<TimelineAsset | undefined> {
const { asset, month } = findMonthForAsset(timelineManager, assetDescriptor.id) ?? {};
if (!month || !asset) {
return;
}
switch (interval) {
case 'asset': {
return getAssetByAssetOffset(timelineManager, asset, month, direction);
}
case 'day': {
return getAssetByDayOffset(timelineManager, asset, month, direction);
}
case 'month': {
return getAssetByMonthOffset(timelineManager, month, direction);
}
case 'year': {
return getAssetByYearOffset(timelineManager, month, direction);
}
}
}
export function findMonthForAsset(timelineManager: TimelineManager, id: string) {
for (const month of timelineManager.months) {
const asset = month.findAssetById({ id });
if (asset) {
return { month, asset };
}
}
}
export function getMonthByDate(
timelineManager: TimelineManager,
targetYearMonth: TimelineYearMonth,
): TimelineMonth | undefined {
return timelineManager.months.find(
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
);
}
async function getAssetByAssetOffset(
timelineManager: TimelineManager,
asset: TimelineAsset,
month: TimelineMonth,
direction: Direction,
) {
const day = month.findDayForAsset(asset);
for await (const targetAsset of timelineManager.assetsIterator({
startMonth: month,
startDay: day,
startAsset: asset,
direction,
})) {
if (asset.id !== targetAsset.id) {
return targetAsset;
}
}
}
async function getAssetByDayOffset(
timelineManager: TimelineManager,
asset: TimelineAsset,
month: TimelineMonth,
direction: Direction,
) {
const day = month.findDayForAsset(asset);
for await (const targetAsset of timelineManager.assetsIterator({
startMonth: month,
startDay: day,
startAsset: asset,
direction,
})) {
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
return targetAsset;
}
}
}
async function getAssetByMonthOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) {
for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) {
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next();
return done ? undefined : value;
}
}
}
async function getAssetByYearOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) {
for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) {
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next();
return done ? undefined : value;
}
}
}
export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) {
let { asset: startAsset, month: startMonth } = findMonthForAsset(timelineManager, start.id) ?? {};
if (!startMonth || !startAsset) {
return [];
}
let { asset: endAsset, month: endMonth } = findMonthForAsset(timelineManager, end.id) ?? {};
if (!endMonth || !endAsset) {
return [];
}
const assetOrder: AssetOrder = timelineManager.getAssetOrder();
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
[startAsset, endAsset] = [endAsset, startAsset];
[startMonth, endMonth] = [endMonth, startMonth];
}
const range: TimelineAsset[] = [];
const startDay = startMonth.findDayForAsset(startAsset);
for await (const targetAsset of timelineManager.assetsIterator({
startMonth,
startDay,
startAsset,
})) {
range.push(targetAsset);
if (targetAsset.id === endAsset.id) {
break;
}
}
return range;
}
export function findMonthForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
for (const month of timelineManager.months) {
const { year, month: monthNum } = month.yearMonth;
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
return month;
}
}
}
export function 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;
}
}
return closestMonth;
}

View File

@@ -1,28 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function updateObject(target: any, source: any): boolean {
if (!target) {
return false;
}
let updated = false;
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
if (key === '__proto__' || key === 'constructor') {
continue;
}
const isDate = target[key] instanceof Date;
if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]);
} else {
if (target[key] !== source[key]) {
target[key] = source[key];
updated = true;
}
}
}
return updated;
}
export function isMismatched<T>(option: T | undefined, value: T): boolean {
return option === undefined ? false : option !== value;
}

View File

@@ -1,4 +1,59 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { TimelineAsset } from './types';
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function updateObject(target: any, source: any): boolean {
if (!target) {
return false;
}
let updated = false;
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
if (key === '__proto__' || key === 'constructor') {
continue;
}
const isDate = target[key] instanceof Date;
if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]);
} else {
if (target[key] !== source[key]) {
target[key] = source[key];
updated = true;
}
}
}
return updated;
}
export function isMismatched<T>(option: T | undefined, value: T): boolean {
return option === undefined ? false : option !== value;
}
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);
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
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;
}

View File

@@ -1,7 +1,14 @@
import { calculateViewerAssetIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { TUNABLES } from '$lib/utils/tunables';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export class ViewerAsset {
readonly #day: TimelineDay;
@@ -11,10 +18,10 @@ export class ViewerAsset {
return false;
}
const store = this.#day.month.timelineManager;
const positionTop = this.#day.absoluteTop + this.position.top;
const scrollManager = this.#day.month.scrollManager;
const positionTop = this.#day.topAbsolute + this.position.top;
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
return calculateViewerAssetIntersecting(scrollManager, positionTop, this.position.height);
});
position: CommonPosition | undefined = $state.raw();
@@ -26,3 +33,19 @@ export class ViewerAsset {
this.asset = asset;
}
}
/**
* Calculate intersection for viewer assets with additional parameters like header height
*/
function calculateViewerAssetIntersecting(
scrollManager: VirtualScrollManager,
positionTop: number,
positionHeight: number,
expandTop: number = INTERSECTION_EXPAND_TOP,
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
) {
const topWindow = scrollManager.visibleWindow.top - scrollManager.headerHeight - expandTop;
const bottomWindow = scrollManager.visibleWindow.bottom + scrollManager.headerHeight + expandBottom;
const positionBottom = positionTop + positionHeight;
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
@@ -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

@@ -3,7 +3,7 @@ import ToastAction from '$lib/components/ToastAction.svelte';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.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';
@@ -14,6 +14,7 @@ import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links';
import { getSegmentIdentifier } from '$lib/utils/timeline-util';
import {
addAssetsToAlbum as addAssets,
addAssetsToAlbums as addToAlbums,
@@ -490,16 +491,16 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
isSelectingAllAssets.set(true);
try {
for (const month of timelineManager.months) {
await timelineManager.loadMonth(month.yearMonth);
for (const monthGroup of timelineManager.segments) {
await timelineManager.loadSegment(getSegmentIdentifier(monthGroup.yearMonth));
if (!get(isSelectingAllAssets)) {
assetInteraction.clearMultiselect();
break; // Cancelled
}
assetInteraction.selectAssets(assetsSnapshot([...month.assetsIterator()]));
assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()]));
for (const dateGroup of month.days) {
for (const dateGroup of monthGroup.days) {
assetInteraction.addGroupToMultiselectGroup(dateGroup.dayTitle);
}
}

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,9 +1,9 @@
import { locale } from '$lib/stores/preferences.store';
import { parseUtcDate } from '$lib/utils/date-time';
import { formatDayTitle, toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { formatGroupTitle, toISOYearMonthUTC } from '$lib/utils/timeline-util';
import { DateTime } from 'luxon';
describe('formatDayTitle', () => {
describe('formatGroupTitle', () => {
beforeAll(() => {
vi.useFakeTimers();
process.env.TZ = 'UTC';
@@ -18,63 +18,63 @@ describe('formatDayTitle', () => {
it('formats today', () => {
const date = parseUtcDate('2024-07-27T01:00:00Z');
locale.set('en');
expect(formatDayTitle(date)).toBe('today');
expect(formatGroupTitle(date)).toBe('today');
locale.set('es');
expect(formatDayTitle(date)).toBe('hoy');
expect(formatGroupTitle(date)).toBe('hoy');
});
it('formats yesterday', () => {
const date = parseUtcDate('2024-07-26T23:59:59Z');
locale.set('en');
expect(formatDayTitle(date)).toBe('yesterday');
expect(formatGroupTitle(date)).toBe('yesterday');
locale.set('fr');
expect(formatDayTitle(date)).toBe('hier');
expect(formatGroupTitle(date)).toBe('hier');
});
it('formats last week', () => {
const date = parseUtcDate('2024-07-21T00:00:00Z');
locale.set('en');
expect(formatDayTitle(date)).toBe('Sunday');
expect(formatGroupTitle(date)).toBe('Sunday');
locale.set('ar-SA');
expect(formatDayTitle(date)).toBe('الأحد');
expect(formatGroupTitle(date)).toBe('الأحد');
});
it('formats date 7 days ago', () => {
const date = parseUtcDate('2024-07-20T00:00:00Z');
locale.set('en');
expect(formatDayTitle(date)).toBe('Sat, Jul 20');
expect(formatGroupTitle(date)).toBe('Sat, Jul 20');
locale.set('de');
expect(formatDayTitle(date)).toBe('Sa., 20. Juli');
expect(formatGroupTitle(date)).toBe('Sa., 20. Juli');
});
it('formats date this year', () => {
const date = parseUtcDate('2020-01-01T00:00:00Z');
locale.set('en');
expect(formatDayTitle(date)).toBe('Wed, Jan 1, 2020');
expect(formatGroupTitle(date)).toBe('Wed, Jan 1, 2020');
locale.set('ja');
expect(formatDayTitle(date)).toBe('2020年1月1日(水)');
expect(formatGroupTitle(date)).toBe('2020年1月1日(水)');
});
it('formats future date', () => {
const tomorrow = parseUtcDate('2024-07-28T00:00:00Z');
locale.set('en');
expect(formatDayTitle(tomorrow)).toBe('Sun, Jul 28');
expect(formatGroupTitle(tomorrow)).toBe('Sun, Jul 28');
const nextMonth = parseUtcDate('2024-08-28T00:00:00Z');
locale.set('en');
expect(formatDayTitle(nextMonth)).toBe('Wed, Aug 28');
expect(formatGroupTitle(nextMonth)).toBe('Wed, Aug 28');
const nextYear = parseUtcDate('2025-01-10T12:00:00Z');
locale.set('en');
expect(formatDayTitle(nextYear)).toBe('Fri, Jan 10, 2025');
expect(formatGroupTitle(nextYear)).toBe('Fri, Jan 10, 2025');
});
it('returns "Invalid DateTime" when date is invalid', () => {
const date = DateTime.invalid('test');
locale.set('en');
expect(formatDayTitle(date)).toBe('Invalid DateTime');
expect(formatGroupTitle(date)).toBe('Invalid DateTime');
locale.set('es');
expect(formatDayTitle(date)).toBe('Invalid DateTime');
expect(formatGroupTitle(date)).toBe('Invalid DateTime');
});
});

View File

@@ -1,4 +1,6 @@
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
@@ -99,7 +101,7 @@ export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string =>
return `${yearFull}-${monthFull}-01T00:00:00.000Z`;
};
export function formatMonthTitle(_date: DateTime): string {
export function formatMonthGroupTitle(_date: DateTime): string {
if (!_date.isValid) {
return _date.toString();
}
@@ -113,7 +115,7 @@ export function formatMonthTitle(_date: DateTime): string {
);
}
export function formatDayTitle(_date: DateTime): string {
export function formatGroupTitle(_date: DateTime): string {
if (!_date.isValid) {
return _date.toString();
}
@@ -221,27 +223,47 @@ 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);
export const formatGroupTitleFull = (_date: DateTime): string => {
if (!_date.isValid) {
return _date.toString();
}
const result = new Set<T>();
for (const value of setA) {
if (!setB.has(value)) {
result.add(value);
}
}
return result;
}
const date = _date as DateTime<true>;
return getDateLocaleString(date);
};
/**
* Removes all elements of setB from setA in-place (mutates setA).
* Creates a segment identifier for a given year/month or date/time.
* This is used to uniquely identify and match timeline segments (MonthGroups).
*/
export function setDifferenceInPlace<T>(setA: Set<T>, setB: Set<T>): Set<T> {
for (const value of setB) {
setA.delete(value);
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime): SegmentIdentifier => ({
get id() {
return yearMonth.year + '-' + yearMonth.month;
},
matches: (segment: ScrollSegment) => {
const monthSegment = segment as TimelineMonth;
return (
monthSegment.yearMonth &&
monthSegment.yearMonth.year === yearMonth.year &&
monthSegment.yearMonth.month === yearMonth.month
);
},
});
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 setA;
}
return closestMonth;
};

View File

@@ -140,8 +140,8 @@
const handleStartSlideshow = async () => {
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.days[0]?.viewerAssets[0]?.asset;
? await timelineManager.search.getRandomAsset()
: timelineManager.getFirstAsset();
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
}

View File

@@ -5,7 +5,7 @@
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
@@ -117,7 +117,7 @@
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
dayTitle: string,
groupTitle: string,
asset: TimelineAsset,
) => void,
) => {