diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 2a07aa2900..c461d6245e 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -85,8 +85,8 @@ onerror={setErrored} style:width={widthStyle} style:height={heightStyle} - style:filter={hidden ? 'grayscale(50%)' : 'none'} style:opacity={hidden ? '0.5' : '1'} + style:filter="blur(7px)" src={url} alt={loaded || errored ? altText : ''} {title} diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index e4e3d325c5..9ee0ea4941 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -88,6 +88,7 @@ > {$t('memory_lane_title', + import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; + import AssetLayout from '$lib/components/timeline/base-components/AssetLayout.svelte'; + import BaseTimelineViewer from '$lib/components/timeline/base-components/base-timeline-viewer.svelte'; + import SelectableTimelineMonth from '$lib/components/timeline/internal-components/selectable-timeline-month.svelte'; + import Skeleton from '$lib/elements/Skeleton.svelte'; + import { SearchStreamManager } from '$lib/managers/timeline-manager/SearchStreamManager.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + + let { isViewing: showAssetViewer } = assetViewingStore; + + interface Props { + searchTerms: any; + } + + let { searchTerms }: Props = $props(); + + let viewer: BaseTimelineViewer | undefined = $state(); + let showSkeleton: boolean = $state(true); + + const timelineManager = new SearchStreamManager(searchTerms, { isSmartSearchEnabled: true }); + timelineManager.init(); + const assetInteraction = new AssetInteraction(); + + + + {#snippet skeleton({ segment })} + + {/snippet} + {#snippet segment({ segment, onScrollCompensationMonthInDOM })} + + {#snippet content({ onAssetOpen, onAssetSelect, onHover })} + + {#snippet thumbnail({ asset, position })} + {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} + {@const isAssetSelected = assetInteraction.hasSelectedAsset(asset.id)} + onAssetOpen(asset)} + onSelect={() => onAssetSelect(asset)} + onMouseEvent={() => onHover(asset)} + selected={isAssetSelected} + selectionCandidate={isAssetSelectionCandidate} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> + {/snippet} + + {/snippet} + + {/snippet} + diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 75dce198f9..66a40a024d 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -1,10 +1,16 @@ + + +
+ {#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)} + {@const position = viewerAsset.position!} + {@const asset = viewerAsset.asset!} + + +
+ {@render thumbnail({ asset, position })} + {@render customThumbnailLayout?.(asset)} +
+ {/each} +
+ + diff --git a/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte b/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte index 15f9d25258..0a643b8dfd 100644 --- a/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte +++ b/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte @@ -2,37 +2,43 @@ import { afterNavigate, beforeNavigate } from '$app/navigation'; import { page } from '$app/stores'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; - import Skeleton from '$lib/components/timeline/base-components/skeleton.svelte'; - import SelectableTimelineMonth from '$lib/components/timeline/internal-components/selectable-timeline-month.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; - import type { DayGroup } from '$lib/managers/timeline-manager/day-group.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 } from '$lib/managers/timeline-manager/types'; - import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; + import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { onMount, type Snippet } from 'svelte'; import type { UpdatePayload } from 'vite'; interface Props { - customThumbnailLayout?: Snippet<[TimelineAsset]>; + segment: Snippet< + [ + { + segment: PhotostreamSegment; + scrollToFunction: (top: number) => void; + onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void; + }, + ] + >; + skeleton: Snippet< + [ + { + segment: PhotostreamSegment; + }, + ] + >; - isSelectionMode?: boolean; - singleSelect?: boolean; + showScrollbar?: boolean; /** `true` if this asset grid responds to navigation events; if `true`, then look at the `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and additionally, update the page location/url with the asset as the asset-grid is scrolled */ enableRouting: boolean; - timelineManager: TimelineManager; - assetInteraction: AssetInteraction; - withStacked?: boolean; - showArchiveIcon?: boolean; + timelineManager: PhotostreamManager; + showSkeleton?: boolean; isShowDeleteConfirmation?: boolean; styleMarginRightOverride?: string; - onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void; - onSelect?: (asset: TimelineAsset) => void; + header?: Snippet<[scrollToFunction: (top: number) => void]>; children?: Snippet; empty?: Snippet; @@ -40,22 +46,16 @@ } let { - customThumbnailLayout, + segment, - isSelectionMode = false, - singleSelect = false, enableRouting, timelineManager = $bindable(), - assetInteraction, - withStacked = false, showSkeleton = $bindable(true), - showArchiveIcon = false, styleMarginRightOverride, isShowDeleteConfirmation = $bindable(false), - - onAssetOpen, - onSelect, + showScrollbar, children, + skeleton, empty, header, handleTimelineScroll = () => {}, @@ -96,7 +96,7 @@ updateSlidingWindow(); }; - const scrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => { + const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => { const { heightDelta, scrollTop } = compensation; if (heightDelta !== undefined) { scrollBy(heightDelta); @@ -106,16 +106,16 @@ timelineManager.clearScrollCompensation(); }; - const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => { + const getAssetHeight = (assetId: string, monthGroup: PhotostreamSegment) => { // the following method may trigger any layouts, so need to // handle any scroll compensation that may have been set - const height = monthGroup!.findAssetAbsolutePosition(assetId); + const height = monthGroup.findAssetAbsolutePosition(assetId); // this is in a while loop, since scrollCompensations invoke scrolls // which may load months, triggering more scrollCompensations. Call // this in a loop, until no more layouts occur. while (timelineManager.scrollCompensation.monthGroup) { - scrollCompensation(timelineManager.scrollCompensation); + handleTriggeredScrollCompensation(timelineManager.scrollCompensation); } return height; }; @@ -129,8 +129,8 @@ return assetTop >= scrollTop && assetTop < scrollTop + clientHeight; }; - const scrollToAssetId = async (assetId: string) => { - const monthGroup = await timelineManager.findMonthGroupForAsset(assetId); + export const scrollToAssetId = (assetId: string) => { + const monthGroup = timelineManager.getSegmentForAssetId(assetId); if (!monthGroup) { return false; } @@ -145,16 +145,6 @@ return true; }; - export const scrollToAsset = (asset: TimelineAsset) => { - const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id); - if (!monthGroup) { - return false; - } - const height = getAssetHeight(asset.id, monthGroup); - scrollTo(height); - return true; - }; - const completeNav = async () => { const scrollTarget = $gridScrollTarget?.at; let scrolled = false; @@ -219,8 +209,14 @@
((timelineManager.viewportWidth = v), updateSlidingWindow())} @@ -247,50 +243,26 @@ {/if}
- {#each timelineManager.months as monthGroup (monthGroup.viewId)} - {@const display = monthGroup.intersecting} + {#each timelineManager.months as monthGroup (monthGroup.id)} + {@const shouldDisplay = monthGroup.intersecting} {@const absoluteHeight = monthGroup.top} - - {#if !monthGroup.isLoaded} -
- -
- {:else if display} -
- { - if (isSingleSelect) { - scrollTo(0); - } - onSelect?.(asset); - }} - onScrollCompensationMonthInDOM={scrollCompensation} - /> -
- {/if} +
+ {#if !shouldDisplay} + {@render skeleton({ segment: monthGroup })} + {:else} + {@render segment({ + segment: monthGroup, + scrollToFunction: scrollTo, + onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation, + })} + {/if} +
{/each}
import BaseTimelineViewer from '$lib/components/timeline/base-components/base-timeline-viewer.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte'; - import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; + import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; - import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { findMonthAtScrollPosition, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import type { Snippet } from 'svelte'; interface Props { - customThumbnailLayout?: Snippet<[TimelineAsset]>; - - isSelectionMode?: boolean; - singleSelect?: boolean; /** `true` if this timeline responds to navigation events; if `true`, then look at the `AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and additionally, update the page location/url with the asset as the asset-grid is scrolled */ enableRouting: boolean; timelineManager: TimelineManager; - assetInteraction: AssetInteraction; - withStacked?: boolean; - showArchiveIcon?: boolean; showSkeleton?: boolean; - isShowDeleteConfirmation?: boolean; - onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void; - onSelect?: (asset: TimelineAsset) => void; + segment: Snippet< + [ + { + segment: PhotostreamSegment; + onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void; + }, + ] + >; + skeleton: Snippet< + [ + { + segment: PhotostreamSegment; + }, + ] + >; children?: Snippet; empty?: Snippet; } let { - customThumbnailLayout, - isSelectionMode = false, - singleSelect = false, enableRouting, timelineManager = $bindable(), - assetInteraction, - withStacked = false, - showArchiveIcon = false, showSkeleton = $bindable(true), isShowDeleteConfirmation = $bindable(false), - onAssetOpen, - onSelect = () => {}, + segment, + skeleton, children, empty, }: Props = $props(); @@ -160,27 +158,21 @@ handleScrollTop?.(scrollToTop); }; let baseTimelineViewer: BaseTimelineViewer | undefined = $state(); - export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAsset(asset) ?? false; + export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAssetId(asset.id) ?? false; {#snippet header(scrollToFunction)} {#if timelineManager.months.length > 0} diff --git a/web/src/lib/components/timeline/base-components/month-group-segment.svelte b/web/src/lib/components/timeline/base-components/month-group-segment.svelte new file mode 100644 index 0000000000..291ac557f6 --- /dev/null +++ b/web/src/lib/components/timeline/base-components/month-group-segment.svelte @@ -0,0 +1,73 @@ + + +

a

+{#if !shouldDisplay} +
+ +
+{:else} +
+ {@render contents()} +
+{/if} + + diff --git a/web/src/lib/components/timeline/base-components/timeline-month.svelte b/web/src/lib/components/timeline/base-components/timeline-month.svelte index bfc805f698..99e545c347 100644 --- a/web/src/lib/components/timeline/base-components/timeline-month.svelte +++ b/web/src/lib/components/timeline/base-components/timeline-month.svelte @@ -1,64 +1,42 @@ {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} {@const absoluteWidth = dayGroup.left} - + {@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
- {#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || isDayGroupSelected(dayGroup))} + {#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || isDayGroupSelected)}
onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))} > - {#if isDayGroupSelected(dayGroup)} + {#if isDayGroupSelected} {:else} @@ -126,48 +97,17 @@
- -
- {#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)} - {@const position = viewerAsset.position!} - {@const asset = viewerAsset.asset!} - - -
- onAssetOpen?.(dayGroup, assetSnapshot(asset))} - onSelect={() => onAssetSelect(dayGroup, assetSnapshot(asset))} - onMouseEvent={() => onHover(dayGroup, assetSnapshot(asset))} - selected={isAssetSelected(asset)} - selectionCandidate={isAssetSelectionCandidate(asset)} - disabled={isAssetDisabled(asset)} - thumbnailWidth={position.width} - thumbnailHeight={position.height} - /> - {#if customThumbnailLayout} - {@render customThumbnailLayout(asset)} - {/if} -
- {/each} -
+ {#snippet thumbnail({ asset, position })} + {@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })} + {/snippet} +
{/each} @@ -175,7 +115,4 @@ section { contain: layout paint style; } - [data-image-grid] { - user-select: none; - } diff --git a/web/src/lib/components/timeline/internal-components/selectable-timeline-daygroup.svelte b/web/src/lib/components/timeline/internal-components/selectable-timeline-daygroup.svelte new file mode 100644 index 0000000000..d9f550860f --- /dev/null +++ b/web/src/lib/components/timeline/internal-components/selectable-timeline-daygroup.svelte @@ -0,0 +1,63 @@ + + +{@render content({ + onDayGroupSelect, + onDayGroupAssetSelect, +})} diff --git a/web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte b/web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte index 87da8d69e6..5f4afb5d7f 100644 --- a/web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte +++ b/web/src/lib/components/timeline/internal-components/selectable-timeline-month.svelte @@ -1,40 +1,41 @@ - assetInteraction.selectedGroup.has(dayGroup.groupTitle)} - isAssetSelected={(asset) => assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} - isAssetSelectionCandidate={(asset) => assetInteraction.hasSelectionCandidate(asset.id)} - isAssetDisabled={(asset) => timelineManager.albumAssets.has(asset.id)} -/> +{@render content({ + onAssetOpen: handleOnAssetOpen, + onAssetSelect: handleSelectAsset, + onHover: handleOnHover, +})} diff --git a/web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts b/web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts index 497654cea9..ed1456953f 100644 --- a/web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/PhotostreamManager.svelte.ts @@ -4,8 +4,7 @@ import { CancellableTask } from '$lib/utils/cancellable-task'; import { clamp, debounce } from 'lodash-es'; import type { PhotostreamSegment, SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; -import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; -import type { TimelineManagerLayoutOptions, TimelineManagerOptions, Viewport } from './types'; +import type { AssetDescriptor, TimelineAsset, TimelineManagerLayoutOptions, Viewport } from './types'; export abstract class PhotostreamManager { isInitialized = $state(false); @@ -30,8 +29,6 @@ export abstract class PhotostreamManager { () => void 0, ); - protected options: TimelineManagerOptions = {}; - #viewportHeight = $state(0); #viewportWidth = $state(0); #scrollTop = $state(0); @@ -188,10 +185,8 @@ export abstract class PhotostreamManager { } } - protected async init(options: TimelineManagerOptions) { - this.isInitialized = true; - // this.months = []; - + async init() { + this.isInitialized = false; await this.initTask.execute(async () => undefined, true); } @@ -209,7 +204,7 @@ export abstract class PhotostreamManager { } if (!this.initTask.executed) { - await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.init(this.options)); + await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.init()); } const changedWidth = viewport.width !== this.viewportWidth; @@ -252,28 +247,24 @@ export abstract class PhotostreamManager { return; } - if (segment.loader?.executed) { - return; - } - - const result = await segment.loader?.execute(async (signal: AbortSignal) => { - await this.fetchSegment(segment, signal); - }, cancelable); + const result = await segment.load(cancelable); if (result === 'LOADED') { updateIntersectionMonthGroup(this, segment); } } - getSegmentMatcher(identifier: SegmentIdentifier) { - return (segment: MonthGroup) => { - return identifier; - }; - } getSegmentByIdentifier(identifier: SegmentIdentifier) { return this.months.find((segment) => identifier.matches(segment)); } - protected abstract fetchSegment(segment: PhotostreamSegment, signal: AbortSignal): Promise; + getSegmentForAssetId(assetId: String) { + for (const month of this.months) { + const asset = month.assets.find((asset) => asset.id === assetId); + if (asset) { + return month; + } + } + } refreshLayout() { for (const month of this.months) { @@ -290,4 +281,18 @@ export abstract class PhotostreamManager { getMaxScroll() { return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight); } -} \ No newline at end of file + + async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) { + const range: TimelineAsset[] = []; + let collecting = false; + + for (const month of this.months) { + for (const asset of month.assets) { + if (asset.id === start.id) collecting = true; + if (collecting) range.push(asset); + if (asset.id === end.id) return range; + } + } + return range; + } +} diff --git a/web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts b/web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts index fc257ec3fc..5f30a2d296 100644 --- a/web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts +++ b/web/src/lib/managers/timeline-manager/PhotostreamSegment.svelte.ts @@ -5,6 +5,7 @@ import { get } from 'svelte/store'; import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; +import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; export type SegmentIdentifier = { matches(segment: PhotostreamSegment): boolean; @@ -16,10 +17,12 @@ export abstract class PhotostreamSegment { #height = $state(0); #top = $state(0); + #assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset)); initialCount = $state(0); percent = $state(0); - assetsCount = $derived(this.isLoaded ? this.getAssets().length : this.initialCount); + + assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount)); loader = new CancellableTask( () => this.markLoaded(), () => this.markCanceled, @@ -31,6 +34,8 @@ export abstract class PhotostreamSegment { abstract get identifier(): SegmentIdentifier; + abstract get id(): string; + get isLoaded() { return this.#isLoaded; } @@ -50,7 +55,7 @@ export abstract class PhotostreamSegment { } this.#intersecting = newValue; if (newValue) { - this.load(); + this.load(true); } else { this.cancel(); } @@ -60,9 +65,19 @@ export abstract class PhotostreamSegment { return this.#intersecting; } - abstract load(): Promise; + async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> { + return await this.loader?.execute(async (signal: AbortSignal) => { + await this.fetch(signal); + }, cancelable); + } - abstract getAssets(): TimelineAsset[]; + protected abstract fetch(signal: AbortSignal): Promise; + + get assets(): TimelineAsset[] { + return this.#assets; + } + + abstract get viewerAssets(): ViewerAsset[]; set height(height: number) { if (this.#height === height) { @@ -130,4 +145,6 @@ export abstract class PhotostreamSegment { this.intersecting = intersecting; this.actuallyIntersecting = actuallyIntersecting; } -} \ No newline at end of file + + abstract findAssetAbsolutePosition(assetId: string): number; +} diff --git a/web/src/lib/managers/timeline-manager/SearchStreamManager.svelte.ts b/web/src/lib/managers/timeline-manager/SearchStreamManager.svelte.ts new file mode 100644 index 0000000000..0571c7ce9f --- /dev/null +++ b/web/src/lib/managers/timeline-manager/SearchStreamManager.svelte.ts @@ -0,0 +1,45 @@ +import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; +import { SearchStreamSegment } from '$lib/managers/timeline-manager/SearchStreamSegment.svelte'; +import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; + +export type SearchTerms = MetadataSearchDto & Pick & { isVisible: boolean }; + +export class SearchStreamManager extends PhotostreamManager { + #isSmartSearchEnabled: boolean; + + #searchTerms: SearchTerms; + #months: SearchStreamSegment[] = $state([]); + + constructor(searchTerms: SearchTerms, options: { isSmartSearchEnabled: boolean }) { + super(); + this.#searchTerms = searchTerms; + this.#isSmartSearchEnabled = options.isSmartSearchEnabled; + } + + async init() { + this.isInitialized = false; + await this.initTask.execute(async () => { + // add some months to start the searches + for (let i = 1; i < 3; i++) { + this.#months.push(new SearchStreamSegment(this, { ...this.#searchTerms, page: i })); + } + }, true); + + this.updateViewportGeometry(false); + } + + get months(): SearchStreamSegment[] { + return this.#months; + } + + get isSmartSearchEnabled() { + return this.#isSmartSearchEnabled; + } + + loadNextPage() { + debugger; + // note: pages are 1-based + this.#months.push(new SearchStreamSegment(this, { ...this.#searchTerms, page: this.#months.length + 1 })); + this.updateViewportGeometry(false); + } +} diff --git a/web/src/lib/managers/timeline-manager/SearchStreamSegment.svelte.ts b/web/src/lib/managers/timeline-manager/SearchStreamSegment.svelte.ts new file mode 100644 index 0000000000..87e7a2ad65 --- /dev/null +++ b/web/src/lib/managers/timeline-manager/SearchStreamSegment.svelte.ts @@ -0,0 +1,99 @@ +import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; +import type { SearchStreamManager, SearchTerms } from '$lib/managers/timeline-manager/SearchStreamManager.svelte'; +import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; +import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils'; +import { toTimelineAsset } from '$lib/utils/timeline-util'; +import { searchAssets, searchSmart } from '@immich/sdk'; +import { isEqual } from 'lodash-es'; + +export class SearchStreamSegment extends PhotostreamSegment { + manager: SearchStreamManager; + #identifier: SegmentIdentifier; + #id: string; + #searchTerms: SearchTerms; + + #viewerAssets: ViewerAsset[] = $state([]); + + constructor(manager: SearchStreamManager, searchTerms: SearchTerms) { + super(); + this.manager = manager; + this.#searchTerms = searchTerms; + this.#id = JSON.stringify(searchTerms); + this.#identifier = { + matches(segment: SearchStreamSegment) { + return isEqual(segment.#searchTerms, searchTerms); + }, + }; + this.initialCount = searchTerms.size || 100; + } + + get timelineManager(): SearchStreamManager { + return this.manager; + } + + get identifier(): SegmentIdentifier { + return this.#identifier; + } + + get id(): string { + return this.#id; + } + + async fetch(signal: AbortSignal): Promise { + const searchDto: SearchTerms = { + ...this.#searchTerms, + withExif: true, + isVisible: true, + }; + + const { assets } = + ('query' in searchDto || 'queryAssetId' in searchDto) && this.manager.isSmartSearchEnabled + ? await searchSmart({ smartSearchDto: searchDto }, { signal }) + : await searchAssets({ metadataSearchDto: searchDto }, { signal }); + this.#viewerAssets = assets.items.map((asset) => new ViewerAsset(this, toTimelineAsset(asset))); + this.layout(); + } + + layout(): void { + const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset); + const rowWidth = Math.floor(this.timelineManager.viewportWidth); + const rowHeight = rowWidth < 850 ? 100 : 235; + + const geometry = getJustifiedLayoutFromAssets(timelineAssets, { + spacing: 2, + heightTolerance: 0.15, + rowHeight, + rowWidth, + }); + // this.width = geometry.containerWidth; + this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight; + for (let i = 0; i < this.#viewerAssets.length; i++) { + const position = getPosition(geometry, i); + this.#viewerAssets[i].position = position; + } + } + + get viewerAssets(): ViewerAsset[] { + return this.#viewerAssets; + } + + findAssetAbsolutePosition(assetId: string) { + const viewerAsset = this.#viewerAssets.find((viewAsset) => viewAsset.id === assetId); + if (viewerAsset) { + if (!viewerAsset.position) { + console.warn('No position for asset'); + return -1; + } + return this.top + viewerAsset.position.top + this.timelineManager.headerHeight; + } + return -1; + } + + updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) { + super.updateIntersection({ intersecting, actuallyIntersecting }); + // if we're the last month, try to load next month + if (intersecting && this.timelineManager.months[this.timelineManager.months.length - 1] === this) { + this.timelineManager.loadNextPage(); + } + } +} diff --git a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts index 6ee7bf9320..4980d5f3d4 100644 --- a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts @@ -1,7 +1,6 @@ import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import { TUNABLES } from '$lib/utils/tunables'; -import type { TimelineManager } from '../timeline-manager.svelte'; const { TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, @@ -55,7 +54,7 @@ export function calculateSegmentIntersecting( * Calculate intersection for viewer assets with additional parameters like header height and scroll compensation */ export function calculateViewerAssetIntersecting( - timelineManager: TimelineManager, + timelineManager: PhotostreamManager, positionTop: number, positionHeight: number, expandTop: number = INTERSECTION_EXPAND_TOP, diff --git a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts index 70ffea2c49..60efdcca88 100644 --- a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts @@ -1,5 +1,6 @@ import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; +import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { MonthGroup } from '../month-group.svelte'; import type { UpdateGeometryOptions } from '../types'; @@ -25,7 +26,7 @@ export function updateGeometry( month.layout(noDefer); } -export function layoutMonthGroup(timelineManager: PhotostreamManager, month: MonthGroup, noDefer: boolean = false) { +export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) { let cumulativeHeight = 0; let cumulativeWidth = 0; let currentRowHeight = 0; diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 45cbeaba7a..50f85f5f1a 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -15,6 +15,7 @@ import { } from '$lib/utils/timeline-util'; import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; +import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import { SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; @@ -64,11 +65,10 @@ export class MonthGroup extends PhotostreamSegment { return this.#yearMonth; } - load(): Promise { - return this.timelineManager.loadSegment(this.#identifier); + fetch(signal: AbortSignal): Promise { + return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal); } - get lastDayGroup() { return this.dayGroups.at(-1); } @@ -77,9 +77,9 @@ export class MonthGroup extends PhotostreamSegment { return this.dayGroups[0]?.getFirstAsset(); } - getAssets() { + get viewerAssets() { // eslint-disable-next-line unicorn/no-array-reduce - return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []); + return this.dayGroups.reduce((accumulator: ViewerAsset[], g: DayGroup) => accumulator.concat(g.viewerAssets), []); } sortDayGroups() { @@ -221,12 +221,15 @@ export class MonthGroup extends PhotostreamSegment { return this.getRandomDayGroup()?.getRandomAsset()?.asset; } + get id() { + return this.viewId; + } + get viewId() { const { year, month } = this.yearMonth; return year + '-' + month; } - findDayGroupForAsset(asset: TimelineAsset) { for (const group of this.dayGroups) { if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) { 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 55edaafe86..1640cb79a1 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 @@ -128,10 +128,10 @@ describe('TimelineManager', () => { }); it('loads a month', async () => { - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3); }); it('ignores invalid months', async () => { @@ -146,7 +146,7 @@ describe('TimelineManager', () => { month?.cancel(); expect(abortSpy).toBeCalledTimes(1); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3); }); it('prevents loading months multiple times', async () => { @@ -166,10 +166,10 @@ describe('TimelineManager', () => { month.cancel(); await loadPromise; - expect(month?.getAssets().length).toEqual(0); + expect(month?.assets.length).toEqual(0); await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); - expect(month!.getAssets().length).toEqual(3); + expect(month!.assets.length).toEqual(3); }); }); @@ -198,7 +198,7 @@ describe('TimelineManager', () => { expect(timelineManager.months.length).toEqual(1); expect(timelineManager.assetCount).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(1); + expect(timelineManager.months[0].assets.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); @@ -215,7 +215,7 @@ describe('TimelineManager', () => { expect(timelineManager.months.length).toEqual(1); expect(timelineManager.assetCount).toEqual(2); - expect(timelineManager.months[0].getAssets().length).toEqual(2); + expect(timelineManager.months[0].assets.length).toEqual(2); expect(timelineManager.months[0].yearMonth.year).toEqual(2024); expect(timelineManager.months[0].yearMonth.month).toEqual(1); }); @@ -240,10 +240,10 @@ describe('TimelineManager', () => { const month = getMonthGroupByDate(timelineManager, { 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', () => { @@ -341,14 +341,14 @@ describe('TimelineManager', () => { timelineManager.addAssets([asset]); expect(timelineManager.months.length).toEqual(1); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1); timelineManager.updateAssets([updatedAsset]); expect(timelineManager.months.length).toEqual(2); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); - expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); + expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1); }); }); @@ -374,7 +374,7 @@ describe('TimelineManager', () => { expect(timelineManager.assetCount).toEqual(2); expect(timelineManager.months.length).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(2); + expect(timelineManager.months[0].assets.length).toEqual(2); }); it('removes asset from month', () => { @@ -388,7 +388,7 @@ describe('TimelineManager', () => { expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.months.length).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(1); + expect(timelineManager.months[0].assets.length).toEqual(1); }); it('does not remove month when empty', () => { @@ -480,8 +480,8 @@ describe('TimelineManager', () => { await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 }); - const a = month!.getAssets()[0]; - const b = month!.getAssets()[1]; + const a = month!.assets[0]; + const b = month!.assets[1]; const previous = await timelineManager.getLaterAsset(b); expect(previous).toEqual(a); }); @@ -492,8 +492,8 @@ describe('TimelineManager', () => { const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 }); const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 }); - const a = month!.getAssets()[0]; - const b = previousMonth!.getAssets()[0]; + const a = month!.assets[0]; + const b = previousMonth!.assets[0]; const previous = await timelineManager.getLaterAsset(a); expect(previous).toEqual(b); }); 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 f7d2b560f3..67d69cdc4b 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -14,7 +14,6 @@ import { isEqual } from 'lodash-es'; import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; -import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; import { addAssetsToMonthGroups, runAssetOperation, @@ -28,7 +27,6 @@ import { } from '$lib/managers/timeline-manager/internal/search-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte'; -import { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import { DayGroup } from './day-group.svelte'; import { isMismatched, updateObject } from './internal/utils.svelte'; import { MonthGroup } from './month-group.svelte'; @@ -71,6 +69,10 @@ export class TimelineManager extends PhotostreamManager { return this.#months; } + get options() { + return this.#options; + } + async *assetsIterator(options?: { startMonthGroup?: MonthGroup; startDayGroup?: DayGroup; @@ -144,16 +146,17 @@ export class TimelineManager extends PhotostreamManager { return; } await this.initTask.reset(); - await this.init(options); + this.#options = options; + await this.init(); this.updateViewportGeometry(false); } - async init(options: TimelineManagerOptions) { + async init() { + this.isInitialized = false; this.#months = []; this.albumAssets.clear(); await this.initTask.execute(async () => { - this.#options = options; await this.#initializeMonthGroups(); }, true); } @@ -179,10 +182,6 @@ export class TimelineManager extends PhotostreamManager { this.scrubberTimelineHeight = this.timelineHeight; } - protected fetchSegment(segment: PhotostreamSegment, signal: AbortSignal): Promise { - return loadFromTimeBuckets(this, segment as MonthGroup, this.#options, signal); - } - addAssets(assets: TimelineAsset[]) { const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset)); const notUpdated = this.updateAssets(assetsToUpdate); diff --git a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts index b6e28df576..916f6fd9ca 100644 --- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts +++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts @@ -1,3 +1,4 @@ +import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte'; import type { CommonPosition } from '$lib/utils/layout-utils'; import type { DayGroup } from './day-group.svelte'; @@ -5,16 +6,21 @@ import { calculateViewerAssetIntersecting } from './internal/intersection-suppor import type { TimelineAsset } from './types'; export class ViewerAsset { - readonly #group: DayGroup; + readonly #group: DayGroup | PhotostreamSegment; intersecting = $derived.by(() => { if (!this.position) { return false; } + if ((this.#group as DayGroup).sortAssets) { + const dayGroup = this.#group as DayGroup; + const store = dayGroup.monthGroup.timelineManager; + const positionTop = dayGroup.absoluteDayGroupTop + this.position.top; + return calculateViewerAssetIntersecting(store, positionTop, this.position.height); + } - const store = this.#group.monthGroup.timelineManager; - const positionTop = this.#group.absoluteDayGroupTop + this.position.top; - + const store = (this.#group as PhotostreamSegment).timelineManager; + const positionTop = this.position.top + (this.#group as PhotostreamSegment).top; return calculateViewerAssetIntersecting(store, positionTop, this.position.height); }); @@ -22,7 +28,7 @@ export class ViewerAsset { asset: TimelineAsset = $state(); id: string = $derived(this.asset.id); - constructor(group: DayGroup, asset: TimelineAsset) { + constructor(group: DayGroup | PhotostreamSegment, asset: TimelineAsset) { this.#group = group; this.asset = asset; } diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 86fb0850af..7e0cf10c00 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -96,6 +96,7 @@ >
{item.value} { - if (!nextPage || (isLoading && !force)) { - return; - } - isLoading = true; + const searchTerms = $derived.by(() => ({ + withExif: true, + isVisible: true, + language: $lang, + ...terms, + })); - const searchDto: SearchTerms = { - page: nextPage, - withExif: true, - isVisible: true, - language: $lang, - ...terms, - }; + // // eslint-disable-next-line svelte/valid-prop-names-in-kit-pages + // export const loadNextPage = async (force?: boolean) => { + // if (!nextPage || (isLoading && !force)) { + // return; + // } + // isLoading = true; - try { - const { albums, assets } = - ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled - ? await searchSmart({ smartSearchDto: searchDto }) - : await searchAssets({ metadataSearchDto: searchDto }); + // const searchDto: SearchTerms = { + // page: nextPage, + // withExif: true, + // isVisible: true, + // language: $lang, + // ...terms, + // }; - searchResultAlbums.push(...albums.items); - searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset))); + // try { + // const { albums, assets } = + // ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled + // ? await searchSmart({ smartSearchDto: searchDto }) + // : await searchAssets({ metadataSearchDto: searchDto }); - nextPage = Number(assets.nextPage) || 0; - } catch (error) { - handleError(error, $t('loading_search_results_failed')); - } finally { - isLoading = false; - } - }; + // searchResultAlbums.push(...albums.items); + // searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset))); + + // nextPage = Number(assets.nextPage) || 0; + // } catch (error) { + // handleError(error, $t('loading_search_results_failed')); + // } finally { + // isLoading = false; + // } + // }; function getHumanReadableDate(dateString: string) { const date = parseUtcDate(dateString).startOf('day'); @@ -359,7 +363,7 @@ {/if}
{/if} -
- {#if searchResultAssets.length > 0} +
+ {#key searchTerms} + + {/key} +