Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/src/repositories/storage.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export class StorageRepository {
const file = await fs.open(filepath);
try {
const { buffer } = await file.read(options);
return buffer;
return buffer as Buffer;
} finally {
await file.close();
}
Expand Down
172 changes: 112 additions & 60 deletions web/src/lib/components/timeline/Timeline.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
Expand All @@ -10,6 +10,7 @@
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
Expand All @@ -18,7 +19,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { navigate } from '$lib/utils/navigation';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
Expand Down Expand Up @@ -129,38 +130,57 @@
timelineManager.scrollableElement = scrollableElement;
});

const scrollToTop = () => {
timelineManager.scrollTo(0);
};
const getAssetPosition = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);

const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
const scrollToAssetPosition = (assetId: string, monthGroup: MonthGroup) => {
const position = getAssetPosition(assetId, monthGroup);

const assetIsVisible = (assetTop: number): boolean => {
if (!scrollableElement) {
return false;
if (!position) {
return;
}

// Need to update window positions/intersections because <Portal> may have
// gone from invisible to visible.
timelineManager.updateSlidingWindow();

const assetTop = position.top;
const assetBottom = position.top + position.height;
const visibleTop = timelineManager.visibleWindow.top;
const visibleBottom = timelineManager.visibleWindow.bottom;

// Check if the asset is already at least partially visible in the viewport
if (isIntersecting(assetTop, assetBottom, visibleTop, visibleBottom)) {
return;
}

const { clientHeight, scrollTop } = scrollableElement;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
const currentTop = scrollableElement?.scrollTop || 0;
const viewportHeight = visibleBottom - visibleTop;

// Calculate the minimum scroll needed to bring the asset into view.
// Compare two alignment strategies and choose whichever requires less scroll distance:
// 1. Align asset top with viewport top
// 2. Align asset bottom with viewport bottom

// Option 1: Scroll so the top of the asset is at the top of the viewport
const scrollToAlignTop = assetTop;
const distanceToAlignTop = Math.abs(scrollToAlignTop - currentTop);

// Option 2: Scroll so the bottom of the asset is at the bottom of the viewport
const scrollToAlignBottom = assetBottom - viewportHeight;
const distanceToAlignBottom = Math.abs(scrollToAlignBottom - currentTop);

// Choose whichever option requires the minimum scroll distance
const scrollTarget = distanceToAlignTop < distanceToAlignBottom ? scrollToAlignTop : scrollToAlignBottom;

timelineManager.scrollTo(scrollTarget);
};

const scrollToAssetId = async (assetId: string) => {
const scrollAndLoadAsset = async (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
}

const height = getAssetHeight(assetId, monthGroup);

// If the asset is already visible, then don't scroll.
if (assetIsVisible(height)) {
// need to update window positions/intersections because since the <Portal>
// went from invisible to visible
timelineManager.updateSlidingWindow();
return true;
}

timelineManager.scrollTo(height);
scrollToAssetPosition(assetId, monthGroup);
return true;
};

Expand All @@ -169,52 +189,69 @@
if (!monthGroup) {
return false;
}
const height = getAssetHeight(asset.id, monthGroup);
timelineManager.scrollTo(height);
scrollToAssetPosition(asset.id, monthGroup);
return true;
};

const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
export const scrollAfterNavigate = async ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) => {
if (timelineManager.viewportHeight === 0 || timelineManager.viewportWidth === 0) {
// this can happen if you do the following navigation order
// /photos?at=<id>, /photos/<id>, http://example.com, browser back, browser back
const rect = scrollableElement?.getBoundingClientRect();
if (rect) {
timelineManager.viewportHeight = rect.height;
timelineManager.viewportWidth = rect.width;
}
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollToTop();
if (scrollToAssetQueryParam) {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
}
}
invisible = false;
};

beforeNavigate(() => (timelineManager.suspendTransitions = true));

afterNavigate((nav) => {
const { complete } = nav;
complete.then(completeNav, completeNav);
// note: only modified once in afterNavigate()
let initialLoadWasAssetViewer: boolean | null = null;
// only modified in beforeNavigate()
let hasNavigatedToOrFromAssetViewer: boolean = false;

// beforeNavigate is only called AFTER a svelte route has already been loaded
// and a new route is being navigated to. It will never be called on direct
// navigations by the browser.
beforeNavigate(({ from, to }) => {
timelineManager.suspendTransitions = true;
const isNavigatingToAssetViewer = isAssetViewerRoute(to);
const isNavigatingFromAssetViewer = isAssetViewerRoute(from);
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
});

const handleAfterUpdate = (payload: UpdatePayload) => {
const timelineUpdate = payload.updates.some(
(update) => update.path.endsWith('Timeline.svelte') || update.path.endsWith('assets-store.ts'),
);
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
// after successful navigation.
afterNavigate(({ complete }) => {
void complete.finally(() => {
const isAssetViewerPage = isAssetViewerRoute(page);

// Set initial load state only once - if initialLoadWasAssetViewer is null, then
// this is a direct browser navigation.
const isDirectNavigation = initialLoadWasAssetViewer === null;
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}

if (timelineUpdate) {
setTimeout(() => {
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
void navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
} else {
scrollToTop();
}
invisible = false;
}, 500);
}
};
const isDirectTimelineLoad = isDirectNavigation && !isAssetViewerPage;
const isNavigatingFromAssetViewer = !isDirectNavigation && hasNavigatedToOrFromAssetViewer;
const scrollToAssetQueryParam = isDirectTimelineLoad || isNavigatingFromAssetViewer;

void scrollAfterNavigate({ scrollToAssetQueryParam });
});
});

const updateIsScrolling = () => (timelineManager.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
Expand All @@ -235,6 +272,7 @@

timelineManager.scrollTo(scrollToTop);
};

// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListener = (scrubberData) => {
Expand Down Expand Up @@ -500,7 +538,21 @@

<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />

<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={() => timelineManager.destroy()} />
<HotModuleReload
onAfterUpdate={() => {
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
}
void scrollAfterNavigate({ scrollToAssetQueryParam: true });
}}
onBeforeUpdate={(payload: UpdatePayload) => {
const timelineUpdate = payload.updates.some((update) => update.path.endsWith('Timeline.svelte'));
if (timelineUpdate) {
timelineManager.destroy();
}
}}
/>

<TimelineKeyboardActions
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
Expand Down
8 changes: 5 additions & 3 deletions web/src/lib/managers/timeline-manager/month-group.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,14 @@ export class MonthGroup {
if (viewerAsset) {
if (!viewerAsset.position) {
console.warn('No position for asset');
break;
return;
}
return this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight;
return {
top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight,
height: viewerAsset.position.height,
};
}
}
return -1;
}

*assetsIterator(options?: { startDayGroup?: DayGroup; startAsset?: TimelineAsset; direction?: Direction }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ export class TimelineManager extends VirtualScrollManager {
await this.initTask.reset();
await this.#init(options);
this.updateViewportGeometry(false);
this.#createScrubberMonths();
}

async #init(options: TimelineManagerOptions) {
Expand Down
Loading