diff --git a/web/src/lib/components/timeline/Photostream.svelte b/web/src/lib/components/timeline/Photostream.svelte
index 93425d03e7..5a78a3b1fb 100644
--- a/web/src/lib/components/timeline/Photostream.svelte
+++ b/web/src/lib/components/timeline/Photostream.svelte
@@ -1,6 +1,5 @@
+ import { afterNavigate, beforeNavigate } from '$app/navigation';
+ import { page } from '$app/state';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
@@ -14,6 +16,7 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+ import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
@@ -71,11 +74,51 @@
customThumbnailLayout,
}: Props = $props();
- let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
+ let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let viewer: PhotostreamWithScrubber | undefined = $state();
let showSkeleton: boolean = $state(true);
+ // tri-state boolean
+ let initialLoadWasAssetViewer: boolean | null = null;
+ let hasNavigatedToOrFromAssetViewer: boolean = false;
+ let timelineScrollPositionInitialized = false;
+
+ beforeNavigate(({ from, to }) => {
+ timelineManager.suspendTransitions = true;
+ hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
+ });
+
+ const completeAfterNavigate = () => {
+ const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
+ let isInitial = false;
+ // Set initial load state only once
+ if (initialLoadWasAssetViewer === null) {
+ initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
+ isInitial = true;
+ }
+
+ let scrollToAssetQueryParam = false;
+ if (
+ !timelineScrollPositionInitialized &&
+ ((isInitial && !assetViewerPage) || // Direct timeline load
+ (!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
+ ) {
+ scrollToAssetQueryParam = true;
+ timelineScrollPositionInitialized = true;
+ }
+
+ return viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
+ };
+ afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
+
+ const onViewerClose = async (asset: { id: string }) => {
+ assetViewingStore.showAssetViewer(false);
+ showSkeleton = true;
+ $gridScrollTarget = { at: asset.id };
+ await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
+ };
+
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
@@ -85,7 +128,7 @@
viewer?.scrollToAsset(asset) ?? false}
+ scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
@@ -165,6 +208,6 @@
{#if $showAssetViewer}
-
+
{/if}
diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte
index 17e3ed06f3..578452c44c 100644
--- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte
+++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte
@@ -9,15 +9,16 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
- let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
+ let { asset: viewingAsset, mutex, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
- showSkeleton: boolean;
+
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
+ onViewerClose?: (asset: { id: string }) => Promise;
removeAction?:
| AssetAction.UNARCHIVE
@@ -30,12 +31,12 @@
let {
timelineManager,
- showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
+ onViewerClose = () => Promise.resolve(void 0),
}: Props = $props();
const handlePrevious = async () => {
@@ -79,13 +80,6 @@
}
};
- const handleClose = async (asset: { id: string }) => {
- assetViewingStore.showAssetViewer(false);
- showSkeleton = true;
- $gridScrollTarget = { at: asset.id };
- await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
- };
-
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
@@ -97,7 +91,7 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
- (await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
+ (await handleNext()) || (await handlePrevious()) || (await onViewerClose?.(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
@@ -172,6 +166,6 @@
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
- onClose={handleClose}
+ onClose={onViewerClose}
/>
{/await}
diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
index 6d7a64d3d8..69ced1b51c 100644
--- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
+++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
@@ -7,8 +7,10 @@
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import {
- setFocusToAsset as setFocusAssetInit,
- setFocusTo as setFocusToInit,
+ setFocusToAsset as setFocusAssetUtil,
+ setFocusTo as setFocusToUtil,
+ type FocusDirection,
+ type FocusInterval,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -32,7 +34,7 @@
assetInteraction: AssetInteraction;
isShowDeleteConfirmation: boolean;
onEscape?: () => void;
- scrollToAsset: (asset: TimelineAsset) => boolean;
+ scrollToAsset: (asset: TimelineAsset) => Promise;
}
let {
@@ -147,8 +149,10 @@
}
});
- const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
- const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
+ const setFocusTo = (direction: FocusDirection, interval: FocusInterval) =>
+ setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
+
+ const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
let shortcutList = $derived(
(() => {
@@ -212,7 +216,7 @@
(DateTime.fromISO(dateString.date) as DateTime).toObject(),
);
if (asset) {
- setFocusAsset(asset);
+ void setFocusAsset(asset);
}
}
}}
diff --git a/web/src/lib/components/timeline/actions/focus-actions.ts b/web/src/lib/components/timeline/actions/focus-actions.ts
index f0f9e2e50c..74cb0ea42c 100644
--- a/web/src/lib/components/timeline/actions/focus-actions.ts
+++ b/web/src/lib/components/timeline/actions/focus-actions.ts
@@ -21,19 +21,26 @@ export const focusPreviousAsset = () =>
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
-export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
- const scrolled = scrollToAsset(asset);
+export const setFocusToAsset = async (
+ scrollToAsset: (asset: TimelineAsset) => Promise,
+ asset: TimelineAsset,
+) => {
+ const scrolled = await scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
};
+export type FocusDirection = 'earlier' | 'later';
+
+export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
+
export const setFocusTo = async (
- scrollToAsset: (asset: TimelineAsset) => boolean,
+ scrollToAsset: (asset: TimelineAsset) => Promise,
store: TimelineManager,
- direction: 'earlier' | 'later',
- interval: 'day' | 'month' | 'year' | 'asset',
+ direction: FocusDirection,
+ interval: FocusInterval,
) => {
if (tracker.isActive()) {
// there are unfinished running invocations, so return early
@@ -65,7 +72,10 @@ export const setFocusTo = async (
return;
}
- const scrolled = scrollToAsset(asset);
+ const scrolled = await scrollToAsset(asset);
+ if (!invocation.isStillValid()) {
+ return;
+ }
if (scrolled) {
await tick();
if (!invocation.isStillValid()) {
diff --git a/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts b/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts
index e2f22a4be8..b7e1c96055 100644
--- a/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts
+++ b/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts
@@ -269,13 +269,14 @@ export abstract class PhotostreamManager {
return this.months.find((segment) => identifier.matches(segment));
}
- getSegmentForAssetId(assetId: string) {
+ findSegmentForAssetId(assetId: string): Promise {
for (const month of this.months) {
const asset = month.assets.find((asset) => asset.id === assetId);
if (asset) {
- return month;
+ return Promise.resolve(month);
}
}
+ return Promise.resolve(void 0);
}
refreshLayout() {
diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
index 1640cb79a1..460888d841 100644
--- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
+++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
@@ -1,5 +1,8 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
-import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
+import {
+ findMonthGroupForAsset,
+ getMonthGroupByDate,
+} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
@@ -556,10 +559,10 @@ describe('TimelineManager', () => {
);
timelineManager.addAssets([assetOne, assetTwo]);
- expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
- expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
- expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
- expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
+ expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
+ expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
+ expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
+ expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
it('ignores removed months', () => {
@@ -576,8 +579,8 @@ describe('TimelineManager', () => {
timelineManager.addAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
- expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
- expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
+ expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
+ expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
});
});
diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
index 54ec7d5a9a..3ed8a90b7f 100644
--- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
@@ -187,7 +187,7 @@ export class TimelineManager extends PhotostreamManager {
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
}
- async findMonthGroupForAsset(id: string) {
+ async findSegmentForAssetId(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
@@ -218,11 +218,6 @@ export class TimelineManager extends PhotostreamManager {
return getMonthGroupByDate(this, yearMonth);
}
- getMonthGroupByAssetId(assetId: string) {
- const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
- return monthGroupInfo?.monthGroup;
- }
-
async getRandomMonthGroup() {
const random = Math.floor(Math.random() * this.months.length);
const month = this.months[random];