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];