feat: Extract common StreamWithViewer component

- Create new StreamWithViewer component to handle asset viewer lifecycle
  and navigation
  - Move beforeNavigate/afterNavigate hooks from Timeline to StreamWithViewer
  - Extract asset viewer Portal rendering and close handler to wrapper
  component
  - Move timeline segment loading logic for viewed assets to StreamWithViewer
  - Simplify Timeline component by removing ~76 lines of navigation/viewer
  code
  - Remove showSkeleton state management from Timeline (now handled by
  PhotostreamWithScrubber)

  This separation of concerns makes the Timeline component more focused on
  rendering while StreamWithViewer handles all viewer-related navigation and state
  management.The new component can be reused by other photostream-like components that
  need asset viewer functionality.
This commit is contained in:
midzelis 2025-09-25 14:26:23 +00:00
parent 79adb016e8
commit 398755e65e
2 changed files with 160 additions and 140 deletions

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Portal from '$lib/elements/Portal.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.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 { DateTime } from 'luxon';
import { type Snippet } from 'svelte';
interface Props {
timelineManager: PhotostreamManager;
children?: Snippet;
assetViewer: Snippet<[{ onViewerClose: (asset: { id: string }) => Promise<void> }]>;
onAfterNavigateComplete: (args: { scrollToAssetQueryParam: boolean }) => void;
}
let { timelineManager, children, assetViewer, onAfterNavigateComplete }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
// 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 onAfterNavigateComplete({ scrollToAssetQueryParam });
};
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
const onViewerClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
}
});
</script>
{@render children?.()}
<Portal target="body">
{#if $showAssetViewer}
{@render assetViewer({ onViewerClose })}
{/if}
</Portal>

View file

@ -1,25 +1,19 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte'; import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte'; import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
import SelectableDay from '$lib/components/timeline/SelectableDay.svelte'; import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte'; import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte'; import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; 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 { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
interface Props { interface Props {
@ -74,140 +68,90 @@
customThumbnailLayout, customThumbnailLayout,
}: Props = $props(); }: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore; let viewer: PhotostreamWithScrubber | undefined = $state(undefined);
let viewer: PhotostreamWithScrubber | undefined = $state(); const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
let showSkeleton: boolean = $state(true); viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
// 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);
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
}
});
</script> </script>
<TimelineKeyboardActions <StreamWithViewer {timelineManager} {onAfterNavigateComplete}>
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)} {#snippet assetViewer({ onViewerClose })}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
{onEscape}
/>
<PhotostreamWithScrubber
bind:this={viewer}
{enableRouting}
{timelineManager}
{isShowDeleteConfirmation}
{showSkeleton}
{children}
{empty}
>
{#snippet skeleton({ segment })}
<Skeleton
height={segment.height - segment.timelineManager.headerHeight}
title={(segment as MonthGroup).monthGroupTitle}
/>
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
<SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{onAssetOpen}
{onAssetSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<SelectableDay {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<MonthSegment
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
monthGroup={segment as MonthGroup}
{timelineManager}
{onDayGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen(asset)}
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
onMouseEvent={(isMouseOver) => {
if (isMouseOver) {
onAssetHover(asset);
} else {
onAssetHover(null);
}
}}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</MonthSegment>
{/snippet}
</SelectableDay>
{/snippet}
</SelectableSegment>
{/snippet}
</PhotostreamWithScrubber>
<Portal target="body">
{#if $showAssetViewer}
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} /> <TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
{/if} {/snippet}
</Portal> <TimelineKeyboardActions
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
{onEscape}
/>
<PhotostreamWithScrubber
bind:this={viewer}
{enableRouting}
{timelineManager}
{isShowDeleteConfirmation}
{children}
{empty}
>
{#snippet skeleton({ segment })}
<Skeleton
height={segment.height - segment.timelineManager.headerHeight}
title={(segment as MonthGroup).monthGroupTitle}
/>
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
<SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{onAssetOpen}
{onAssetSelect}
>
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
<SelectableDay {assetInteraction} {onAssetSelect}>
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
<MonthSegment
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
monthGroup={segment as MonthGroup}
{timelineManager}
{onDayGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen(asset)}
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
onMouseEvent={(isMouseOver) => {
if (isMouseOver) {
onAssetHover(asset);
} else {
onAssetHover(null);
}
}}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</MonthSegment>
{/snippet}
</SelectableDay>
{/snippet}
</SelectableSegment>
{/snippet}
</PhotostreamWithScrubber>
</StreamWithViewer>