mirror of
https://github.com/immich-app/immich.git
synced 2025-12-14 01:10:38 +03:00
Compare commits
3 Commits
revert-sve
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc790604aa | ||
|
|
14dad831ee | ||
|
|
fb5a0089af |
@@ -11,7 +11,7 @@
|
|||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.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 { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { getSegmentIdentifier, 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 { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
// Indicates whether the viewport is currently in the lead-out section (after all months)
|
||||||
let isInLeadOutSection = $state(false);
|
let isInLeadOutSection = $state(false);
|
||||||
|
|
||||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0);
|
||||||
const maxMd = $derived(mobileDevice.maxMd);
|
const maxMd = $derived(mobileDevice.maxMd);
|
||||||
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
// handle any scroll compensation that may have been set
|
// handle any scroll compensation that may have been set
|
||||||
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
const height = monthGroup!.findAssetAbsolutePosition(assetId);
|
||||||
|
|
||||||
while (timelineManager.scrollCompensation.monthGroup) {
|
while (timelineManager.scrollCompensation.segment) {
|
||||||
handleScrollCompensation(timelineManager.scrollCompensation);
|
handleScrollCompensation(timelineManager.scrollCompensation);
|
||||||
timelineManager.clearScrollCompensation();
|
timelineManager.clearScrollCompensation();
|
||||||
}
|
}
|
||||||
@@ -267,11 +267,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getMaxScrollPercent = () => {
|
|
||||||
const totalHeight = timelineManager.timelineHeight + bottomSectionHeight + timelineManager.topSectionHeight;
|
|
||||||
return (totalHeight - timelineManager.viewportHeight) / totalHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMaxScroll = () => {
|
const getMaxScroll = () => {
|
||||||
if (!element || !timelineElement) {
|
if (!element || !timelineElement) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -283,7 +278,7 @@
|
|||||||
|
|
||||||
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
|
const scrollToMonthGroupAndOffset = (monthGroup: MonthGroup, monthGroupScrollPercent: number) => {
|
||||||
const topOffset = monthGroup.top;
|
const topOffset = monthGroup.top;
|
||||||
const maxScrollPercent = getMaxScrollPercent();
|
const maxScrollPercent = timelineManager.maxScrollPercent;
|
||||||
const delta = monthGroup.height * monthGroupScrollPercent;
|
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||||
|
|
||||||
@@ -295,13 +290,13 @@
|
|||||||
const onScrub: ScrubberListener = (scrubberData) => {
|
const onScrub: ScrubberListener = (scrubberData) => {
|
||||||
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData;
|
||||||
|
|
||||||
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
if (!scrubberMonth || timelineManager.streamViewerHeight < timelineManager.viewportHeight * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
const offset = maxScroll * overallScrollPercent;
|
const offset = maxScroll * overallScrollPercent;
|
||||||
scrollTop(offset);
|
scrollTop(offset);
|
||||||
} else {
|
} else {
|
||||||
const monthGroup = timelineManager.months.find(
|
const monthGroup = timelineManager.segments.find(
|
||||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||||
);
|
);
|
||||||
if (!monthGroup) {
|
if (!monthGroup) {
|
||||||
@@ -319,7 +314,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
if (timelineManager.streamViewerHeight < timelineManager.viewportHeight * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||||
@@ -338,10 +333,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxScrollPercent = getMaxScrollPercent();
|
let maxScrollPercent = timelineManager.maxScrollPercent;
|
||||||
let found = false;
|
let found = false;
|
||||||
|
|
||||||
const monthsLength = timelineManager.months.length;
|
const monthsLength = timelineManager.segments.length;
|
||||||
for (let i = -1; i < monthsLength + 1; i++) {
|
for (let i = -1; i < monthsLength + 1; i++) {
|
||||||
let monthGroup: TimelineYearMonth | undefined;
|
let monthGroup: TimelineYearMonth | undefined;
|
||||||
let monthGroupHeight = 0;
|
let monthGroupHeight = 0;
|
||||||
@@ -352,8 +347,8 @@
|
|||||||
// lead-out
|
// lead-out
|
||||||
monthGroupHeight = bottomSectionHeight;
|
monthGroupHeight = bottomSectionHeight;
|
||||||
} else {
|
} else {
|
||||||
monthGroup = timelineManager.months[i].yearMonth;
|
monthGroup = timelineManager.segments[i].yearMonth;
|
||||||
monthGroupHeight = timelineManager.months[i].height;
|
monthGroupHeight = timelineManager.segments[i].height;
|
||||||
}
|
}
|
||||||
|
|
||||||
let next = top - monthGroupHeight * maxScrollPercent;
|
let next = top - monthGroupHeight * maxScrollPercent;
|
||||||
@@ -366,7 +361,7 @@
|
|||||||
|
|
||||||
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
// compensate for lost precision/rounding errors advance to the next bucket, if present
|
||||||
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) {
|
||||||
viewportTopMonth = timelineManager.months[i + 1].yearMonth;
|
viewportTopMonth = timelineManager.segments[i + 1].yearMonth;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,12 +468,12 @@
|
|||||||
|
|
||||||
// Select/deselect assets in range (start,end)
|
// Select/deselect assets in range (start,end)
|
||||||
let started = false;
|
let started = false;
|
||||||
for (const monthGroup of timelineManager.months) {
|
for (const monthGroup of timelineManager.segments) {
|
||||||
if (monthGroup === endBucket) {
|
if (monthGroup === endBucket) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (started) {
|
if (started) {
|
||||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
await timelineManager.loadSegment(monthGroup.identifier);
|
||||||
for (const asset of monthGroup.assetsIterator()) {
|
for (const asset of monthGroup.assetsIterator()) {
|
||||||
if (deselect) {
|
if (deselect) {
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
||||||
@@ -494,7 +489,7 @@
|
|||||||
|
|
||||||
// Update date group selection in range [start,end]
|
// Update date group selection in range [start,end]
|
||||||
started = false;
|
started = false;
|
||||||
for (const monthGroup of timelineManager.months) {
|
for (const monthGroup of timelineManager.segments) {
|
||||||
if (monthGroup === startBucket) {
|
if (monthGroup === startBucket) {
|
||||||
started = true;
|
started = true;
|
||||||
}
|
}
|
||||||
@@ -553,7 +548,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
||||||
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
|
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -570,7 +565,7 @@
|
|||||||
{onEscape}
|
{onEscape}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if timelineManager.months.length > 0}
|
{#if timelineManager.segments.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
{timelineManager}
|
{timelineManager}
|
||||||
height={timelineManager.viewportHeight}
|
height={timelineManager.viewportHeight}
|
||||||
@@ -615,7 +610,7 @@
|
|||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
class:invisible={showSkeleton}
|
class:invisible={showSkeleton}
|
||||||
style:height={timelineManager.timelineHeight + 'px'}
|
style:height={timelineManager.streamViewerHeight + 'px'}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
use:resizeObserver={topSectionResizeObserver}
|
||||||
@@ -631,11 +626,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
|
{#each timelineManager.segments as monthGroup (monthGroup.identifier.id)}
|
||||||
{@const display = monthGroup.intersecting}
|
{@const display = monthGroup.intersecting}
|
||||||
{@const absoluteHeight = monthGroup.top}
|
{@const absoluteHeight = monthGroup.top}
|
||||||
|
|
||||||
{#if !monthGroup.isLoaded}
|
{#if !monthGroup.loaded}
|
||||||
<div
|
<div
|
||||||
style:height={monthGroup.height + 'px'}
|
style:height={monthGroup.height + 'px'}
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
@@ -643,7 +638,7 @@
|
|||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Skeleton
|
<Skeleton
|
||||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
height={monthGroup.height - monthGroup.assetStreamManager.headerHeight}
|
||||||
title={monthGroup.monthGroupTitle}
|
title={monthGroup.monthGroupTitle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -679,7 +674,7 @@
|
|||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
|
style:transform={`translate3d(0,${timelineManager.streamViewerHeight}px,0)`}
|
||||||
></div>
|
></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
|
|
||||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
@@ -67,7 +66,7 @@
|
|||||||
let hoveredDayGroup = $state();
|
let hoveredDayGroup = $state();
|
||||||
|
|
||||||
const transitionDuration = $derived.by(() =>
|
const transitionDuration = $derived.by(() =>
|
||||||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
monthGroup.assetStreamManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||||
);
|
);
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
const _onClick = (
|
const _onClick = (
|
||||||
@@ -125,18 +124,8 @@
|
|||||||
return intersectable.filter((int) => int.intersecting);
|
return intersectable.filter((int) => int.intersecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
|
|
||||||
const { month, year } = dayGroup.monthGroup.yearMonth;
|
|
||||||
const date = fromTimelinePlainDate({
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
day: dayGroup.day,
|
|
||||||
});
|
|
||||||
return getDateLocaleString(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
|
if (timelineManager.scrollCompensation.segment === monthGroup) {
|
||||||
onScrollCompensation(timelineManager.scrollCompensation);
|
onScrollCompensation(timelineManager.scrollCompensation);
|
||||||
timelineManager.clearScrollCompensation();
|
timelineManager.clearScrollCompensation();
|
||||||
}
|
}
|
||||||
@@ -149,8 +138,8 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<section
|
<section
|
||||||
class={[
|
class={[
|
||||||
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
|
{ 'transition-all': !monthGroup.assetStreamManager.suspendTransitions },
|
||||||
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
|
!monthGroup.assetStreamManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||||
]}
|
]}
|
||||||
data-group
|
data-group
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
@@ -185,7 +174,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
|
<span class="w-full truncate first-letter:capitalize" title={dayGroup.groupTitleFull}>
|
||||||
{dayGroup.groupTitle}
|
{dayGroup.groupTitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,9 +217,9 @@
|
|||||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
dayGroup.monthGroup.assetStreamManager.albumAssets.has(asset.id)}
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
disabled={dayGroup.monthGroup.assetStreamManager.albumAssets.has(asset.id)}
|
||||||
thumbnailWidth={position.width}
|
thumbnailWidth={position.width}
|
||||||
thumbnailHeight={position.height}
|
thumbnailHeight={position.height}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0);
|
||||||
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||||
let isShortcutModalOpen = false;
|
let isShortcutModalOpen = false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import type { AssetStreamSegment, SegmentIdentifier } from '$lib/managers/AssetStreamManager/AssetStreamSegment.svelte';
|
||||||
|
import type { AssetDescriptor, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { CancellableTask, TaskStatus } from '$lib/utils/cancellable-task';
|
||||||
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
|
import { clamp, debounce } from 'lodash-es';
|
||||||
|
|
||||||
|
const {
|
||||||
|
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||||
|
} = TUNABLES;
|
||||||
|
|
||||||
|
export abstract class AssetStreamManager {
|
||||||
|
isInitialized = $state(false);
|
||||||
|
topSectionHeight = $state(0);
|
||||||
|
bottomSectionHeight = $state(60);
|
||||||
|
|
||||||
|
streamViewerHeight = $derived.by(
|
||||||
|
() => this.segments.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight,
|
||||||
|
);
|
||||||
|
assetCount = $derived.by(() => this.segments.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
||||||
|
|
||||||
|
topIntersectingSegment: AssetStreamSegment | undefined = $state();
|
||||||
|
|
||||||
|
visibleWindow = $derived.by(() => ({
|
||||||
|
top: this.#scrollTop,
|
||||||
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
protected initTask = new CancellableTask(
|
||||||
|
() => (this.isInitialized = true),
|
||||||
|
() => (this.isInitialized = false),
|
||||||
|
() => void 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
#viewportHeight = $state(0);
|
||||||
|
#viewportWidth = $state(0);
|
||||||
|
#scrollTop = $state(0);
|
||||||
|
|
||||||
|
#rowHeight = $state(235);
|
||||||
|
#headerHeight = $state(48);
|
||||||
|
#gap = $state(12);
|
||||||
|
|
||||||
|
#scrolling = $state(false);
|
||||||
|
#suspendTransitions = $state(false);
|
||||||
|
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||||
|
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||||
|
scrollCompensation: {
|
||||||
|
heightDelta: number | undefined;
|
||||||
|
scrollTop: number | undefined;
|
||||||
|
segment: AssetStreamSegment | undefined;
|
||||||
|
} = $state({
|
||||||
|
heightDelta: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
segment: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }) {
|
||||||
|
let changed = false;
|
||||||
|
changed ||= this.#setHeaderHeight(headerHeight);
|
||||||
|
changed ||= this.#setGap(gap);
|
||||||
|
changed ||= this.#setRowHeight(rowHeight);
|
||||||
|
if (changed) {
|
||||||
|
this.refreshLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract get segments(): AssetStreamSegment[];
|
||||||
|
|
||||||
|
get maxScrollPercent() {
|
||||||
|
const totalHeight = this.streamViewerHeight + this.bottomSectionHeight + this.topSectionHeight;
|
||||||
|
return (totalHeight - this.viewportHeight) / totalHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
get maxScroll() {
|
||||||
|
return this.topSectionHeight + this.bottomSectionHeight + (this.streamViewerHeight - this.viewportHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
#setHeaderHeight(value: number) {
|
||||||
|
if (this.#headerHeight == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#headerHeight = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get headerHeight() {
|
||||||
|
return this.#headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setGap(value: number) {
|
||||||
|
if (this.#gap == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#gap = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get gap() {
|
||||||
|
return this.#gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setRowHeight(value: number) {
|
||||||
|
if (this.#rowHeight == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#rowHeight = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rowHeight() {
|
||||||
|
return this.#rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
set scrolling(value: boolean) {
|
||||||
|
this.#scrolling = value;
|
||||||
|
if (value) {
|
||||||
|
this.suspendTransitions = true;
|
||||||
|
this.#resetScrolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get scrolling() {
|
||||||
|
return this.#scrolling;
|
||||||
|
}
|
||||||
|
|
||||||
|
set suspendTransitions(value: boolean) {
|
||||||
|
this.#suspendTransitions = value;
|
||||||
|
if (value) {
|
||||||
|
this.#resetSuspendTransitions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get suspendTransitions() {
|
||||||
|
return this.#suspendTransitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
set viewportWidth(value: number) {
|
||||||
|
const changed = value !== this.#viewportWidth;
|
||||||
|
this.#viewportWidth = value;
|
||||||
|
this.suspendTransitions = true;
|
||||||
|
void this.updateViewportGeometry(changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewportWidth() {
|
||||||
|
return this.#viewportWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
set viewportHeight(value: number) {
|
||||||
|
this.#viewportHeight = value;
|
||||||
|
this.#suspendTransitions = true;
|
||||||
|
void this.updateViewportGeometry(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewportHeight() {
|
||||||
|
return this.#viewportHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasEmptyViewport() {
|
||||||
|
return this.viewportWidth === 0 || this.viewportHeight === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSlidingWindow(scrollTop: number) {
|
||||||
|
if (this.#scrollTop !== scrollTop) {
|
||||||
|
this.#scrollTop = scrollTop;
|
||||||
|
this.updateIntersections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScrollCompensation() {
|
||||||
|
this.scrollCompensation = {
|
||||||
|
heightDelta: undefined,
|
||||||
|
scrollTop: undefined,
|
||||||
|
segment: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIntersections() {
|
||||||
|
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let topIntersectingSegment = undefined;
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
this.updateSegmentIntersections(segment);
|
||||||
|
if (!topIntersectingSegment && segment.actuallyIntersecting) {
|
||||||
|
topIntersectingSegment = segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (topIntersectingSegment !== undefined && this.topIntersectingSegment !== topIntersectingSegment) {
|
||||||
|
this.topIntersectingSegment = topIntersectingSegment;
|
||||||
|
}
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
if (segment === this.topIntersectingSegment) {
|
||||||
|
this.topIntersectingSegment.percent = clamp(
|
||||||
|
(this.visibleWindow.top - this.topIntersectingSegment.top) / this.topIntersectingSegment.height,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
segment.percent = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.isInitialized = false;
|
||||||
|
await this.initTask.execute(() => Promise.resolve(undefined), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateViewportGeometry(changedWidth: boolean) {
|
||||||
|
if (!this.isInitialized || this.hasEmptyViewport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
segment.updateGeometry({ invalidateHeight: changedWidth });
|
||||||
|
}
|
||||||
|
this.updateIntersections();
|
||||||
|
}
|
||||||
|
|
||||||
|
createLayoutOptions() {
|
||||||
|
const viewportWidth = this.viewportWidth;
|
||||||
|
|
||||||
|
return {
|
||||||
|
spacing: 2,
|
||||||
|
heightTolerance: 0.15,
|
||||||
|
rowHeight: this.#rowHeight,
|
||||||
|
rowWidth: Math.floor(viewportWidth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
|
||||||
|
const { cancelable = true } = options ?? {};
|
||||||
|
const segment = this.segments.find((segment) => identifier.matches(segment));
|
||||||
|
if (!segment || segment.loader?.executed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await segment.load(cancelable);
|
||||||
|
if (result === TaskStatus.LOADED) {
|
||||||
|
this.updateSegmentIntersections(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSegmentForAssetId(assetId: string) {
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
const asset = segment.assets.find((asset) => asset.id === assetId);
|
||||||
|
if (asset) {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshLayout() {
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
segment.updateGeometry({ invalidateHeight: true });
|
||||||
|
}
|
||||||
|
this.updateIntersections();
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
||||||
|
const range: TimelineAsset[] = [];
|
||||||
|
let collecting = false;
|
||||||
|
|
||||||
|
for (const segment of this.segments) {
|
||||||
|
for (const asset of segment.assets) {
|
||||||
|
if (asset.id === start.id) {
|
||||||
|
collecting = true;
|
||||||
|
}
|
||||||
|
if (collecting) {
|
||||||
|
range.push(asset);
|
||||||
|
}
|
||||||
|
if (asset.id === end.id) {
|
||||||
|
return Promise.resolve(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected calculateSegmentIntersecting(segment: AssetStreamSegment, expandTop: number, expandBottom: number) {
|
||||||
|
const monthGroupTop = segment.top;
|
||||||
|
const monthGroupBottom = monthGroupTop + segment.height;
|
||||||
|
const topWindow = this.visibleWindow.top - expandTop;
|
||||||
|
const bottomWindow = this.visibleWindow.bottom + expandBottom;
|
||||||
|
|
||||||
|
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateSegmentIntersections(segment: AssetStreamSegment) {
|
||||||
|
const actuallyIntersecting = this.calculateSegmentIntersecting(segment, 0, 0);
|
||||||
|
let preIntersecting = false;
|
||||||
|
if (!actuallyIntersecting) {
|
||||||
|
preIntersecting = this.calculateSegmentIntersecting(segment, INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM);
|
||||||
|
}
|
||||||
|
segment.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General function to check if a rectangular region intersects with a window.
|
||||||
|
* @param regionTop - Top position of the region to check
|
||||||
|
* @param regionBottom - Bottom position of the region to check
|
||||||
|
* @param windowTop - Top position of the window
|
||||||
|
* @param windowBottom - Bottom position of the window
|
||||||
|
* @returns true if the region intersects with the window
|
||||||
|
*/
|
||||||
|
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
|
||||||
|
return (
|
||||||
|
(regionTop >= windowTop && regionTop < windowBottom) ||
|
||||||
|
(regionBottom >= windowTop && regionBottom < windowBottom) ||
|
||||||
|
(regionTop < windowTop && regionBottom >= windowBottom)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type { AssetStreamManager } from '$lib/managers/AssetStreamManager/AssetStreamManager.svelte';
|
||||||
|
import type { TimelineAsset, UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
|
||||||
|
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||||
|
import { CancellableTask, TaskStatus } from '$lib/utils/cancellable-task';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export type SegmentIdentifier = {
|
||||||
|
get id(): string;
|
||||||
|
matches(segment: AssetStreamSegment): boolean;
|
||||||
|
};
|
||||||
|
export abstract class AssetStreamSegment {
|
||||||
|
#intersecting = $state(false);
|
||||||
|
#actuallyIntersecting = $state(false);
|
||||||
|
#isLoaded = $state(false);
|
||||||
|
|
||||||
|
#height = $state(0);
|
||||||
|
#top = $state(0);
|
||||||
|
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
|
||||||
|
|
||||||
|
initialCount = $state(0);
|
||||||
|
percent = $state(0);
|
||||||
|
|
||||||
|
assetsCount = $derived.by(() => (this.loaded ? this.viewerAssets.length : this.initialCount));
|
||||||
|
loader = new CancellableTask(
|
||||||
|
() => (this.loaded = true),
|
||||||
|
() => (this.loaded = false),
|
||||||
|
() => this.handleLoadError,
|
||||||
|
);
|
||||||
|
isHeightActual = $state(false);
|
||||||
|
|
||||||
|
abstract get assetStreamManager(): AssetStreamManager;
|
||||||
|
|
||||||
|
abstract get identifier(): SegmentIdentifier;
|
||||||
|
|
||||||
|
abstract get viewerAssets(): ViewerAsset[];
|
||||||
|
|
||||||
|
abstract findAssetAbsolutePosition(assetId: string): number;
|
||||||
|
|
||||||
|
protected abstract fetch(signal: AbortSignal): Promise<void>;
|
||||||
|
|
||||||
|
get loaded() {
|
||||||
|
return this.#isLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected set loaded(newValue: boolean) {
|
||||||
|
this.#isLoaded = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
set intersecting(newValue: boolean) {
|
||||||
|
const old = this.#intersecting;
|
||||||
|
if (old === newValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#intersecting = newValue;
|
||||||
|
if (newValue) {
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get intersecting() {
|
||||||
|
return this.#intersecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
get actuallyIntersecting() {
|
||||||
|
return this.#actuallyIntersecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
get assets(): TimelineAsset[] {
|
||||||
|
return this.#assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
set height(height: number) {
|
||||||
|
if (this.#height === height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { assetStreamManager: store, percent } = this;
|
||||||
|
const index = store.segments.indexOf(this);
|
||||||
|
const heightDelta = height - this.#height;
|
||||||
|
this.#height = height;
|
||||||
|
const prevSegment = store.segments[index - 1];
|
||||||
|
if (prevSegment) {
|
||||||
|
const newTop = prevSegment.#top + prevSegment.#height;
|
||||||
|
if (this.#top !== newTop) {
|
||||||
|
this.#top = newTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let cursor = index + 1; cursor < store.segments.length; cursor++) {
|
||||||
|
const segment = this.assetStreamManager.segments[cursor];
|
||||||
|
const newTop = segment.#top + heightDelta;
|
||||||
|
if (segment.#top !== newTop) {
|
||||||
|
segment.#top = newTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (store.topIntersectingSegment) {
|
||||||
|
const currentIndex = store.segments.indexOf(store.topIntersectingSegment);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
if (index < currentIndex) {
|
||||||
|
store.scrollCompensation = {
|
||||||
|
heightDelta,
|
||||||
|
scrollTop: undefined,
|
||||||
|
segment: this,
|
||||||
|
};
|
||||||
|
} else if (percent > 0) {
|
||||||
|
const top = this.top + height * percent;
|
||||||
|
store.scrollCompensation = {
|
||||||
|
heightDelta: undefined,
|
||||||
|
scrollTop: top,
|
||||||
|
segment: this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get height() {
|
||||||
|
return this.#height;
|
||||||
|
}
|
||||||
|
|
||||||
|
get top(): number {
|
||||||
|
return this.#top + this.assetStreamManager.topSectionHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(cancelable: boolean): Promise<TaskStatus> {
|
||||||
|
return await this.loader?.execute(async (signal: AbortSignal) => {
|
||||||
|
await this.fetch(signal);
|
||||||
|
}, cancelable);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleLoadError(error: unknown) {
|
||||||
|
const _$t = get(t);
|
||||||
|
handleError(error, _$t('errors.failed_to_load_assets'));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.loader?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
layout(_: boolean) {}
|
||||||
|
|
||||||
|
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
||||||
|
this.intersecting = intersecting;
|
||||||
|
this.#actuallyIntersecting = actuallyIntersecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGeometry(options: UpdateGeometryOptions) {
|
||||||
|
const { invalidateHeight, noDefer = false } = options;
|
||||||
|
if (invalidateHeight) {
|
||||||
|
this.isHeightActual = false;
|
||||||
|
}
|
||||||
|
if (!this.loaded) {
|
||||||
|
const viewportWidth = this.assetStreamManager.viewportWidth;
|
||||||
|
if (!this.isHeightActual) {
|
||||||
|
const unwrappedWidth = (3 / 2) * this.assetsCount * this.assetStreamManager.rowHeight * (7 / 10);
|
||||||
|
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
||||||
|
const height = 51 + Math.max(1, rows) * this.assetStreamManager.rowHeight;
|
||||||
|
this.height = height;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.layout(noDefer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export class DayGroup {
|
|||||||
readonly monthGroup: MonthGroup;
|
readonly monthGroup: MonthGroup;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly groupTitle: string;
|
readonly groupTitle: string;
|
||||||
|
readonly groupTitleFull: string;
|
||||||
readonly day: number;
|
readonly day: number;
|
||||||
viewerAssets: ViewerAsset[] = $state([]);
|
viewerAssets: ViewerAsset[] = $state([]);
|
||||||
|
|
||||||
@@ -26,11 +27,12 @@ export class DayGroup {
|
|||||||
#col = $state(0);
|
#col = $state(0);
|
||||||
#deferredLayout = false;
|
#deferredLayout = false;
|
||||||
|
|
||||||
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
|
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string, groupTitleFull: string) {
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.monthGroup = monthGroup;
|
this.monthGroup = monthGroup;
|
||||||
this.day = day;
|
this.day = day;
|
||||||
this.groupTitle = groupTitle;
|
this.groupTitle = groupTitle;
|
||||||
|
this.groupTitleFull = groupTitleFull;
|
||||||
}
|
}
|
||||||
|
|
||||||
get top() {
|
get top() {
|
||||||
@@ -131,7 +133,7 @@ export class DayGroup {
|
|||||||
}
|
}
|
||||||
unprocessedIds.delete(assetId);
|
unprocessedIds.delete(assetId);
|
||||||
processedIds.add(assetId);
|
processedIds.add(assetId);
|
||||||
if (remove || this.monthGroup.timelineManager.isExcluded(asset)) {
|
if (remove || this.monthGroup.assetStreamManager.isExcluded(asset)) {
|
||||||
this.viewerAssets.splice(index, 1);
|
this.viewerAssets.splice(index, 1);
|
||||||
changedGeometry = true;
|
changedGeometry = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,29 @@
|
|||||||
|
import { isIntersecting, type AssetStreamManager } from '$lib/managers/AssetStreamManager/AssetStreamManager.svelte';
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
import type { MonthGroup } from '../month-group.svelte';
|
|
||||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||||
} = TUNABLES;
|
} = TUNABLES;
|
||||||
|
|
||||||
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
|
|
||||||
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
|
|
||||||
let preIntersecting = false;
|
|
||||||
if (!actuallyIntersecting) {
|
|
||||||
preIntersecting = calculateMonthGroupIntersecting(
|
|
||||||
timelineManager,
|
|
||||||
month,
|
|
||||||
INTERSECTION_EXPAND_TOP,
|
|
||||||
INTERSECTION_EXPAND_BOTTOM,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
month.intersecting = actuallyIntersecting || preIntersecting;
|
|
||||||
month.actuallyIntersecting = actuallyIntersecting;
|
|
||||||
if (preIntersecting || actuallyIntersecting) {
|
|
||||||
timelineManager.clearDeferredLayout(month);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General function to check if a rectangular region intersects with a window.
|
|
||||||
* @param regionTop - Top position of the region to check
|
|
||||||
* @param regionBottom - Bottom position of the region to check
|
|
||||||
* @param windowTop - Top position of the window
|
|
||||||
* @param windowBottom - Bottom position of the window
|
|
||||||
* @returns true if the region intersects with the window
|
|
||||||
*/
|
|
||||||
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
|
|
||||||
return (
|
|
||||||
(regionTop >= windowTop && regionTop < windowBottom) ||
|
|
||||||
(regionBottom >= windowTop && regionBottom < windowBottom) ||
|
|
||||||
(regionTop < windowTop && regionBottom >= windowBottom)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateMonthGroupIntersecting(
|
|
||||||
timelineManager: TimelineManager,
|
|
||||||
monthGroup: MonthGroup,
|
|
||||||
expandTop: number,
|
|
||||||
expandBottom: number,
|
|
||||||
) {
|
|
||||||
const monthGroupTop = monthGroup.top;
|
|
||||||
const monthGroupBottom = monthGroupTop + monthGroup.height;
|
|
||||||
const topWindow = timelineManager.visibleWindow.top - expandTop;
|
|
||||||
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
|
|
||||||
|
|
||||||
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
||||||
*/
|
*/
|
||||||
export function calculateViewerAssetIntersecting(
|
export function calculateViewerAssetIntersecting(
|
||||||
timelineManager: TimelineManager,
|
assetStreamManager: AssetStreamManager,
|
||||||
positionTop: number,
|
positionTop: number,
|
||||||
positionHeight: number,
|
positionHeight: number,
|
||||||
expandTop: number = INTERSECTION_EXPAND_TOP,
|
expandTop: number = INTERSECTION_EXPAND_TOP,
|
||||||
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
|
||||||
) {
|
) {
|
||||||
const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0;
|
const scrollCompensationHeightDelta = assetStreamManager.scrollCompensation?.heightDelta ?? 0;
|
||||||
|
|
||||||
const topWindow =
|
const topWindow =
|
||||||
timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta;
|
assetStreamManager.visibleWindow.top - assetStreamManager.headerHeight - expandTop + scrollCompensationHeightDelta;
|
||||||
const bottomWindow =
|
const bottomWindow =
|
||||||
timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta;
|
assetStreamManager.visibleWindow.bottom +
|
||||||
|
assetStreamManager.headerHeight +
|
||||||
|
expandBottom +
|
||||||
|
scrollCompensationHeightDelta;
|
||||||
|
|
||||||
const positionBottom = positionTop + positionHeight;
|
const positionBottom = positionTop + positionHeight;
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,5 @@
|
|||||||
|
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { MonthGroup } from '../month-group.svelte';
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
|
||||||
import type { UpdateGeometryOptions } from '../types';
|
|
||||||
|
|
||||||
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
|
|
||||||
const { invalidateHeight, noDefer = false } = options;
|
|
||||||
if (invalidateHeight) {
|
|
||||||
month.isHeightActual = false;
|
|
||||||
}
|
|
||||||
if (!month.isLoaded) {
|
|
||||||
const viewportWidth = timelineManager.viewportWidth;
|
|
||||||
if (!month.isHeightActual) {
|
|
||||||
const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10);
|
|
||||||
const rows = Math.ceil(unwrappedWidth / viewportWidth);
|
|
||||||
const height = 51 + Math.max(1, rows) * timelineManager.rowHeight;
|
|
||||||
month.height = height;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
layoutMonthGroup(timelineManager, month, noDefer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
||||||
let cumulativeHeight = 0;
|
let cumulativeHeight = 0;
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
|
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
|
||||||
import { AssetOrder } from '@immich/sdk';
|
import { AssetOrder } from '@immich/sdk';
|
||||||
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
|
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
|
||||||
import { MonthGroup } from '../month-group.svelte';
|
import { MonthGroup } from '../month-group.svelte';
|
||||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
import type { AssetOperation, TimelineAsset } from '../types';
|
import type { AssetOperation, TimelineAsset } from '../types';
|
||||||
import { updateGeometry } from './layout-support.svelte';
|
|
||||||
import { getMonthGroupByDate } from './search-support.svelte';
|
import { getMonthGroupByDate } from './search-support.svelte';
|
||||||
|
|
||||||
export function addAssetsToMonthGroups(
|
export function addAssetsToMonthGroups(
|
||||||
@@ -17,25 +15,23 @@ export function addAssetsToMonthGroups(
|
|||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addContext = new GroupInsertionCache();
|
const addContext = new GroupInsertionCache();
|
||||||
const updatedMonthGroups = new SvelteSet<MonthGroup>();
|
const updatedMonthGroups = new SvelteSet<MonthGroup>();
|
||||||
const monthCount = timelineManager.months.length;
|
const monthCount = timelineManager.segments.length;
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
||||||
|
|
||||||
if (!month) {
|
if (!month) {
|
||||||
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
|
month = new MonthGroup(timelineManager, asset.localDateTime, 1, true, options.order);
|
||||||
month.isLoaded = true;
|
timelineManager.segments.push(month);
|
||||||
timelineManager.months.push(month);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
month.addTimelineAsset(asset, addContext);
|
month.addTimelineAsset(asset, addContext);
|
||||||
updatedMonthGroups.add(month);
|
updatedMonthGroups.add(month);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineManager.months.length !== monthCount) {
|
if (timelineManager.segments.length !== monthCount) {
|
||||||
timelineManager.months.sort((a, b) => {
|
timelineManager.segments.sort((a, b) => {
|
||||||
return a.yearMonth.year === b.yearMonth.year
|
return a.yearMonth.year === b.yearMonth.year
|
||||||
? b.yearMonth.month - a.yearMonth.month
|
? b.yearMonth.month - a.yearMonth.month
|
||||||
: b.yearMonth.year - a.yearMonth.year;
|
: b.yearMonth.year - a.yearMonth.year;
|
||||||
@@ -52,7 +48,7 @@ export function addAssetsToMonthGroups(
|
|||||||
|
|
||||||
for (const month of addContext.updatedBuckets) {
|
for (const month of addContext.updatedBuckets) {
|
||||||
month.sortDayGroups();
|
month.sortDayGroups();
|
||||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
month.updateGeometry({ invalidateHeight: true });
|
||||||
}
|
}
|
||||||
timelineManager.updateIntersections();
|
timelineManager.updateIntersections();
|
||||||
}
|
}
|
||||||
@@ -71,7 +67,7 @@ export function runAssetOperation(
|
|||||||
let idsToProcess = new SvelteSet(ids);
|
let idsToProcess = new SvelteSet(ids);
|
||||||
const idsProcessed = new SvelteSet<string>();
|
const idsProcessed = new SvelteSet<string>();
|
||||||
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
|
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
if (idsToProcess.size > 0) {
|
if (idsToProcess.size > 0) {
|
||||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||||
if (moveAssets.length > 0) {
|
if (moveAssets.length > 0) {
|
||||||
@@ -95,7 +91,7 @@ export function runAssetOperation(
|
|||||||
}
|
}
|
||||||
const changedGeometry = changedMonthGroups.size > 0;
|
const changedGeometry = changedMonthGroups.size > 0;
|
||||||
for (const month of changedMonthGroups) {
|
for (const month of changedMonthGroups) {
|
||||||
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
month.updateGeometry({ invalidateHeight: true });
|
||||||
}
|
}
|
||||||
if (changedGeometry) {
|
if (changedGeometry) {
|
||||||
timelineManager.updateIntersections();
|
timelineManager.updateIntersections();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function getAssetWithOffset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findMonthGroupForAsset(timelineManager: TimelineManager, id: string) {
|
export function findMonthGroupForAsset(timelineManager: TimelineManager, id: string) {
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
const asset = month.findAssetById({ id });
|
const asset = month.findAssetById({ id });
|
||||||
if (asset) {
|
if (asset) {
|
||||||
return { monthGroup: month, asset };
|
return { monthGroup: month, asset };
|
||||||
@@ -44,7 +44,7 @@ export function getMonthGroupByDate(
|
|||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
targetYearMonth: TimelineYearMonth,
|
targetYearMonth: TimelineYearMonth,
|
||||||
): MonthGroup | undefined {
|
): MonthGroup | undefined {
|
||||||
return timelineManager.months.find(
|
return timelineManager.segments.find(
|
||||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
|
export function findMonthGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
|
||||||
for (const month of timelineManager.months) {
|
for (const month of timelineManager.segments) {
|
||||||
const { year, month: monthNum } = month.yearMonth;
|
const { year, month: monthNum } = month.yearMonth;
|
||||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||||
return month;
|
return month;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { AssetStreamSegment, type SegmentIdentifier } from '$lib/managers/AssetStreamManager/AssetStreamSegment.svelte';
|
||||||
|
import { layoutMonthGroup } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import {
|
import {
|
||||||
formatGroupTitle,
|
formatGroupTitle,
|
||||||
|
formatGroupTitleFull,
|
||||||
formatMonthGroupTitle,
|
formatMonthGroupTitle,
|
||||||
fromTimelinePlainDate,
|
fromTimelinePlainDate,
|
||||||
fromTimelinePlainDateTime,
|
fromTimelinePlainDateTime,
|
||||||
@@ -13,82 +13,55 @@ import {
|
|||||||
type TimelineDateTime,
|
type TimelineDateTime,
|
||||||
type TimelineYearMonth,
|
type TimelineYearMonth,
|
||||||
} from '$lib/utils/timeline-util';
|
} from '$lib/utils/timeline-util';
|
||||||
|
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { DayGroup } from './day-group.svelte';
|
import { DayGroup } from './day-group.svelte';
|
||||||
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||||
import type { TimelineManager } from './timeline-manager.svelte';
|
import { getSegmentIdentifier, type TimelineManager } from './timeline-manager.svelte';
|
||||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||||
import { ViewerAsset } from './viewer-asset.svelte';
|
import { ViewerAsset } from './viewer-asset.svelte';
|
||||||
|
|
||||||
export class MonthGroup {
|
export class MonthGroup extends AssetStreamSegment {
|
||||||
#intersecting: boolean = $state(false);
|
|
||||||
actuallyIntersecting: boolean = $state(false);
|
|
||||||
isLoaded: boolean = $state(false);
|
|
||||||
dayGroups: DayGroup[] = $state([]);
|
dayGroups: DayGroup[] = $state([]);
|
||||||
readonly timelineManager: TimelineManager;
|
|
||||||
|
|
||||||
#height: number = $state(0);
|
|
||||||
#top: number = $state(0);
|
|
||||||
|
|
||||||
#initialCount: number = 0;
|
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
percent: number = $state(0);
|
#yearMonth: TimelineYearMonth;
|
||||||
|
#identifier: SegmentIdentifier;
|
||||||
assetsCount: number = $derived(
|
#timelineManager: TimelineManager;
|
||||||
this.isLoaded
|
|
||||||
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
|
|
||||||
: this.#initialCount,
|
|
||||||
);
|
|
||||||
loader: CancellableTask | undefined;
|
|
||||||
isHeightActual: boolean = $state(false);
|
|
||||||
|
|
||||||
readonly monthGroupTitle: string;
|
readonly monthGroupTitle: string;
|
||||||
readonly yearMonth: TimelineYearMonth;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
store: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
yearMonth: TimelineYearMonth,
|
yearMonth: TimelineYearMonth,
|
||||||
initialCount: number,
|
initialCount: number,
|
||||||
|
loaded: boolean,
|
||||||
order: AssetOrder = AssetOrder.Desc,
|
order: AssetOrder = AssetOrder.Desc,
|
||||||
) {
|
) {
|
||||||
this.timelineManager = store;
|
super();
|
||||||
this.#initialCount = initialCount;
|
this.initialCount = initialCount;
|
||||||
|
this.#yearMonth = yearMonth;
|
||||||
|
this.#identifier = getSegmentIdentifier(yearMonth);
|
||||||
|
this.#timelineManager = timelineManager;
|
||||||
this.#sortOrder = order;
|
this.#sortOrder = order;
|
||||||
|
|
||||||
this.yearMonth = yearMonth;
|
|
||||||
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||||
|
this.loaded = loaded;
|
||||||
this.loader = new CancellableTask(
|
|
||||||
() => {
|
|
||||||
this.isLoaded = true;
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.dayGroups = [];
|
|
||||||
this.isLoaded = false;
|
|
||||||
},
|
|
||||||
this.#handleLoadError,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set intersecting(newValue: boolean) {
|
get identifier() {
|
||||||
const old = this.#intersecting;
|
return this.#identifier;
|
||||||
if (old === newValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#intersecting = newValue;
|
|
||||||
if (newValue) {
|
|
||||||
void this.timelineManager.loadMonthGroup(this.yearMonth);
|
|
||||||
} else {
|
|
||||||
this.cancel();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get intersecting() {
|
get assetStreamManager() {
|
||||||
return this.#intersecting;
|
return this.#timelineManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
get yearMonth() {
|
||||||
|
return this.#yearMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(signal: AbortSignal): Promise<void> {
|
||||||
|
return loadFromTimeBuckets(this.assetStreamManager, this, this.assetStreamManager.options, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
get lastDayGroup() {
|
get lastDayGroup() {
|
||||||
@@ -99,9 +72,9 @@ export class MonthGroup {
|
|||||||
return this.dayGroups[0]?.getFirstAsset();
|
return this.dayGroups[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssets() {
|
get viewerAssets() {
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce
|
// 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() {
|
sortDayGroups() {
|
||||||
@@ -222,7 +195,8 @@ export class MonthGroup {
|
|||||||
addContext.setDayGroup(dayGroup, localDateTime);
|
addContext.setDayGroup(dayGroup, localDateTime);
|
||||||
} else {
|
} else {
|
||||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||||
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
|
const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
|
||||||
|
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle, groupTitleFull);
|
||||||
this.dayGroups.push(dayGroup);
|
this.dayGroups.push(dayGroup);
|
||||||
addContext.setDayGroup(dayGroup, localDateTime);
|
addContext.setDayGroup(dayGroup, localDateTime);
|
||||||
addContext.newDayGroups.add(dayGroup);
|
addContext.newDayGroups.add(dayGroup);
|
||||||
@@ -233,67 +207,6 @@ export class MonthGroup {
|
|||||||
addContext.changedDayGroups.add(dayGroup);
|
addContext.changedDayGroups.add(dayGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewId() {
|
|
||||||
const { year, month } = this.yearMonth;
|
|
||||||
return year + '-' + month;
|
|
||||||
}
|
|
||||||
|
|
||||||
set height(height: number) {
|
|
||||||
if (this.#height === height) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { timelineManager: store, percent } = this;
|
|
||||||
const index = store.months.indexOf(this);
|
|
||||||
const heightDelta = height - this.#height;
|
|
||||||
this.#height = height;
|
|
||||||
const prevMonthGroup = store.months[index - 1];
|
|
||||||
if (prevMonthGroup) {
|
|
||||||
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
|
||||||
if (this.#top !== newTop) {
|
|
||||||
this.#top = newTop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
|
|
||||||
const monthGroup = this.timelineManager.months[cursor];
|
|
||||||
const newTop = monthGroup.#top + heightDelta;
|
|
||||||
if (monthGroup.#top !== newTop) {
|
|
||||||
monthGroup.#top = newTop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (store.topIntersectingMonthGroup) {
|
|
||||||
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
if (index < currentIndex) {
|
|
||||||
store.scrollCompensation = {
|
|
||||||
heightDelta,
|
|
||||||
scrollTop: undefined,
|
|
||||||
monthGroup: this,
|
|
||||||
};
|
|
||||||
} else if (percent > 0) {
|
|
||||||
const top = this.top + height * percent;
|
|
||||||
store.scrollCompensation = {
|
|
||||||
heightDelta: undefined,
|
|
||||||
scrollTop: top,
|
|
||||||
monthGroup: this,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get height() {
|
|
||||||
return this.#height;
|
|
||||||
}
|
|
||||||
|
|
||||||
get top(): number {
|
|
||||||
return this.#top + this.timelineManager.topSectionHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleLoadError(error: unknown) {
|
|
||||||
const _$t = get(t);
|
|
||||||
handleError(error, _$t('errors.failed_to_load_assets'));
|
|
||||||
}
|
|
||||||
|
|
||||||
findDayGroupForAsset(asset: TimelineAsset) {
|
findDayGroupForAsset(asset: TimelineAsset) {
|
||||||
for (const group of this.dayGroups) {
|
for (const group of this.dayGroups) {
|
||||||
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
||||||
@@ -307,7 +220,7 @@ export class MonthGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findAssetAbsolutePosition(assetId: string) {
|
findAssetAbsolutePosition(assetId: string) {
|
||||||
this.timelineManager.clearDeferredLayout(this);
|
this.#clearDeferredLayout();
|
||||||
for (const group of this.dayGroups) {
|
for (const group of this.dayGroups) {
|
||||||
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||||
if (viewerAsset) {
|
if (viewerAsset) {
|
||||||
@@ -315,7 +228,7 @@ export class MonthGroup {
|
|||||||
console.warn('No position for asset');
|
console.warn('No position for asset');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight;
|
return this.top + group.top + viewerAsset.position.top + this.assetStreamManager.headerHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
@@ -365,4 +278,25 @@ export class MonthGroup {
|
|||||||
cancel() {
|
cancel() {
|
||||||
this.loader?.cancel();
|
this.loader?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
layout(noDefer: boolean) {
|
||||||
|
layoutMonthGroup(this.assetStreamManager, this, noDefer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearDeferredLayout() {
|
||||||
|
const hasDeferred = this.dayGroups.some((group) => group.deferredLayout);
|
||||||
|
if (hasDeferred) {
|
||||||
|
this.updateGeometry({ invalidateHeight: true, noDefer: true });
|
||||||
|
for (const group of this.dayGroups) {
|
||||||
|
group.deferredLayout = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
||||||
|
super.updateIntersection({ intersecting, actuallyIntersecting });
|
||||||
|
if (intersecting) {
|
||||||
|
this.#clearDeferredLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||||
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
import { TimelineManager } from './timeline-manager.svelte';
|
import { getSegmentIdentifier } from './timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from './types';
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
async function getAssets(timelineManager: TimelineManager) {
|
async function getAssets(timelineManager: TimelineManager) {
|
||||||
@@ -22,6 +23,14 @@ function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initViewport = async (timelineManager: TimelineManager, viewportHeight = 1000, viewportWidth = 1588) => {
|
||||||
|
timelineManager.viewportHeight = viewportHeight;
|
||||||
|
timelineManager.viewportWidth = viewportWidth;
|
||||||
|
await timelineManager.init();
|
||||||
|
timelineManager.updateSlidingWindow(0);
|
||||||
|
timelineManager.updateIntersections();
|
||||||
|
};
|
||||||
|
|
||||||
describe('TimelineManager', () => {
|
describe('TimelineManager', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -63,16 +72,16 @@ describe('TimelineManager', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
await initViewport(timelineManager);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load months in viewport', () => {
|
it('should load months in viewport', () => {
|
||||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates month height', () => {
|
it('calculates month height', () => {
|
||||||
const plainMonths = timelineManager.months.map((month) => ({
|
const plainMonths = timelineManager.segments.map((month) => ({
|
||||||
year: month.yearMonth.year,
|
year: month.yearMonth.year,
|
||||||
month: month.yearMonth.month,
|
month: month.yearMonth.month,
|
||||||
height: month.height,
|
height: month.height,
|
||||||
@@ -82,17 +91,17 @@ describe('TimelineManager', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
||||||
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
|
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
|
||||||
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
|
expect.objectContaining({ year: 2024, month: 1, height: 404.5 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
it('calculates timeline height', () => {
|
||||||
expect(timelineManager.timelineHeight).toBe(12_447.5);
|
expect(timelineManager.streamViewerHeight).toBe(12566);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadMonthGroup', () => {
|
describe('loadSegment', () => {
|
||||||
let timelineManager: TimelineManager;
|
let timelineManager: TimelineManager;
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
@@ -124,52 +133,52 @@ describe('TimelineManager', () => {
|
|||||||
}
|
}
|
||||||
return bucketAssetsResponse[timeBucket];
|
return bucketAssetsResponse[timeBucket];
|
||||||
});
|
});
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 0 });
|
await initViewport(timelineManager);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads a month', async () => {
|
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.loadMonthGroup({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(2);
|
||||||
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 () => {
|
it('ignores invalid months', async () => {
|
||||||
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels month loading', async () => {
|
it('cancels month loading', async () => {
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
void timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||||
month?.cancel();
|
month?.cancel();
|
||||||
expect(abortSpy).toBeCalledTimes(1);
|
expect(abortSpy).toBeCalledTimes(1);
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 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 () => {
|
it('prevents loading months multiple times', async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
||||||
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
||||||
]);
|
]);
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(2);
|
||||||
|
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows loading a canceled month', async () => {
|
it('allows loading a canceled month', async () => {
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
|
|
||||||
month.cancel();
|
month.cancel();
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
expect(month?.getAssets().length).toEqual(0);
|
expect(month?.assets.length).toEqual(0);
|
||||||
|
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
expect(month!.getAssets().length).toEqual(3);
|
expect(month!.assets.length).toEqual(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,11 +189,12 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager = new TimelineManager();
|
timelineManager = new TimelineManager();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
timelineManager.viewportHeight = 1000;
|
||||||
|
timelineManager.viewportHeight = 1588;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty initially', () => {
|
it('is empty initially', () => {
|
||||||
expect(timelineManager.months.length).toEqual(0);
|
expect(timelineManager.segments.length).toEqual(0);
|
||||||
expect(timelineManager.assetCount).toEqual(0);
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,12 +206,12 @@ describe('TimelineManager', () => {
|
|||||||
);
|
);
|
||||||
timelineManager.addAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
expect(timelineManager.segments[0].assets.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
expect(timelineManager.segments[0].getFirstAsset().id).toEqual(asset.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to existing month', () => {
|
it('adds assets to existing month', () => {
|
||||||
@@ -213,11 +223,11 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager.addAssets([assetOne]);
|
timelineManager.addAssets([assetOne]);
|
||||||
timelineManager.addAssets([assetTwo]);
|
timelineManager.addAssets([assetTwo]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.assetCount).toEqual(2);
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
expect(timelineManager.segments[0].assets.length).toEqual(2);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[0].yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders assets in months by descending date', () => {
|
it('orders assets in months by descending date', () => {
|
||||||
@@ -240,10 +250,10 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
expect(month).not.toBeNull();
|
expect(month).not.toBeNull();
|
||||||
expect(month?.getAssets().length).toEqual(3);
|
expect(month?.assets.length).toEqual(3);
|
||||||
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
|
expect(month?.assets[0].id).toEqual(assetOne.id);
|
||||||
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
|
expect(month?.assets[1].id).toEqual(assetThree.id);
|
||||||
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
|
expect(month?.assets[2].id).toEqual(assetTwo.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders months by descending date', () => {
|
it('orders months by descending date', () => {
|
||||||
@@ -264,15 +274,15 @@ describe('TimelineManager', () => {
|
|||||||
);
|
);
|
||||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(3);
|
expect(timelineManager.segments.length).toEqual(3);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(4);
|
expect(timelineManager.segments[0].yearMonth.month).toEqual(4);
|
||||||
|
|
||||||
expect(timelineManager.months[1].yearMonth.year).toEqual(2024);
|
expect(timelineManager.segments[1].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[1].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[1].yearMonth.month).toEqual(1);
|
||||||
|
|
||||||
expect(timelineManager.months[2].yearMonth.year).toEqual(2023);
|
expect(timelineManager.segments[2].yearMonth.year).toEqual(2023);
|
||||||
expect(timelineManager.months[2].yearMonth.month).toEqual(1);
|
expect(timelineManager.segments[2].yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates existing asset', () => {
|
it('updates existing asset', () => {
|
||||||
@@ -304,13 +314,14 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager = new TimelineManager();
|
timelineManager = new TimelineManager();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
timelineManager.viewportHeight = 1000;
|
||||||
|
timelineManager.viewportHeight = 1588;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores non-existing assets', () => {
|
it('ignores non-existing assets', () => {
|
||||||
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(0);
|
expect(timelineManager.segments.length).toEqual(0);
|
||||||
expect(timelineManager.assetCount).toEqual(0);
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,11 +331,11 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
timelineManager.addAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(false);
|
||||||
|
|
||||||
timelineManager.updateAssets([updatedAsset]);
|
timelineManager.updateAssets([updatedAsset]);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('asset moves months when asset date changes', () => {
|
it('asset moves months when asset date changes', () => {
|
||||||
@@ -339,16 +350,16 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
timelineManager.addAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
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]);
|
timelineManager.updateAssets([updatedAsset]);
|
||||||
expect(timelineManager.months.length).toEqual(2);
|
expect(timelineManager.segments.length).toEqual(2);
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
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 })).not.toBeUndefined();
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,7 +370,8 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager = new TimelineManager();
|
timelineManager = new TimelineManager();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
timelineManager.viewportHeight = 1000;
|
||||||
|
timelineManager.viewportHeight = 1588;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid IDs', () => {
|
it('ignores invalid IDs', () => {
|
||||||
@@ -373,8 +385,8 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
timelineManager.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(2);
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
expect(timelineManager.segments[0].assets.length).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes asset from month', () => {
|
it('removes asset from month', () => {
|
||||||
@@ -387,8 +399,8 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager.removeAssets([assetOne.id]);
|
timelineManager.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
expect(timelineManager.segments[0].assets.length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not remove month when empty', () => {
|
it('does not remove month when empty', () => {
|
||||||
@@ -401,7 +413,7 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(0);
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.segments.length).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -411,7 +423,8 @@ describe('TimelineManager', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
timelineManager = new TimelineManager();
|
timelineManager = new TimelineManager();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
await timelineManager.updateViewport({ width: 0, height: 0 });
|
|
||||||
|
await initViewport(timelineManager, 0, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty store returns null', () => {
|
it('empty store returns null', () => {
|
||||||
@@ -468,7 +481,8 @@ describe('TimelineManager', () => {
|
|||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
|
||||||
|
initViewport(timelineManager);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for invalid assetId', async () => {
|
it('returns null for invalid assetId', async () => {
|
||||||
@@ -477,45 +491,45 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId', async () => {
|
it('returns previous assetId', async () => {
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
|
|
||||||
const a = month!.getAssets()[0];
|
const a = month!.assets[0];
|
||||||
const b = month!.getAssets()[1];
|
const b = month!.assets[1];
|
||||||
const previous = await timelineManager.getLaterAsset(b);
|
const previous = await timelineManager.getLaterAsset(b);
|
||||||
expect(previous).toEqual(a);
|
expect(previous).toEqual(a);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId spanning multiple months', async () => {
|
it('returns previous assetId spanning multiple months', async () => {
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||||
|
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
const a = month!.getAssets()[0];
|
const a = month!.assets[0];
|
||||||
const b = previousMonth!.getAssets()[0];
|
const b = previousMonth!.assets[0];
|
||||||
const previous = await timelineManager.getLaterAsset(a);
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
expect(previous).toEqual(b);
|
expect(previous).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads previous month', async () => {
|
it('loads previous month', async () => {
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
const a = month!.getFirstAsset();
|
const a = month!.getFirstAsset();
|
||||||
const b = previousMonth!.getFirstAsset();
|
const b = previousMonth!.getFirstAsset();
|
||||||
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute');
|
const loadSegmentSpy = vi.spyOn(month!.loader!, 'execute');
|
||||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||||
const previous = await timelineManager.getLaterAsset(a);
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
expect(previous).toEqual(b);
|
expect(previous).toEqual(b);
|
||||||
expect(loadMonthGroupSpy).toBeCalledTimes(0);
|
expect(loadSegmentSpy).toBeCalledTimes(0);
|
||||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips removed assets', async () => {
|
it('skips removed assets', async () => {
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||||
|
|
||||||
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||||
timelineManager.removeAssets([assetTwo.id]);
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
@@ -523,8 +537,8 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no more assets', async () => {
|
it('returns null when no more assets', async () => {
|
||||||
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||||
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
expect(await timelineManager.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -535,7 +549,7 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager = new TimelineManager();
|
timelineManager = new TimelineManager();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
|
||||||
await timelineManager.updateViewport({ width: 0, height: 0 });
|
await initViewport(timelineManager, 0, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for invalid months', () => {
|
it('returns null for invalid months', () => {
|
||||||
@@ -618,7 +632,7 @@ describe('TimelineManager', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 0 });
|
await initViewport(timelineManager);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gets all assets once', async () => {
|
it('gets all assets once', async () => {
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
import { AssetStreamManager } from '$lib/managers/AssetStreamManager/AssetStreamManager.svelte';
|
||||||
|
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
|
||||||
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
|
||||||
|
|
||||||
import { clamp, debounce, isEqual } from 'lodash-es';
|
|
||||||
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
||||||
|
|
||||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
|
||||||
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
|
||||||
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
|
||||||
import {
|
import {
|
||||||
addAssetsToMonthGroups,
|
addAssetsToMonthGroups,
|
||||||
runAssetOperation,
|
runAssetOperation,
|
||||||
@@ -23,6 +12,11 @@ import {
|
|||||||
retrieveRange as retrieveRangeUtil,
|
retrieveRange as retrieveRangeUtil,
|
||||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||||
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
|
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import { DayGroup } from './day-group.svelte';
|
import { DayGroup } from './day-group.svelte';
|
||||||
import { isMismatched, updateObject } from './internal/utils.svelte';
|
import { isMismatched, updateObject } from './internal/utils.svelte';
|
||||||
import { MonthGroup } from './month-group.svelte';
|
import { MonthGroup } from './month-group.svelte';
|
||||||
@@ -32,29 +26,14 @@ import type {
|
|||||||
Direction,
|
Direction,
|
||||||
ScrubberMonth,
|
ScrubberMonth,
|
||||||
TimelineAsset,
|
TimelineAsset,
|
||||||
TimelineManagerLayoutOptions,
|
|
||||||
TimelineManagerOptions,
|
TimelineManagerOptions,
|
||||||
Viewport,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class TimelineManager {
|
export class TimelineManager extends AssetStreamManager {
|
||||||
isInitialized = $state(false);
|
|
||||||
months: MonthGroup[] = $state([]);
|
|
||||||
topSectionHeight = $state(0);
|
|
||||||
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
|
|
||||||
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
|
||||||
|
|
||||||
albumAssets: Set<string> = new SvelteSet();
|
albumAssets: Set<string> = new SvelteSet();
|
||||||
|
|
||||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
scrubberTimelineHeight: number = $state(0);
|
scrubberTimelineHeight: number = $state(0);
|
||||||
|
#months: MonthGroup[] = $state([]);
|
||||||
topIntersectingMonthGroup: MonthGroup | undefined = $state();
|
|
||||||
|
|
||||||
visibleWindow = $derived.by(() => ({
|
|
||||||
top: this.#scrollTop,
|
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
|
||||||
}));
|
|
||||||
|
|
||||||
initTask = new CancellableTask(
|
initTask = new CancellableTask(
|
||||||
() => {
|
() => {
|
||||||
@@ -72,121 +51,16 @@ export class TimelineManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static #INIT_OPTIONS = {};
|
static #INIT_OPTIONS = {};
|
||||||
#viewportHeight = $state(0);
|
|
||||||
#viewportWidth = $state(0);
|
|
||||||
#scrollTop = $state(0);
|
|
||||||
#websocketSupport: WebsocketSupport | undefined;
|
#websocketSupport: WebsocketSupport | undefined;
|
||||||
|
|
||||||
#rowHeight = $state(235);
|
|
||||||
#headerHeight = $state(48);
|
|
||||||
#gap = $state(12);
|
|
||||||
|
|
||||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||||
|
|
||||||
#scrolling = $state(false);
|
get segments() {
|
||||||
#suspendTransitions = $state(false);
|
return this.#months;
|
||||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
|
||||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
|
||||||
scrollCompensation: {
|
|
||||||
heightDelta: number | undefined;
|
|
||||||
scrollTop: number | undefined;
|
|
||||||
monthGroup: MonthGroup | undefined;
|
|
||||||
} = $state({
|
|
||||||
heightDelta: 0,
|
|
||||||
scrollTop: 0,
|
|
||||||
monthGroup: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
|
|
||||||
let changed = false;
|
|
||||||
changed ||= this.#setHeaderHeight(headerHeight);
|
|
||||||
changed ||= this.#setGap(gap);
|
|
||||||
changed ||= this.#setRowHeight(rowHeight);
|
|
||||||
if (changed) {
|
|
||||||
this.refreshLayout();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#setHeaderHeight(value: number) {
|
get options() {
|
||||||
if (this.#headerHeight == value) {
|
return this.#options;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#headerHeight = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get headerHeight() {
|
|
||||||
return this.#headerHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
#setGap(value: number) {
|
|
||||||
if (this.#gap == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#gap = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get gap() {
|
|
||||||
return this.#gap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#setRowHeight(value: number) {
|
|
||||||
if (this.#rowHeight == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#rowHeight = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get rowHeight() {
|
|
||||||
return this.#rowHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
set scrolling(value: boolean) {
|
|
||||||
this.#scrolling = value;
|
|
||||||
if (value) {
|
|
||||||
this.suspendTransitions = true;
|
|
||||||
this.#resetScrolling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get scrolling() {
|
|
||||||
return this.#scrolling;
|
|
||||||
}
|
|
||||||
|
|
||||||
set suspendTransitions(value: boolean) {
|
|
||||||
this.#suspendTransitions = value;
|
|
||||||
if (value) {
|
|
||||||
this.#resetSuspendTransitions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get suspendTransitions() {
|
|
||||||
return this.#suspendTransitions;
|
|
||||||
}
|
|
||||||
|
|
||||||
set viewportWidth(value: number) {
|
|
||||||
const changed = value !== this.#viewportWidth;
|
|
||||||
this.#viewportWidth = value;
|
|
||||||
this.suspendTransitions = true;
|
|
||||||
void this.#updateViewportGeometry(changed);
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewportWidth() {
|
|
||||||
return this.#viewportWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
set viewportHeight(value: number) {
|
|
||||||
this.#viewportHeight = value;
|
|
||||||
this.#suspendTransitions = true;
|
|
||||||
void this.#updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewportHeight() {
|
|
||||||
return this.#viewportHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async *assetsIterator(options?: {
|
async *assetsIterator(options?: {
|
||||||
@@ -198,7 +72,7 @@ export class TimelineManager {
|
|||||||
const direction = options?.direction ?? 'earlier';
|
const direction = options?.direction ?? 'earlier';
|
||||||
let { startDayGroup, startAsset } = options ?? {};
|
let { startDayGroup, startAsset } = options ?? {};
|
||||||
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
||||||
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
|
await this.loadSegment(monthGroup.identifier, { cancelable: false });
|
||||||
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
||||||
startDayGroup = startAsset = undefined;
|
startDayGroup = startAsset = undefined;
|
||||||
}
|
}
|
||||||
@@ -207,13 +81,13 @@ export class TimelineManager {
|
|||||||
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
|
*monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
|
||||||
const isEarlier = options?.direction === 'earlier';
|
const isEarlier = options?.direction === 'earlier';
|
||||||
let startIndex = options?.startMonthGroup
|
let startIndex = options?.startMonthGroup
|
||||||
? this.months.indexOf(options.startMonthGroup)
|
? this.segments.indexOf(options.startMonthGroup)
|
||||||
: isEarlier
|
: isEarlier
|
||||||
? 0
|
? 0
|
||||||
: this.months.length - 1;
|
: this.segments.length - 1;
|
||||||
|
|
||||||
while (startIndex >= 0 && startIndex < this.months.length) {
|
while (startIndex >= 0 && startIndex < this.segments.length) {
|
||||||
yield this.months[startIndex];
|
yield this.segments[startIndex];
|
||||||
startIndex += isEarlier ? 1 : -1;
|
startIndex += isEarlier ? 1 : -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,75 +108,24 @@ export class TimelineManager {
|
|||||||
this.#websocketSupport = undefined;
|
this.#websocketSupport = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlidingWindow(scrollTop: number) {
|
|
||||||
if (this.#scrollTop !== scrollTop) {
|
|
||||||
this.#scrollTop = scrollTop;
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearScrollCompensation() {
|
|
||||||
this.scrollCompensation = {
|
|
||||||
heightDelta: undefined,
|
|
||||||
scrollTop: undefined,
|
|
||||||
monthGroup: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIntersections() {
|
|
||||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let topIntersectingMonthGroup = undefined;
|
|
||||||
for (const month of this.months) {
|
|
||||||
updateIntersectionMonthGroup(this, month);
|
|
||||||
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
|
|
||||||
topIntersectingMonthGroup = month;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
|
|
||||||
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
|
|
||||||
}
|
|
||||||
for (const month of this.months) {
|
|
||||||
if (month === this.topIntersectingMonthGroup) {
|
|
||||||
this.topIntersectingMonthGroup.percent = clamp(
|
|
||||||
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
month.percent = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearDeferredLayout(month: MonthGroup) {
|
|
||||||
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
|
|
||||||
if (hasDeferred) {
|
|
||||||
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
|
|
||||||
for (const group of month.dayGroups) {
|
|
||||||
group.deferredLayout = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #initializeMonthGroups() {
|
async #initializeMonthGroups() {
|
||||||
const timebuckets = await getTimeBuckets({
|
const timebuckets = await getTimeBuckets({
|
||||||
...authManager.params,
|
...authManager.params,
|
||||||
...this.#options,
|
...this.#options,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.months = timebuckets.map((timeBucket) => {
|
this.#months = timebuckets.map((timeBucket) => {
|
||||||
const date = new SvelteDate(timeBucket.timeBucket);
|
const date = new SvelteDate(timeBucket.timeBucket);
|
||||||
return new MonthGroup(
|
return new MonthGroup(
|
||||||
this,
|
this,
|
||||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||||
timeBucket.count,
|
timeBucket.count,
|
||||||
|
false,
|
||||||
this.#options.order,
|
this.#options.order,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.albumAssets.clear();
|
this.albumAssets.clear();
|
||||||
this.#updateViewportGeometry(false);
|
this.updateViewportGeometry(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOptions(options: TimelineManagerOptions) {
|
async updateOptions(options: TimelineManagerOptions) {
|
||||||
@@ -313,16 +136,16 @@ export class TimelineManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.initTask.reset();
|
await this.initTask.reset();
|
||||||
await this.#init(options);
|
this.#options = options;
|
||||||
this.#updateViewportGeometry(false);
|
await this.init();
|
||||||
|
this.updateViewportGeometry(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #init(options: TimelineManagerOptions) {
|
async init() {
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.months = [];
|
this.#months = [];
|
||||||
this.albumAssets.clear();
|
this.albumAssets.clear();
|
||||||
await this.initTask.execute(async () => {
|
await this.initTask.execute(async () => {
|
||||||
this.#options = options;
|
|
||||||
await this.#initializeMonthGroups();
|
await this.#initializeMonthGroups();
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
@@ -332,81 +155,20 @@ export class TimelineManager {
|
|||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateViewport(viewport: Viewport) {
|
updateViewportGeometry(changedWidth: boolean) {
|
||||||
if (viewport.height === 0 && viewport.width === 0) {
|
super.updateViewportGeometry(changedWidth);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.initTask.executed) {
|
|
||||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
|
||||||
}
|
|
||||||
|
|
||||||
const changedWidth = viewport.width !== this.viewportWidth;
|
|
||||||
this.viewportHeight = viewport.height;
|
|
||||||
this.viewportWidth = viewport.width;
|
|
||||||
this.#updateViewportGeometry(changedWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateViewportGeometry(changedWidth: boolean) {
|
|
||||||
if (!this.isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const month of this.months) {
|
|
||||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
|
||||||
}
|
|
||||||
this.updateIntersections();
|
|
||||||
this.#createScrubberMonths();
|
this.#createScrubberMonths();
|
||||||
}
|
}
|
||||||
|
|
||||||
#createScrubberMonths() {
|
#createScrubberMonths() {
|
||||||
this.scrubberMonths = this.months.map((month) => ({
|
this.scrubberMonths = this.segments.map((month) => ({
|
||||||
assetCount: month.assetsCount,
|
assetCount: month.assetsCount,
|
||||||
year: month.yearMonth.year,
|
year: month.yearMonth.year,
|
||||||
month: month.yearMonth.month,
|
month: month.yearMonth.month,
|
||||||
title: month.monthGroupTitle,
|
title: month.monthGroupTitle,
|
||||||
height: month.height,
|
height: month.height,
|
||||||
}));
|
}));
|
||||||
this.scrubberTimelineHeight = this.timelineHeight;
|
this.scrubberTimelineHeight = this.streamViewerHeight;
|
||||||
}
|
|
||||||
|
|
||||||
createLayoutOptions() {
|
|
||||||
const viewportWidth = this.viewportWidth;
|
|
||||||
|
|
||||||
return {
|
|
||||||
spacing: 2,
|
|
||||||
heightTolerance: 0.15,
|
|
||||||
rowHeight: this.#rowHeight,
|
|
||||||
rowWidth: Math.floor(viewportWidth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
|
||||||
let cancelable = true;
|
|
||||||
if (options) {
|
|
||||||
cancelable = options.cancelable;
|
|
||||||
}
|
|
||||||
const monthGroup = getMonthGroupByDate(this, yearMonth);
|
|
||||||
if (!monthGroup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (monthGroup.loader?.executed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
|
||||||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
|
||||||
}, cancelable);
|
|
||||||
if (result === 'LOADED') {
|
|
||||||
updateIntersectionMonthGroup(this, monthGroup);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addAssets(assets: TimelineAsset[]) {
|
addAssets(assets: TimelineAsset[]) {
|
||||||
@@ -442,7 +204,7 @@ export class TimelineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||||
await this.loadMonthGroup(yearMonth, options);
|
await this.loadSegment(getSegmentIdentifier(yearMonth), options);
|
||||||
return getMonthGroupByDate(this, yearMonth);
|
return getMonthGroupByDate(this, yearMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +222,7 @@ export class TimelineManager {
|
|||||||
let accumulatedCount = 0;
|
let accumulatedCount = 0;
|
||||||
|
|
||||||
let randomMonth: MonthGroup | undefined = undefined;
|
let randomMonth: MonthGroup | undefined = undefined;
|
||||||
for (const month of this.months) {
|
for (const month of this.segments) {
|
||||||
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
||||||
randomMonth = month;
|
randomMonth = month;
|
||||||
break;
|
break;
|
||||||
@@ -471,7 +233,7 @@ export class TimelineManager {
|
|||||||
if (!randomMonth) {
|
if (!randomMonth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.loadMonthGroup(randomMonth.yearMonth, { cancelable: false });
|
await this.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false });
|
||||||
|
|
||||||
let randomDay: DayGroup | undefined = undefined;
|
let randomDay: DayGroup | undefined = undefined;
|
||||||
for (const day of randomMonth.dayGroups) {
|
for (const day of randomMonth.dayGroups) {
|
||||||
@@ -524,14 +286,14 @@ export class TimelineManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshLayout() {
|
refreshLayout() {
|
||||||
for (const month of this.months) {
|
for (const month of this.segments) {
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
month.updateGeometry({ invalidateHeight: true });
|
||||||
}
|
}
|
||||||
this.updateIntersections();
|
this.updateIntersections();
|
||||||
}
|
}
|
||||||
|
|
||||||
getFirstAsset(): TimelineAsset | undefined {
|
getFirstAsset(): TimelineAsset | undefined {
|
||||||
return this.months[0]?.getFirstAsset();
|
return this.segments[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLaterAsset(
|
async getLaterAsset(
|
||||||
@@ -553,7 +315,7 @@ export class TimelineManager {
|
|||||||
if (!monthGroup) {
|
if (!monthGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.loadMonthGroup(dateTime, { cancelable: false });
|
await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
|
||||||
const asset = monthGroup.findClosest(dateTime);
|
const asset = monthGroup.findClosest(dateTime);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
return asset;
|
return asset;
|
||||||
@@ -579,3 +341,13 @@ export class TimelineManager {
|
|||||||
return this.#options.order ?? AssetOrder.Desc;
|
return this.#options.order ?? AssetOrder.Desc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({
|
||||||
|
get id() {
|
||||||
|
return yearMonth.year + '-' + yearMonth.month;
|
||||||
|
},
|
||||||
|
matches: (segment: MonthGroup) => {
|
||||||
|
return (
|
||||||
|
segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export class ViewerAsset {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = this.#group.monthGroup.timelineManager;
|
const store = this.#group.monthGroup.assetStreamManager;
|
||||||
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
||||||
|
|
||||||
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
||||||
|
|||||||
@@ -512,8 +512,8 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
|
|||||||
isSelectingAllAssets.set(true);
|
isSelectingAllAssets.set(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const monthGroup of timelineManager.months) {
|
for (const monthGroup of timelineManager.segments) {
|
||||||
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
await monthGroup.load(false);
|
||||||
|
|
||||||
if (!get(isSelectingAllAssets)) {
|
if (!get(isSelectingAllAssets)) {
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export enum TaskStatus {
|
||||||
|
DONE,
|
||||||
|
WAITED,
|
||||||
|
CANCELED,
|
||||||
|
LOADED,
|
||||||
|
ERRORED,
|
||||||
|
}
|
||||||
export class CancellableTask {
|
export class CancellableTask {
|
||||||
cancelToken: AbortController | null = null;
|
cancelToken: AbortController | null = null;
|
||||||
cancellable: boolean = true;
|
cancellable: boolean = true;
|
||||||
@@ -32,18 +39,18 @@ export class CancellableTask {
|
|||||||
|
|
||||||
async waitUntilCompletion() {
|
async waitUntilCompletion() {
|
||||||
if (this.executed) {
|
if (this.executed) {
|
||||||
return 'DONE';
|
return TaskStatus.DONE;
|
||||||
}
|
}
|
||||||
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
// if there is a cancel token, task is currently executing, so wait on the promise. If it
|
||||||
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
// isn't, then the task is in new state, it hasn't been loaded, nor has it been executed.
|
||||||
// in either case, we wait on the promise.
|
// in either case, we wait on the promise.
|
||||||
await this.complete;
|
await this.complete;
|
||||||
return 'WAITED';
|
return TaskStatus.WAITED;
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||||
if (this.executed) {
|
if (this.executed) {
|
||||||
return 'DONE';
|
return TaskStatus.DONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if promise is pending, wait on previous request instead.
|
// if promise is pending, wait on previous request instead.
|
||||||
@@ -54,7 +61,7 @@ export class CancellableTask {
|
|||||||
this.cancellable = cancellable;
|
this.cancellable = cancellable;
|
||||||
}
|
}
|
||||||
await this.complete;
|
await this.complete;
|
||||||
return 'WAITED';
|
return TaskStatus.WAITED;
|
||||||
}
|
}
|
||||||
this.cancellable = cancellable;
|
this.cancellable = cancellable;
|
||||||
const cancelToken = (this.cancelToken = new AbortController());
|
const cancelToken = (this.cancelToken = new AbortController());
|
||||||
@@ -62,18 +69,18 @@ export class CancellableTask {
|
|||||||
try {
|
try {
|
||||||
await f(cancelToken.signal);
|
await f(cancelToken.signal);
|
||||||
if (cancelToken.signal.aborted) {
|
if (cancelToken.signal.aborted) {
|
||||||
return 'CANCELED';
|
return TaskStatus.CANCELED;
|
||||||
}
|
}
|
||||||
this.#transitionToExecuted();
|
this.#transitionToExecuted();
|
||||||
return 'LOADED';
|
return TaskStatus.LOADED;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if ((error as any).name === 'AbortError') {
|
if ((error as any).name === 'AbortError') {
|
||||||
// abort error is not treated as an error, but as a cancellation.
|
// abort error is not treated as an error, but as a cancellation.
|
||||||
return 'CANCELED';
|
return TaskStatus.CANCELED;
|
||||||
}
|
}
|
||||||
this.#transitionToErrored(error);
|
this.#transitionToErrored(error);
|
||||||
return 'ERRORED';
|
return TaskStatus.ERRORED;
|
||||||
} finally {
|
} finally {
|
||||||
this.cancelToken = null;
|
this.cancelToken = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ export function formatGroupTitle(_date: DateTime): string {
|
|||||||
return getDateLocaleString(date, { locale: get(locale) });
|
return getDateLocaleString(date, { locale: get(locale) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatGroupTitleFull = (_date: DateTime): string => {
|
||||||
|
if (!_date.isValid) {
|
||||||
|
return _date.toString();
|
||||||
|
}
|
||||||
|
const date = _date as DateTime<true>;
|
||||||
|
return getDateLocaleString(date);
|
||||||
|
};
|
||||||
|
|
||||||
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
||||||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
const asset =
|
const asset =
|
||||||
$slideshowNavigation === SlideshowNavigation.Shuffle
|
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||||
? await timelineManager.getRandomAsset()
|
? await timelineManager.getRandomAsset()
|
||||||
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
|
: timelineManager.segments[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user