feat(web): lighter timeline buckets

This commit is contained in:
Min Idzelis 2025-04-19 22:43:08 +00:00
parent 242a559e0f
commit 5a8f9f3b5c
47 changed files with 531 additions and 406 deletions

21
.vscode/settings.json vendored
View File

@ -8,7 +8,11 @@
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.formatOnSave": true "editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.removeUnusedImports": "explicit",
"source.organizeImports": "explicit"
}
}, },
"[css]": { "[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
@ -17,13 +21,14 @@
}, },
"[svelte]": { "[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode", "editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2 "editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.removeUnusedImports": "explicit",
"source.organizeImports": "explicit"
}
}, },
"svelte.enable-ts-plugin": true, "svelte.enable-ts-plugin": true,
"eslint.validate": [ "eslint.validate": ["javascript", "svelte"],
"javascript",
"svelte"
],
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"[dart]": { "[dart]": {
"editor.formatOnSave": true, "editor.formatOnSave": true,
@ -34,9 +39,7 @@
"editor.wordBasedSuggestions": "off", "editor.wordBasedSuggestions": "off",
"editor.defaultFormatter": "Dart-Code.dart-code" "editor.defaultFormatter": "Dart-Code.dart-code"
}, },
"cSpell.words": [ "cSpell.words": ["immich"],
"immich"
],
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"*.ts": "${capture}.spec.ts,${capture}.mock.ts", "*.ts": "${capture}.spec.ts,${capture}.mock.ts",

View File

@ -1,25 +1,25 @@
<script lang="ts"> <script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte'; import AssetGrid from '../photos-page/asset-grid.svelte';
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte'; import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
import ThemeButton from '../shared-components/theme-button.svelte'; import ThemeButton from '../shared-components/theme-button.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte'; import AlbumSummary from './album-summary.svelte';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props { interface Props {
sharedLink: SharedLinkResponseDto; sharedLink: SharedLinkResponseDto;
@ -36,7 +36,7 @@
$effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order })); $effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order }));
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
dragAndDropFilesStore.subscribe((value) => { dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) { if (value.isDragging && value.files.length > 0) {

View File

@ -1,18 +1,19 @@
import type { AssetAction } from '$lib/constants'; import type { AssetAction } from '$lib/constants';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import type { AlbumResponseDto } from '@immich/sdk';
type ActionMap = { type ActionMap = {
[AssetAction.ARCHIVE]: { asset: AssetResponseDto }; [AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: AssetResponseDto }; [AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.FAVORITE]: { asset: AssetResponseDto }; [AssetAction.FAVORITE]: { asset: TimelineAsset };
[AssetAction.UNFAVORITE]: { asset: AssetResponseDto }; [AssetAction.UNFAVORITE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: AssetResponseDto }; [AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: AssetResponseDto }; [AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: AssetResponseDto }; [AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.ADD]: { asset: AssetResponseDto }; [AssetAction.ADD]: { asset: TimelineAsset };
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto }; [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
}; };
export type Action = { export type Action = {

View File

@ -6,6 +6,7 @@
import Portal from '$lib/components/shared-components/portal/portal.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -24,14 +25,14 @@
showSelectionModal = false; showSelectionModal = false;
const album = await addAssetsToNewAlbum(albumName, [asset.id]); const album = await addAssetsToNewAlbum(albumName, [asset.id]);
if (album) { if (album) {
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album }); onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
} }
}; };
const handleAddToAlbum = async (album: AlbumResponseDto) => { const handleAddToAlbum = async (album: AlbumResponseDto) => {
showSelectionModal = false; showSelectionModal = false;
await addAssetsToAlbum(album.id, [asset.id]); await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album }); onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
}; };
</script> </script>

View File

@ -4,6 +4,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { toggleArchive } from '$lib/utils/asset-utils'; import { toggleArchive } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -18,11 +19,11 @@
const onArchive = async () => { const onArchive = async () => {
if (!asset.isArchived) { if (!asset.isArchived) {
preAction({ type: AssetAction.ARCHIVE, asset }); preAction({ type: AssetAction.ARCHIVE, asset: toTimelineAsset(asset) });
} }
const updatedAsset = await toggleArchive(asset); const updatedAsset = await toggleArchive(asset);
if (updatedAsset) { if (updatedAsset) {
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset }); onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: toTimelineAsset(asset) });
} }
}; };
</script> </script>

View File

@ -11,6 +11,7 @@
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk'; import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js'; import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -42,9 +43,9 @@
const trashAsset = async () => { const trashAsset = async () => {
try { try {
preAction({ type: AssetAction.TRASH, asset }); preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } }); await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
onAction({ type: AssetAction.TRASH, asset }); onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
notificationController.show({ notificationController.show({
message: $t('moved_to_trash'), message: $t('moved_to_trash'),
@ -58,7 +59,7 @@
const deleteAsset = async () => { const deleteAsset = async () => {
try { try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } }); await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset }); onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
notificationController.show({ notificationController.show({
message: $t('permanently_deleted_asset'), message: $t('permanently_deleted_asset'),

View File

@ -7,6 +7,7 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk'; import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiHeart, mdiHeartOutline } from '@mdi/js'; import { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -30,7 +31,10 @@
asset = { ...asset, isFavorite: data.isFavorite }; asset = { ...asset, isFavorite: data.isFavorite };
onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset }); onAction({
type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE,
asset: toTimelineAsset(asset),
});
notificationController.show({ notificationController.show({
type: NotificationType.Info, type: NotificationType.Info,

View File

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils'; import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk'; import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { mdiPinOutline } from '@mdi/js'; import { mdiPinOutline } from '@mdi/js';
import type { OnAction } from './action';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import type { OnAction } from './action';
interface Props { interface Props {
stack: StackResponseDto; stack: StackResponseDto;
@ -29,7 +30,7 @@
const keptAsset = await keepThisDeleteOthers(asset, stack); const keptAsset = await keepThisDeleteOthers(asset, stack);
if (keptAsset) { if (keptAsset) {
onAction({ type: AssetAction.UNSTACK, assets: [keptAsset] }); onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
} }
}; };
</script> </script>

View File

@ -6,6 +6,7 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk'; import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiHistory } from '@mdi/js'; import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -23,7 +24,7 @@
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } }); await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false; asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset }); onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
notificationController.show({ notificationController.show({
type: NotificationType.Info, type: NotificationType.Info,

View File

@ -2,6 +2,7 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { deleteStack } from '$lib/utils/asset-utils'; import { deleteStack } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { StackResponseDto } from '@immich/sdk'; import type { StackResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js'; import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -17,7 +18,7 @@
const handleUnstack = async () => { const handleUnstack = async () => {
const unstackedAssets = await deleteStack([stack.id]); const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) { if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets }); onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((a) => toTimelineAsset(a)) });
} }
}; };
</script> </script>

View File

@ -13,8 +13,9 @@ describe('AssetViewerNavBar component', () => {
showDownloadButton: false, showDownloadButton: false,
showMotionPlayButton: false, showMotionPlayButton: false,
showShareButton: false, showShareButton: false,
preAction: () => {},
onZoomImage: () => {}, onZoomImage: () => {},
onCopyImage: () => {}, onCopyImage: async () => {},
onAction: () => {}, onAction: () => {},
onRunJob: () => {}, onRunJob: () => {},
onPlaySlideshow: () => {}, onPlaySlideshow: () => {},

View File

@ -15,6 +15,7 @@
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils'; import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
AssetJobName, AssetJobName,
AssetTypeEnum, AssetTypeEnum,
@ -52,7 +53,7 @@
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[]; preloadAssets?: { id: string }[];
showNavigation?: boolean; showNavigation?: boolean;
withStacked?: boolean; withStacked?: boolean;
isShared?: boolean; isShared?: boolean;
@ -62,7 +63,7 @@
onAction?: OnAction | undefined; onAction?: OnAction | undefined;
reactions?: ActivityResponseDto[]; reactions?: ActivityResponseDto[];
showCloseButton?: boolean; showCloseButton?: boolean;
onClose: (dto: { asset: AssetResponseDto }) => void; onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>; onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>; onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<AssetResponseDto | undefined>; onRandom: () => Promise<AssetResponseDto | undefined>;
@ -267,7 +268,7 @@
}; };
const closeViewer = () => { const closeViewer = () => {
onClose({ asset }); onClose(asset);
}; };
const closeEditor = () => { const closeEditor = () => {
@ -605,8 +606,8 @@
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }} imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs" brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id} dimmed={stackedAsset.id !== asset.id}
asset={stackedAsset} asset={toTimelineAsset(stackedAsset)}
onClick={(stackedAsset) => { onClick={() => {
asset = stackedAsset; asset = stackedAsset;
}} }}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}

View File

@ -10,7 +10,7 @@
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { type SwipeCustomEvent, swipe } from 'svelte-gestures'; import { type SwipeCustomEvent, swipe } from 'svelte-gestures';
@ -24,7 +24,7 @@
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[] | undefined; preloadAssets?: { id: string }[] | undefined;
element?: HTMLDivElement | undefined; element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean; haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined; sharedLink?: SharedLinkResponseDto | undefined;
@ -68,12 +68,10 @@
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => { const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: { id: string }[]) => {
for (const preloadAsset of preloadAssets || []) { for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) {
let img = new Image(); let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); img.src = getAssetUrl(preloadAsset.id, targetSize, null);
}
} }
}; };

View File

@ -4,8 +4,8 @@
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time'; import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util'; // import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,
mdiCameraBurst, mdiCameraBurst,
@ -17,22 +17,23 @@
} from '@mdi/js'; } from '@mdi/js';
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getFocusable } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
import { onMount } from 'svelte';
import { getFocusable } from '$lib/utils/focus-util';
interface Props { interface Props {
asset: AssetResponseDto; asset: TimelineAsset;
groupIndex?: number; groupIndex?: number;
thumbnailSize?: number | undefined; thumbnailSize?: number;
thumbnailWidth?: number | undefined; thumbnailWidth?: number;
thumbnailHeight?: number | undefined; thumbnailHeight?: number;
selected?: boolean; selected?: boolean;
focussed?: boolean; focussed?: boolean;
selectionCandidate?: boolean; selectionCandidate?: boolean;
@ -44,10 +45,10 @@
imageClass?: ClassValue; imageClass?: ClassValue;
brokenAssetClass?: ClassValue; brokenAssetClass?: ClassValue;
dimmed?: boolean; dimmed?: boolean;
onClick?: ((asset: AssetResponseDto) => void) | undefined; onClick?: (asset: TimelineAsset) => void;
onSelect?: ((asset: AssetResponseDto) => void) | undefined; onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
handleFocus?: (() => void) | undefined; handleFocus?: () => void;
} }
let { let {
@ -331,7 +332,7 @@
</div> </div>
{/if} {/if}
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR} {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute right-0 top-0 z-10 flex place-items-center gap-1 text-xs font-medium text-white"> <div class="absolute right-0 top-0 z-10 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pr-2 pt-2"> <span class="pr-2 pt-2">
<Icon path={mdiRotate360} size="24" /> <Icon path={mdiRotate360} size="24" />
@ -344,7 +345,7 @@
<div <div
class={[ class={[
'absolute z-10 flex place-items-center gap-1 text-xs font-medium text-white', 'absolute z-10 flex place-items-center gap-1 text-xs font-medium text-white',
asset.type == AssetTypeEnum.Image && !asset.livePhotoVideoId ? 'top-0 right-0' : 'top-7 right-1', asset.isImage && !asset.livePhotoVideoId ? 'top-0 right-0' : 'top-7 right-1',
]} ]}
> >
<span class="pr-2 pt-2 flex place-items-center gap-1"> <span class="pr-2 pt-2 flex place-items-center gap-1">
@ -354,27 +355,28 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- altText={$getAltText(asset)} -->
<ImageThumbnail <ImageThumbnail
class={imageClass} class={imageClass}
{brokenAssetClass} {brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)} altText="todo"
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
curve={selected} curve={selected}
onComplete={(errored) => ((loaded = true), (thumbError = errored))} onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/> />
{#if asset.type === AssetTypeEnum.Video} {#if asset.isVideo}
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })} url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover} enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected} curve={selected}
durationInSeconds={timeToSeconds(asset.duration)} durationInSeconds={timeToSeconds(asset.duration!)}
playbackOnIconHover={!$playVideoThumbnailOnHover} playbackOnIconHover={!$playVideoThumbnailOnHover}
/> />
</div> </div>
{:else if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} {:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })} url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}

View File

@ -73,7 +73,7 @@
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
// need to include padding in the viewport for gallery // need to include padding in the viewport for gallery
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 }); const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<AssetResponseDto>();
let progressBarController: Tween<number> | undefined = $state(undefined); let progressBarController: Tween<number> | undefined = $state(undefined);
let videoPlayer: HTMLVideoElement | undefined = $state(); let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`; const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`;

View File

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { OnArchive } from '$lib/utils/actions'; import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { archiveAssets } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
interface Props { interface Props {
onArchive?: OnArchive; onArchive?: OnArchive;

View File

@ -6,9 +6,10 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils'; import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetJobName, AssetTypeEnum, runAssetJobs } from '@immich/sdk'; import { isTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { AssetJobName, AssetTypeEnum, runAssetJobs, type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props { interface Props {
jobs?: AssetJobName[]; jobs?: AssetJobName[];
@ -19,7 +20,11 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video)); let isAllVideos = $derived(
[...getOwnedAssets()].every((asset) =>
isTimelineAsset(asset) ? asset.isVideo : (asset as AssetResponseDto).type === AssetTypeEnum.Video,
),
);
const handleRunJob = async (name: AssetJobName) => { const handleRunJob = async (name: AssetJobName) => {
try { try {

View File

@ -4,11 +4,11 @@
import { getSelectedAssets } from '$lib/utils/asset-utils'; import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk'; import { updateAssets } from '@immich/sdk';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiCalendarEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props { interface Props {
menuItem?: boolean; menuItem?: boolean;
} }

View File

@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { getKey } from '$lib/utils';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { isTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js'; import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props { interface Props {
filename?: string; filename?: string;
@ -20,7 +23,11 @@
const assets = [...getAssets()]; const assets = [...getAssets()];
if (assets.length === 1) { if (assets.length === 1) {
clearSelect(); clearSelect();
await downloadFile(assets[0]); let asset: AssetResponseDto = assets[0] as AssetResponseDto;
if (isTimelineAsset(assets[0])) {
asset = await getAssetInfo({ id: assets[0].id, key: getKey() });
}
await downloadFile(asset);
return; return;
} }

View File

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import type { OnLink, OnUnlink } from '$lib/utils/actions'; import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetTypeEnum, getAssetInfo, updateAsset } from '@immich/sdk'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, updateAsset } from '@immich/sdk';
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js'; import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props { interface Props {
onLink: OnLink; onLink: OnLink;
@ -28,14 +30,14 @@
const handleLink = async () => { const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()]; let [still, motion] = [...getOwnedAssets()];
if (still.type === AssetTypeEnum.Video) { if ((still as TimelineAsset).isVideo) {
[still, motion] = [motion, still]; [still, motion] = [motion, still];
} }
try { try {
loading = true; loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } }); const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink({ still: stillResponse, motion }); onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
clearSelect(); clearSelect();
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_link_motion_video')); handleError(error, $t('errors.unable_to_link_motion_video'));
@ -46,23 +48,23 @@
const handleUnlink = async () => { const handleUnlink = async () => {
const [still] = [...getOwnedAssets()]; const [still] = [...getOwnedAssets()];
if (still) {
const motionId = still?.livePhotoVideoId; const motionId = (still as TimelineAsset).livePhotoVideoId;
if (!motionId) { if (!motionId) {
return; return;
} }
try { try {
loading = true; loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } }); const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
const motionResponse = await getAssetInfo({ id: motionId }); const motionResponse = await getAssetInfo({ id: motionId });
onUnlink({ still: stillResponse, motion: motionResponse }); onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
clearSelect(); clearSelect();
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_unlink_motion_video')); handleError(error, $t('errors.unable_to_unlink_motion_video'));
} finally { } finally {
loading = false; loading = false;
} }
}
}; };
</script> </script>

View File

@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { AssetInteraction, BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte';
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js'; import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props { interface Props {
assetStore: AssetStore; assetStore: AssetStore;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction<BaseInteractionAsset>;
} }
let { assetStore, assetInteraction }: Props = $props(); let { assetStore, assetInteraction }: Props = $props();

View File

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { stackAssets, deleteStack } from '$lib/utils/asset-utils';
import type { OnStack, OnUnstack } from '$lib/utils/actions'; import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@ -34,7 +35,7 @@
} }
const unstackedAssets = await deleteStack([stack.id]); const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) { if (unstackedAssets) {
onUnstack?.(unstackedAssets); onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
} }
clearSelect(); clearSelect();
}; };

View File

@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { import {
type AssetStore,
type AssetBucket, type AssetBucket,
assetSnapshot, assetSnapshot,
assetsSnapshot, assetsSnapshot,
type AssetStore,
isSelectingAllAssets, isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte'; } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util'; import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import type { AssetInteraction, BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly } from 'svelte/transition'; import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { scale } from 'svelte/transition';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@ -29,11 +29,11 @@
showArchiveIcon: boolean; showArchiveIcon: boolean;
bucket: AssetBucket; bucket: AssetBucket;
assetStore: AssetStore; assetStore: AssetStore;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction<BaseInteractionAsset>;
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: AssetResponseDto) => void; onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
} }
let { let {
@ -54,7 +54,7 @@
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150)); const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const onClick = (assetStore: AssetStore, assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { const onClick = (assetStore: AssetStore, assets: TimelineAsset[], groupTitle: string, asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) { if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(assetStore, asset, assets, groupTitle); assetSelectHandler(assetStore, asset, assets, groupTitle);
return; return;
@ -62,12 +62,12 @@
void navigate({ targetRoute: 'current', assetId: asset.id }); void navigate({ targetRoute: 'current', assetId: asset.id });
}; };
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = ( const assetSelectHandler = (
assetStore: AssetStore, assetStore: AssetStore,
asset: AssetResponseDto, asset: TimelineAsset,
assetsInDateGroup: AssetResponseDto[], assetsInDateGroup: TimelineAsset[],
groupTitle: string, groupTitle: string,
) => { ) => {
onSelectAssets(asset); onSelectAssets(asset);
@ -91,7 +91,7 @@
} }
}; };
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => { const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group // Show multi select icon on hover on date group
hoveredDateGroup = groupTitle; hoveredDateGroup = groupTitle;
@ -100,7 +100,7 @@
} }
}; };
const assetOnFocusHandler = (asset: AssetResponseDto) => { const assetOnFocusHandler = (asset: TimelineAsset) => {
assetInteraction.focussedAssetId = asset.id; assetInteraction.focussedAssetId = asset.id;
}; };
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) { function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {

View File

@ -1,10 +1,21 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import {
AssetBucket,
assetsSnapshot,
AssetStore,
isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
@ -13,19 +24,14 @@
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte'; import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
interface Props { interface Props {
isSelectionMode?: boolean; isSelectionMode?: boolean;
@ -35,7 +41,7 @@
additionally, update the page location/url with the asset as the asset-grid is scrolled */ additionally, update the page location/url with the asset as the asset-grid is scrolled */
enableRouting: boolean; enableRouting: boolean;
assetStore: AssetStore; assetStore: AssetStore;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction<TimelineAsset>;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null;
withStacked?: boolean; withStacked?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
@ -43,7 +49,7 @@
album?: AlbumResponseDto | null; album?: AlbumResponseDto | null;
person?: PersonResponseDto | null; person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onSelect?: (asset: AssetResponseDto) => void; onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void; onEscape?: () => void;
children?: Snippet; children?: Snippet;
empty?: Snippet; empty?: Snippet;
@ -352,7 +358,7 @@
} }
}; };
const handleSelectAsset = (asset: AssetResponseDto) => { const handleSelectAsset = (asset: TimelineAsset) => {
if (!assetStore.albumAssets.has(asset.id)) { if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset); assetInteraction.selectAsset(asset);
} }
@ -363,7 +369,8 @@
if (previousAsset) { if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset); const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []); const asset = await getAssetInfo({ id: previousAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id }); await navigate({ targetRoute: 'current', assetId: previousAsset.id });
} }
@ -375,7 +382,8 @@
if (nextAsset) { if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset); const preloadAsset = await assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []); const asset = await getAssetInfo({ id: nextAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id }); await navigate({ targetRoute: 'current', assetId: nextAsset.id });
} }
@ -387,14 +395,14 @@
if (randomAsset) { if (randomAsset) {
const preloadAsset = await assetStore.getNextAsset(randomAsset); const preloadAsset = await assetStore.getNextAsset(randomAsset);
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []); const asset = await getAssetInfo({ id: randomAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: randomAsset.id }); await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
} }
return randomAsset;
}; };
const handleClose = async ({ asset }: { asset: AssetResponseDto }) => { const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
showSkeleton = true; showSkeleton = true;
$gridScrollTarget = { at: asset.id }; $gridScrollTarget = { at: asset.id };
@ -410,7 +418,7 @@
case AssetAction.ARCHIVE: { case AssetAction.ARCHIVE: {
// find the next asset to show or close the viewer // find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); (await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one // delete after find the next one
assetStore.removeAssets([action.asset.id]); assetStore.removeAssets([action.asset.id]);
@ -439,7 +447,7 @@
} }
}; };
let lastAssetMouseEvent: AssetResponseDto | null = $state(null); let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
@ -469,14 +477,14 @@
} }
}; };
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) { if (asset) {
selectAssetCandidates(asset); selectAssetCandidates(asset);
} }
lastAssetMouseEvent = asset; lastAssetMouseEvent = asset;
}; };
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => { const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => {
if (assetInteraction.selectedGroup.has(group)) { if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group); assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) { for (const asset of assets) {
@ -496,7 +504,7 @@
} }
}; };
const handleSelectAssets = async (asset: AssetResponseDto) => { const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) { if (!asset) {
return; return;
} }
@ -579,7 +587,7 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset); assetInteraction.setAssetSelectionStart(deselect ? null : asset);
}; };
const selectAssetCandidates = (endAsset: AssetResponseDto) => { const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) { if (!shiftKeyIsDown) {
return; return;
} }

View File

@ -4,8 +4,8 @@
export interface AssetControlContext { export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive. // Wrap assets in a function, because context isn't reactive.
getAssets: () => AssetResponseDto[]; // All assets includes partners' assets getAssets: () => BaseInteractionAsset[]; // All assets includes partners' assets
getOwnedAssets: () => AssetResponseDto[]; // Only assets owned by the user getOwnedAssets: () => BaseInteractionAsset[]; // Only assets owned by the user
clearSelect: () => void; clearSelect: () => void;
} }
@ -14,13 +14,13 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import type { AssetResponseDto } from '@immich/sdk'; import type { BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: BaseInteractionAsset[];
clearSelect: () => void; clearSelect: () => void;
ownerId?: string | undefined; ownerId?: string | undefined;
children?: Snippet; children?: Snippet;

View File

@ -7,7 +7,7 @@
import { downloadArchive } from '$lib/utils/asset-utils'; import { downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk'; import { addSharedLinkAssets, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';
@ -31,7 +31,7 @@
let { sharedLink = $bindable(), isOwned }: Props = $props(); let { sharedLink = $bindable(), isOwned }: Props = $props();
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<AssetResponseDto>();
let assets = $derived(sharedLink.assets); let assets = $derived(sharedLink.assets);

View File

@ -1,31 +1,32 @@
<script lang="ts"> <script lang="ts">
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
import Portal from '../portal/portal.svelte';
import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import Portal from '../portal/portal.svelte';
import { debounce } from 'lodash-es'; import ShowShortcuts from '../show-shortcuts.svelte';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction<AssetResponseDto>;
disableAssetSelect?: boolean; disableAssetSelect?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
viewport: Viewport; viewport: Viewport;
@ -481,18 +482,18 @@
> >
<Thumbnail <Thumbnail
readonly={disableAssetSelect} readonly={disableAssetSelect}
onClick={(asset) => { onClick={() => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
handleSelectAssets(asset); handleSelectAssets(asset);
return; return;
} }
void viewAssetHandler(asset); void viewAssetHandler(asset);
}} }}
onSelect={(asset) => handleSelectAssets(asset)} onSelect={() => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)} onMouseEvent={() => assetMouseEventHandler(asset)}
handleFocus={() => assetOnFocusHandler(asset)} handleFocus={() => assetOnFocusHandler(asset)}
{showArchiveIcon} {showArchiveIcon}
{asset} asset={toTimelineAsset(asset)}
selected={assetInteraction.hasSelectedAsset(asset.id)} selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
focussed={assetInteraction.isFocussedAsset(asset.id)} focussed={assetInteraction.isFocussedAsset(asset.id)}

View File

@ -1,10 +1,10 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction, type BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte';
import { resetSavedUser, user } from '$lib/stores/user.store'; import { resetSavedUser, user } from '$lib/stores/user.store';
import { assetFactory } from '@test-data/factories/asset-factory'; import { assetFactory } from '@test-data/factories/asset-factory';
import { userAdminFactory } from '@test-data/factories/user-factory'; import { userAdminFactory } from '@test-data/factories/user-factory';
describe('AssetInteraction', () => { describe('AssetInteraction', () => {
let assetInteraction: AssetInteraction; let assetInteraction: AssetInteraction<BaseInteractionAsset>;
beforeEach(() => { beforeEach(() => {
assetInteraction = new AssetInteraction(); assetInteraction = new AssetInteraction();

View File

@ -1,19 +1,27 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk'; import type { AssetStackResponseDto, UserAdminResponseDto } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { fromStore } from 'svelte/store'; import { fromStore } from 'svelte/store';
export class AssetInteraction { export type BaseInteractionAsset = {
selectedAssets = $state<AssetResponseDto[]>([]); id: string;
isTrashed: boolean;
isArchived: boolean;
isFavorite: boolean;
ownerId: string;
stack?: AssetStackResponseDto | null | undefined;
};
export class AssetInteraction<T extends BaseInteractionAsset> {
selectedAssets = $state<T[]>([]);
hasSelectedAsset(assetId: string) { hasSelectedAsset(assetId: string) {
return this.selectedAssets.some((asset) => asset.id === assetId); return this.selectedAssets.some((asset) => asset.id === assetId);
} }
selectedGroup = new SvelteSet<string>(); selectedGroup = new SvelteSet<string>();
assetSelectionCandidates = $state<AssetResponseDto[]>([]); assetSelectionCandidates = $state<T[]>([]);
hasSelectionCandidate(assetId: string) { hasSelectionCandidate(assetId: string) {
return this.assetSelectionCandidates.some((asset) => asset.id === assetId); return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
} }
assetSelectionStart = $state<AssetResponseDto | null>(null); assetSelectionStart = $state<T | null>(null);
focussedAssetId = $state<string | null>(null); focussedAssetId = $state<string | null>(null);
selectionActive = $derived(this.selectedAssets.length > 0); selectionActive = $derived(this.selectedAssets.length > 0);
@ -25,13 +33,13 @@ export class AssetInteraction {
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId)); isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
selectAsset(asset: AssetResponseDto) { selectAsset(asset: T) {
if (!this.hasSelectedAsset(asset.id)) { if (!this.hasSelectedAsset(asset.id)) {
this.selectedAssets.push(asset); this.selectedAssets.push(asset);
} }
} }
selectAssets(assets: AssetResponseDto[]) { selectAssets(assets: T[]) {
for (const asset of assets) { for (const asset of assets) {
this.selectAsset(asset); this.selectAsset(asset);
} }
@ -52,11 +60,11 @@ export class AssetInteraction {
this.selectedGroup.delete(group); this.selectedGroup.delete(group);
} }
setAssetSelectionStart(asset: AssetResponseDto | null) { setAssetSelectionStart(asset: T | null) {
this.assetSelectionStart = asset; this.assetSelectionStart = asset;
} }
setAssetSelectionCandidates(assets: AssetResponseDto[]) { setAssetSelectionCandidates(assets: T[]) {
this.assetSelectionCandidates = assets; this.assetSelectionCandidates = assets;
} }

View File

@ -5,11 +5,11 @@ import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() { function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>(); const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<AssetResponseDto[]>([]); const preloadAssets = writable<{ id: string }[]>([]);
const viewState = writable<boolean>(false); const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => { const setAsset = (asset: AssetResponseDto, assetsToPreload: { id: string }[] = []) => {
preloadAssets.set(assetsToPreload); preloadAssets.set(assetsToPreload);
viewingAssetStoreState.set(asset); viewingAssetStoreState.set(asset);
viewState.set(true); viewState.set(true);

View File

@ -1,7 +1,7 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { AbortError } from '$lib/utils'; import { AbortError } from '$lib/utils';
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory'; import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory';
import { AssetStore } from './assets-store.svelte'; import { AssetStore } from './assets-store.svelte';
describe('AssetStore', () => { describe('AssetStore', () => {
@ -149,9 +149,8 @@ describe('AssetStore', () => {
}); });
it('adds assets to new bucket', () => { it('adds assets to new bucket', () => {
const asset = assetFactory.build({ const asset = timelineAssetFactory.build({
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
fileCreatedAt: '2024-01-20T12:00:00.000Z',
}); });
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@ -163,9 +162,8 @@ describe('AssetStore', () => {
}); });
it('adds assets to existing bucket', () => { it('adds assets to existing bucket', () => {
const [assetOne, assetTwo] = assetFactory.buildList(2, { const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
fileCreatedAt: '2024-01-20T12:00:00.000Z',
}); });
assetStore.addAssets([assetOne]); assetStore.addAssets([assetOne]);
assetStore.addAssets([assetTwo]); assetStore.addAssets([assetTwo]);
@ -177,16 +175,13 @@ describe('AssetStore', () => {
}); });
it('orders assets in buckets by descending date', () => { it('orders assets in buckets by descending date', () => {
const assetOne = assetFactory.build({ const assetOne = timelineAssetFactory.build({
fileCreatedAt: '2024-01-20T12:00:00.000Z',
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
}); });
const assetTwo = assetFactory.build({ const assetTwo = timelineAssetFactory.build({
fileCreatedAt: '2024-01-15T12:00:00.000Z',
localDateTime: '2024-01-15T12:00:00.000Z', localDateTime: '2024-01-15T12:00:00.000Z',
}); });
const assetThree = assetFactory.build({ const assetThree = timelineAssetFactory.build({
fileCreatedAt: '2024-01-16T12:00:00.000Z',
localDateTime: '2024-01-16T12:00:00.000Z', localDateTime: '2024-01-16T12:00:00.000Z',
}); });
assetStore.addAssets([assetOne, assetTwo, assetThree]); assetStore.addAssets([assetOne, assetTwo, assetThree]);
@ -200,9 +195,9 @@ describe('AssetStore', () => {
}); });
it('orders buckets by descending date', () => { it('orders buckets by descending date', () => {
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' }); const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
const assetThree = assetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' }); const assetThree = timelineAssetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo, assetThree]); assetStore.addAssets([assetOne, assetTwo, assetThree]);
expect(assetStore.buckets.length).toEqual(3); expect(assetStore.buckets.length).toEqual(3);
@ -213,7 +208,7 @@ describe('AssetStore', () => {
it('updates existing asset', () => { it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets'); const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
const asset = assetFactory.build(); const asset = timelineAssetFactory.build();
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@ -223,8 +218,8 @@ describe('AssetStore', () => {
// disabled due to the wasm Justified Layout import // disabled due to the wasm Justified Layout import
it('ignores trashed assets when isTrashed is true', async () => { it('ignores trashed assets when isTrashed is true', async () => {
const asset = assetFactory.build({ isTrashed: false }); const asset = timelineAssetFactory.build({ isTrashed: false });
const trashedAsset = assetFactory.build({ isTrashed: true }); const trashedAsset = timelineAssetFactory.build({ isTrashed: true });
const assetStore = new AssetStore(); const assetStore = new AssetStore();
await assetStore.updateOptions({ isTrashed: true }); await assetStore.updateOptions({ isTrashed: true });
@ -244,14 +239,14 @@ describe('AssetStore', () => {
}); });
it('ignores non-existing assets', () => { it('ignores non-existing assets', () => {
assetStore.updateAssets([assetFactory.build()]); assetStore.updateAssets([timelineAssetFactory.build()]);
expect(assetStore.buckets.length).toEqual(0); expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.getAssets().length).toEqual(0); expect(assetStore.getAssets().length).toEqual(0);
}); });
it('updates an asset', () => { it('updates an asset', () => {
const asset = assetFactory.build({ isFavorite: false }); const asset = timelineAssetFactory.build({ isFavorite: false });
const updatedAsset = { ...asset, isFavorite: true }; const updatedAsset = { ...asset, isFavorite: true };
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@ -264,7 +259,7 @@ describe('AssetStore', () => {
}); });
it('asset moves buckets when asset date changes', () => { it('asset moves buckets when asset date changes', () => {
const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const asset = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' }; const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@ -292,7 +287,7 @@ describe('AssetStore', () => {
}); });
it('ignores invalid IDs', () => { it('ignores invalid IDs', () => {
assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' })); assetStore.addAssets(timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
assetStore.removeAssets(['', 'invalid', '4c7d9acc']); assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
expect(assetStore.getAssets().length).toEqual(2); expect(assetStore.getAssets().length).toEqual(2);
@ -301,7 +296,7 @@ describe('AssetStore', () => {
}); });
it('removes asset from bucket', () => { it('removes asset from bucket', () => {
const [assetOne, assetTwo] = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetOne.id]); assetStore.removeAssets([assetOne.id]);
@ -311,7 +306,7 @@ describe('AssetStore', () => {
}); });
it('does not remove bucket when empty', () => { it('does not remove bucket when empty', () => {
const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); const assets = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets(assets); assetStore.addAssets(assets);
assetStore.removeAssets(assets.map((asset) => asset.id)); assetStore.removeAssets(assets.map((asset) => asset.id));
@ -334,12 +329,10 @@ describe('AssetStore', () => {
}); });
it('populated store returns first asset', () => { it('populated store returns first asset', () => {
const assetOne = assetFactory.build({ const assetOne = timelineAssetFactory.build({
fileCreatedAt: '2024-01-20T12:00:00.000Z',
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
}); });
const assetTwo = assetFactory.build({ const assetTwo = timelineAssetFactory.build({
fileCreatedAt: '2024-01-15T12:00:00.000Z',
localDateTime: '2024-01-15T12:00:00.000Z', localDateTime: '2024-01-15T12:00:00.000Z',
}); });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
@ -445,8 +438,8 @@ describe('AssetStore', () => {
}); });
it('returns the bucket index', () => { it('returns the bucket index', () => {
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z'); expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
@ -454,8 +447,8 @@ describe('AssetStore', () => {
}); });
it('ignores removed buckets', () => { it('ignores removed buckets', () => {
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetTwo.id]); assetStore.removeAssets([assetTwo.id]);

View File

@ -7,7 +7,7 @@ import {
type CommonLayoutOptions, type CommonLayoutOptions,
type CommonPosition, type CommonPosition,
} from '$lib/utils/layout-utils'; } from '$lib/utils/layout-utils';
import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util'; import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { import {
AssetOrder, AssetOrder,
@ -16,11 +16,11 @@ import {
getTimeBuckets, getTimeBuckets,
TimeBucketSize, TimeBucketSize,
type AssetResponseDto, type AssetResponseDto,
type AssetStackResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { clamp, debounce, isEqual, throttle } from 'lodash-es';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { get, writable, type Unsubscriber } from 'svelte/store'; import { get, writable, type Unsubscriber } from 'svelte/store';
import { handleError } from '../utils/handle-error'; import { handleError } from '../utils/handle-error';
@ -62,13 +62,30 @@ function updateObject(target: any, source: any): boolean {
return updated; return updated;
} }
export function assetSnapshot(asset: AssetResponseDto) { export function assetSnapshot(asset: TimelineAsset): TimelineAsset {
return $state.snapshot(asset); return $state.snapshot(asset) as TimelineAsset;
} }
export function assetsSnapshot(assets: AssetResponseDto[]) { export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] {
return assets.map((a) => $state.snapshot(a)); return assets.map((a) => $state.snapshot(a)) as TimelineAsset[];
} }
export type TimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: string;
isArchived: boolean;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
stack: AssetStackResponseDto | null;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
};
class IntersectingAsset { class IntersectingAsset {
// --- public --- // --- public ---
readonly #group: AssetDateGroup; readonly #group: AssetDateGroup;
@ -92,17 +109,17 @@ class IntersectingAsset {
}); });
position: CommonPosition | undefined = $state(); position: CommonPosition | undefined = $state();
asset: AssetResponseDto | undefined = $state(); asset: TimelineAsset | undefined = $state();
id: string | undefined = $derived(this.asset?.id); id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: AssetResponseDto) { constructor(group: AssetDateGroup, asset: TimelineAsset) {
this.#group = group; this.#group = group;
this.asset = asset; this.asset = asset;
} }
} }
type AssetOperation = (asset: AssetResponseDto) => { remove: boolean }; type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
type MoveAsset = { asset: AssetResponseDto; year: number; month: number }; type MoveAsset = { asset: TimelineAsset; year: number; month: number };
export class AssetDateGroup { export class AssetDateGroup {
// --- public // --- public
readonly bucket: AssetBucket; readonly bucket: AssetBucket;
@ -131,8 +148,8 @@ export class AssetDateGroup {
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) { sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
this.intersetingAssets.sort((a, b) => { this.intersetingAssets.sort((a, b) => {
const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC(); const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC();
const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC(); const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC();
if (sortOrder === AssetOrder.Asc) { if (sortOrder === AssetOrder.Asc) {
return aDate.diff(bDate).milliseconds; return aDate.diff(bDate).milliseconds;
@ -223,6 +240,25 @@ export type ViewportXY = Viewport & {
y: number; y: number;
}; };
class AddContext {
lookupCache: {
[dayOfMonth: number]: AssetDateGroup;
} = {};
unprocessedAssets: TimelineAsset[] = [];
changedDateGroups = new Set<AssetDateGroup>();
newDateGroups = new Set<AssetDateGroup>();
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
for (const group of this.changedDateGroups) {
group.sortAssets(sortOrder);
}
for (const group of this.newDateGroups) {
group.sortAssets(sortOrder);
}
if (this.newDateGroups.size > 0) {
bucket.sortDateGroups();
}
}
}
export class AssetBucket { export class AssetBucket {
// --- public --- // --- public ---
#intersecting: boolean = $state(false); #intersecting: boolean = $state(false);
@ -314,7 +350,7 @@ export class AssetBucket {
getAssets() { getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce // eslint-disable-next-line unicorn/no-array-reduce
return this.dateGroups.reduce( return this.dateGroups.reduce(
(accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()), (accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
[], [],
); );
} }
@ -379,56 +415,52 @@ export class AssetBucket {
} }
// note - if the assets are not part of this bucket, they will not be added // note - if the assets are not part of this bucket, they will not be added
addAssets(assets: AssetResponseDto[]) { addAssets(bucketResponse: AssetResponseDto[]) {
const lookupCache: { const addContext = new AddContext();
[dayOfMonth: number]: AssetDateGroup; for (const asset of bucketResponse) {
} = {}; const timelineAsset = toTimelineAsset(asset);
const unprocessedAssets: AssetResponseDto[] = []; this.addTimelineAsset(timelineAsset, addContext);
const changedDateGroups = new Set<AssetDateGroup>(); }
const newDateGroups = new Set<AssetDateGroup>();
for (const asset of assets) { addContext.sort(this, this.#sortOrder);
const date = DateTime.fromISO(asset.localDateTime).toUTC(); return addContext.unprocessedAssets;
}
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
const { id, localDateTime } = timelineAsset;
const date = DateTime.fromISO(localDateTime).toUTC();
const month = date.get('month'); const month = date.get('month');
const year = date.get('year'); const year = date.get('year');
if (this.month === month && this.year === year) { if (this.month === month && this.year === year) {
const day = date.get('day'); const day = date.get('day');
let dateGroup: AssetDateGroup | undefined = lookupCache[day]; let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day];
if (!dateGroup) { if (!dateGroup) {
dateGroup = this.findDateGroupByDay(day); dateGroup = this.findDateGroupByDay(day);
if (dateGroup) { if (dateGroup) {
lookupCache[day] = dateGroup; addContext.lookupCache[day] = dateGroup;
} }
} }
if (dateGroup) { if (dateGroup) {
const intersectingAsset = new IntersectingAsset(dateGroup, asset); const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) { if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`); console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
} else { } else {
dateGroup.intersetingAssets.push(intersectingAsset); dateGroup.intersetingAssets.push(intersectingAsset);
changedDateGroups.add(dateGroup); addContext.changedDateGroups.add(dateGroup);
} }
} else { } else {
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset)); dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, timelineAsset));
this.dateGroups.push(dateGroup); this.dateGroups.push(dateGroup);
lookupCache[day] = dateGroup; addContext.lookupCache[day] = dateGroup;
newDateGroups.add(dateGroup); addContext.newDateGroups.add(dateGroup);
} }
} else { } else {
unprocessedAssets.push(asset); addContext.unprocessedAssets.push(timelineAsset);
} }
} }
for (const group of changedDateGroups) {
group.sortAssets(this.#sortOrder);
}
for (const group of newDateGroups) {
group.sortAssets(this.#sortOrder);
}
if (newDateGroups.size > 0) {
this.sortDateGroups();
}
return unprocessedAssets;
}
getRandomDateGroup() { getRandomDateGroup() {
const random = Math.floor(Math.random() * this.dateGroups.length); const random = Math.floor(Math.random() * this.dateGroups.length);
return this.dateGroups[random]; return this.dateGroups[random];
@ -514,12 +546,12 @@ const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
interface AddAsset { interface AddAsset {
type: 'add'; type: 'add';
values: AssetResponseDto[]; values: TimelineAsset[];
} }
interface UpdateAsset { interface UpdateAsset {
type: 'update'; type: 'update';
values: AssetResponseDto[]; values: TimelineAsset[];
} }
interface DeleteAsset { interface DeleteAsset {
@ -701,9 +733,13 @@ export class AssetStore {
connect() { connect() {
this.#unsubscribers.push( this.#unsubscribers.push(
websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })), websocketEvents.on('on_upload_success', (asset) =>
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })), websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
websocketEvents.on('on_asset_update', (asset) => this.#addPendingChanges({ type: 'update', values: [asset] })), websocketEvents.on('on_asset_update', (asset) =>
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })), websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
); );
} }
@ -717,8 +753,8 @@ export class AssetStore {
#getPendingChangeBatches() { #getPendingChangeBatches() {
const batch: { const batch: {
add: AssetResponseDto[]; add: TimelineAsset[];
update: AssetResponseDto[]; update: TimelineAsset[];
remove: string[]; remove: string[];
} = { } = {
add: [], add: [],
@ -1042,7 +1078,7 @@ export class AssetStore {
// so no need to load the bucket, it already has assets // so no need to load the bucket, it already has assets
return; return;
} }
const assets = await getTimeBucket( const bucketResponse = await getTimeBucket(
{ {
...this.#options, ...this.#options,
timeBucket: bucketDate, timeBucket: bucketDate,
@ -1051,9 +1087,9 @@ export class AssetStore {
}, },
{ signal }, { signal },
); );
if (assets) { if (bucketResponse) {
if (this.#options.timelineAlbumId) { if (this.#options.timelineAlbumId) {
const albumAssets = await getTimeBucket( const bucketAssets = await getTimeBucket(
{ {
albumId: this.#options.timelineAlbumId, albumId: this.#options.timelineAlbumId,
timeBucket: bucketDate, timeBucket: bucketDate,
@ -1062,12 +1098,11 @@ export class AssetStore {
}, },
{ signal }, { signal },
); );
for (const asset of albumAssets) { for (const { id } of bucketAssets) {
this.albumAssets.add(asset.id); this.albumAssets.add(id);
} }
} }
const unprocessed = bucket.addAssets(bucketResponse);
const unprocessed = bucket.addAssets(assets);
if (unprocessed.length > 0) { if (unprocessed.length > 0) {
console.error( console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`, `Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
@ -1081,8 +1116,8 @@ export class AssetStore {
} }
} }
addAssets(assets: AssetResponseDto[]) { addAssets(assets: TimelineAsset[]) {
const assetsToUpdate: AssetResponseDto[] = []; const assetsToUpdate: TimelineAsset[] = [];
for (const asset of assets) { for (const asset of assets) {
if (this.isExcluded(asset)) { if (this.isExcluded(asset)) {
@ -1095,7 +1130,7 @@ export class AssetStore {
this.#addAssetsToBuckets([...notUpdated]); this.#addAssetsToBuckets([...notUpdated]);
} }
#addAssetsToBuckets(assets: AssetResponseDto[]) { #addAssetsToBuckets(assets: TimelineAsset[]) {
if (assets.length === 0) { if (assets.length === 0) {
return; return;
} }
@ -1112,7 +1147,9 @@ export class AssetStore {
bucket = new AssetBucket(this, utc, 1, this.#options.order); bucket = new AssetBucket(this, utc, 1, this.#options.order);
this.buckets.push(bucket); this.buckets.push(bucket);
} }
bucket.addAssets([asset]); const addContext = new AddContext();
bucket.addTimelineAsset(asset, addContext);
addContext.sort(bucket, this.#options.order);
updatedBuckets.add(bucket); updatedBuckets.add(bucket);
} }
@ -1138,7 +1175,7 @@ export class AssetStore {
await this.initTask.waitUntilCompletion(); await this.initTask.waitUntilCompletion();
let bucket = this.#findBucketForAsset(id); let bucket = this.#findBucketForAsset(id);
if (!bucket) { if (!bucket) {
const asset = await getAssetInfo({ id }); const asset = toTimelineAsset(await getAssetInfo({ id }));
if (!asset || this.isExcluded(asset)) { if (!asset || this.isExcluded(asset)) {
return; return;
} }
@ -1151,7 +1188,7 @@ export class AssetStore {
} }
async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) { async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) {
let date = fromLocalDateTime(localDateTime); let date = DateTime.fromISO(localDateTime).toUTC();
// Only support TimeBucketSize.Month // Only support TimeBucketSize.Month
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
const iso = date.toISO()!; const iso = date.toISO()!;
@ -1161,7 +1198,7 @@ export class AssetStore {
return this.getBucketByDate(year, month); return this.getBucketByDate(year, month);
} }
async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) { async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) {
const bucketInfo = this.#findBucketForAsset(asset.id); const bucketInfo = this.#findBucketForAsset(asset.id);
if (bucketInfo) { if (bucketInfo) {
return bucketInfo; return bucketInfo;
@ -1195,7 +1232,7 @@ export class AssetStore {
const changedBuckets = new Set<AssetBucket>(); const changedBuckets = new Set<AssetBucket>();
let idsToProcess = new Set(ids); let idsToProcess = new Set(ids);
const idsProcessed = new Set<string>(); const idsProcessed = new Set<string>();
const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = []; const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = [];
for (const bucket of this.buckets) { for (const bucket of this.buckets) {
if (idsToProcess.size > 0) { if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation); const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
@ -1238,8 +1275,8 @@ export class AssetStore {
this.#runAssetOperation(new Set(ids), operation); this.#runAssetOperation(new Set(ids), operation);
} }
updateAssets(assets: AssetResponseDto[]) { updateAssets(assets: TimelineAsset[]) {
const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset])); const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => { const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
updateObject(asset, lookup.get(asset.id)); updateObject(asset, lookup.get(asset.id));
return { remove: false }; return { remove: false };
@ -1261,11 +1298,11 @@ export class AssetStore {
this.updateIntersections(); this.updateIntersections();
} }
getFirstAsset(): AssetResponseDto | undefined { getFirstAsset(): TimelineAsset | undefined {
return this.buckets[0]?.getFirstAsset(); return this.buckets[0]?.getFirstAsset();
} }
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset); let bucket = await this.#getBucketInfoForAsset(asset);
if (!bucket) { if (!bucket) {
return; return;
@ -1308,7 +1345,7 @@ export class AssetStore {
} }
} }
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { async getNextAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset); let bucket = await this.#getBucketInfoForAsset(asset);
if (!bucket) { if (!bucket) {
return; return;
@ -1347,7 +1384,7 @@ export class AssetStore {
} }
} }
isExcluded(asset: AssetResponseDto) { isExcluded(asset: TimelineAsset) {
return ( return (
isMismatched(this.#options.isArchived, asset.isArchived) || isMismatched(this.#options.isArchived, asset.isArchived) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) || isMismatched(this.#options.isFavorite, asset.isFavorite) ||

View File

@ -1,20 +1,20 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import type { AssetStore } from '$lib/stores/assets-store.svelte'; import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
import type { StackResponse } from '$lib/utils/asset-utils'; import type { StackResponse } from '$lib/utils/asset-utils';
import { deleteAssets as deleteBulk, type AssetResponseDto } from '@immich/sdk'; import { deleteAssets as deleteBulk } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { handleError } from './handle-error'; import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void; export type OnDelete = (assetIds: string[]) => void;
export type OnRestore = (ids: string[]) => void; export type OnRestore = (ids: string[]) => void;
export type OnLink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnUnlink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnAddToAlbum = (ids: string[], albumId: string) => void; export type OnAddToAlbum = (ids: string[], albumId: string) => void;
export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (result: StackResponse) => void; export type OnStack = (result: StackResponse) => void;
export type OnUnstack = (assets: AssetResponseDto[]) => void; export type OnUnstack = (assets: TimelineAsset[]) => void;
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
const $t = get(t); const $t = get(t);
@ -64,11 +64,11 @@ export function updateStackedAssetInTimeline(assetStore: AssetStore, { stack, to
* @param assetStore - The asset store to update. * @param assetStore - The asset store to update.
* @param assets - The array of asset response DTOs to update in the asset store. * @param assets - The array of asset response DTOs to update in the asset store.
*/ */
export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: AssetResponseDto[]) { export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: TimelineAsset[]) {
assetStore.updateAssetOperation( assetStore.updateAssetOperation(
assets.map((asset) => asset.id), assets.map((asset) => asset.id),
(asset) => { (asset) => {
asset.stack = undefined; asset.stack = null;
return { remove: false }; return { remove: false };
}, },
); );

View File

@ -3,7 +3,7 @@ import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte';
import type { InterpolationValues } from '$lib/components/i18n/format-message'; import type { InterpolationValues } from '$lib/components/i18n/format-message';
import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction, BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte';
import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte'; import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
import { downloadManager } from '$lib/stores/download-store.svelte'; import { downloadManager } from '$lib/stores/download-store.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
@ -364,7 +364,7 @@ export const getAssetType = (type: AssetTypeEnum) => {
} }
}; };
export const getSelectedAssets = (assets: AssetResponseDto[], user: UserResponseDto | null): string[] => { export const getSelectedAssets = (assets: BaseInteractionAsset[], user: UserResponseDto | null): string[] => {
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id); const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length; const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
@ -383,7 +383,7 @@ export type StackResponse = {
toDeleteIds: string[]; toDeleteIds: string[];
}; };
export const stackAssets = async (assets: AssetResponseDto[], showNotification = true): Promise<StackResponse> => { export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => {
if (assets.length < 2) { if (assets.length < 2) {
return { stack: undefined, toDeleteIds: [] }; return { stack: undefined, toDeleteIds: [] };
} }
@ -403,9 +403,9 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification =
}); });
} }
for (const [index, asset] of assets.entries()) { // for (const [index, asset] of assets.entries()) {
asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null; // asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null;
} // }
return { return {
stack, stack,
@ -467,7 +467,10 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S
} }
}; };
export const selectAllAssets = async (assetStore: AssetStore, assetInteraction: AssetInteraction) => { export const selectAllAssets = async (
assetStore: AssetStore,
assetInteraction: AssetInteraction<BaseInteractionAsset>,
) => {
if (get(isSelectingAllAssets)) { if (get(isSelectingAllAssets)) {
// Selection is already ongoing // Selection is already ongoing
return; return;
@ -495,7 +498,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
} }
}; };
export const cancelMultiselect = (assetInteraction: AssetInteraction) => { export const cancelMultiselect = (assetInteraction: AssetInteraction<BaseInteractionAsset>) => {
isSelectingAllAssets.set(false); isSelectingAllAssets.set(false);
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
}; };
@ -523,7 +526,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
return asset; return asset;
}; };
export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean) => { export const archiveAssets = async (assets: { id: string }[], archive: boolean) => {
const isArchived = archive; const isArchived = archive;
const ids = assets.map(({ id }) => id); const ids = assets.map(({ id }) => id);
const $t = get(t); const $t = get(t);
@ -533,9 +536,9 @@ export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean
await updateAssets({ assetBulkUpdateDto: { ids, isArchived } }); await updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
} }
for (const asset of assets) { // for (const asset of assets) {
asset.isArchived = isArchived; // asset.isArchived = isArchived;
} // }
notificationController.show({ notificationController.show({
message: isArchived message: isArchived

View File

@ -1,7 +1,10 @@
import { getAssetRatio } from '$lib/utils/asset-utils';
// import { TUNABLES } from '$lib/utils/tunables'; // import { TUNABLES } from '$lib/utils/tunables';
// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805 // note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm'; // import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { isTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import createJustifiedLayout from 'justified-layout'; import createJustifiedLayout from 'justified-layout';
@ -26,7 +29,7 @@ export type CommonLayoutOptions = {
}; };
export function getJustifiedLayoutFromAssets( export function getJustifiedLayoutFromAssets(
assets: AssetResponseDto[], assets: (TimelineAsset | AssetResponseDto)[],
options: CommonLayoutOptions, options: CommonLayoutOptions,
): CommonJustifiedLayout { ): CommonJustifiedLayout {
// if (useWasm) { // if (useWasm) {
@ -87,7 +90,7 @@ class Adapter {
} }
} }
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) { export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) {
const adapter = { const adapter = {
targetRowHeight: options.rowHeight, targetRowHeight: options.rowHeight,
containerWidth: options.rowWidth, containerWidth: options.rowWidth,
@ -96,7 +99,7 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
}; };
const result = createJustifiedLayout( const result = createJustifiedLayout(
assets.map((g) => getAssetRatio(g)), assets.map((a) => (isTimelineAsset(a) ? a.ratio : getAssetRatio(a))),
adapter, adapter,
); );
return new Adapter(result); return new Adapter(result);

View File

@ -1,8 +1,10 @@
import type { AssetBucket } from '$lib/stores/assets-store.svelte'; import type { BaseInteractionAsset } from '$lib/stores/asset-interaction.svelte';
import type { AssetBucket, TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { type CommonJustifiedLayout } from '$lib/utils/layout-utils'; import { type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import type { AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { memoize } from 'lodash-es'; import { memoize } from 'lodash-es';
import { DateTime, type LocaleOptions } from 'luxon'; import { DateTime, type LocaleOptions } from 'luxon';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -105,3 +107,30 @@ export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): strin
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
export const formatDateGroupTitle = memoize(formatGroupTitle); export const formatDateGroupTitle = memoize(formatGroupTitle);
export const toTimelineAsset = (unknownAsset: BaseInteractionAsset): TimelineAsset => {
if (isTimelineAsset(unknownAsset)) {
return unknownAsset;
}
const assetResponse = unknownAsset as AssetResponseDto;
const { width, height } = getAssetRatio(assetResponse);
const ratio = width / height;
return {
id: assetResponse.id,
ownerId: assetResponse.ownerId,
ratio,
thumbhash: assetResponse.thumbhash,
localDateTime: assetResponse.localDateTime,
isFavorite: assetResponse.isFavorite,
isArchived: assetResponse.isArchived,
isTrashed: assetResponse.isTrashed,
isVideo: assetResponse.type == AssetTypeEnum.Video,
isImage: assetResponse.type == AssetTypeEnum.Image,
stack: assetResponse.stack || null,
duration: assetResponse.duration || null,
projectionType: assetResponse.exifInfo?.projectionType || null,
livePhotoVideoId: assetResponse.livePhotoVideoId || null,
};
};
export const isTimelineAsset = (arg: BaseInteractionAsset): arg is TimelineAsset =>
(arg as TimelineAsset).ratio !== undefined;

View File

@ -22,9 +22,10 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte'; import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
@ -33,14 +34,16 @@
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute, AlbumPageViewMode } from '$lib/constants'; import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { downloadAlbum, cancelMultiselect } from '$lib/utils/asset-utils'; import { confirmAlbumDelete } from '$lib/utils/album-utils';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
@ -80,13 +83,10 @@
mdiPresentationPlay, mdiPresentationPlay,
mdiShareVariantOutline, mdiShareVariantOutline,
} from '@mdi/js'; } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { confirmAlbumDelete } from '$lib/utils/album-utils';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@ -94,7 +94,7 @@
let { data = $bindable() }: Props = $props(); let { data = $bindable() }: Props = $props();
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; let { isViewing: showAssetViewer, setAssetId, gridScrollTarget } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore; let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state(); let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
@ -107,8 +107,8 @@
let reactions: ActivityResponseDto[] = $state([]); let reactions: ActivityResponseDto[] = $state([]);
let albumOrder: AssetOrder | undefined = $state(data.album.order); let albumOrder: AssetOrder | undefined = $state(data.album.order);
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
const timelineInteraction = new AssetInteraction(); const timelineInteraction = new AssetInteraction<TimelineAsset>();
afterNavigate(({ from }) => { afterNavigate(({ from }) => {
let url: string | undefined = from?.url?.pathname; let url: string | undefined = from?.url?.pathname;
@ -207,8 +207,7 @@
? await assetStore.getRandomAsset() ? await assetStore.getRandomAsset()
: assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset; : assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
if (asset) { if (asset) {
setAsset(asset); handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
$slideshowState = SlideshowState.PlaySlideshow;
} }
}; };

View File

@ -9,16 +9,16 @@
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import type { PageData } from './$types'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;
@ -29,7 +29,7 @@
void assetStore.updateOptions({ isArchived: true }); void assetStore.updateOptions({ isArchived: true });
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
const handleEscape = () => { const handleEscape = () => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {

View File

@ -9,19 +9,19 @@
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { PageData } from './$types';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;
@ -30,10 +30,10 @@
let { data }: Props = $props(); let { data }: Props = $props();
const assetStore = new AssetStore(); const assetStore = new AssetStore();
void assetStore.updateOptions({ isFavorite: true }); void assetStore.updateOptions({ isFavorite: true, withStacked: true });
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
const handleEscape = () => { const handleEscape = () => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
@ -76,6 +76,7 @@
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}> <UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
<AssetGrid <AssetGrid
enableRouting={true} enableRouting={true}
withStacked={true}
{assetStore} {assetStore}
{assetInteraction} {assetInteraction}
removeAction={AssetAction.UNFAVORITE} removeAction={AssetAction.UNFAVORITE}

View File

@ -1,37 +1,38 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation'; import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { foldersStore } from '$lib/stores/folders.svelte'; import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@ -46,7 +47,7 @@
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort()); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<AssetResponseDto>();
onMount(async () => { onMount(async () => {
await foldersStore.fetchUniquePaths(); await foldersStore.fetchUniquePaths();

View File

@ -4,16 +4,16 @@
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';
import { mdiPlus, mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mdiArrowLeft, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;
@ -24,7 +24,7 @@
const assetStore = new AssetStore(); const assetStore = new AssetStore();
$effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true })); $effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true }));
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
const handleEscape = () => { const handleEscape = () => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {

View File

@ -33,20 +33,14 @@
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation'; import { isExternalUrl } from '$lib/utils/navigation';
import { import { getPersonStatistics, mergePerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
getPersonStatistics,
mergePerson,
searchPerson,
updatePerson,
type AssetResponseDto,
type PersonResponseDto,
} from '@immich/sdk';
import { import {
mdiAccountBoxOutline, mdiAccountBoxOutline,
mdiAccountMultipleCheckOutline, mdiAccountMultipleCheckOutline,
@ -59,11 +53,10 @@
mdiHeartOutline, mdiHeartOutline,
mdiPlus, mdiPlus,
} from '@mdi/js'; } from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { locale } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
interface Props { interface Props {
data: PageData; data: PageData;
@ -78,7 +71,7 @@
$effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id })); $effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id }));
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS); let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS);
let isEditingName = $state(false); let isEditingName = $state(false);
@ -202,7 +195,7 @@
data = { ...data, person }; data = { ...data, person };
}; };
const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => { const handleSelectFeaturePhoto = async (asset: TimelineAsset) => {
if (viewMode !== PersonPageViewMode.SELECT_PERSON) { if (viewMode !== PersonPageViewMode.SELECT_PERSON) {
return; return;
} }

View File

@ -22,7 +22,7 @@
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { import {
@ -32,7 +32,7 @@
type OnUnlink, type OnUnlink,
} from '$lib/utils/actions'; } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetTypeEnum } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -42,7 +42,7 @@
void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true }); void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true });
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
let selectedAssets = $derived(assetInteraction.selectedAssets); let selectedAssets = $derived(assetInteraction.selectedAssets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack); let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
@ -50,8 +50,8 @@
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId; const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate = const isLivePhotoCandidate =
selectedAssets.length === 2 && selectedAssets.length === 2 &&
selectedAssets.some((asset) => asset.type === AssetTypeEnum.Image) && selectedAssets.some((asset) => asset.isImage) &&
selectedAssets.some((asset) => asset.type === AssetTypeEnum.Video); selectedAssets.some((asset) => asset.isVideo);
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate); return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
}); });

View File

@ -63,7 +63,7 @@
let scrollY = $state(0); let scrollY = $state(0);
let scrollYHistory = 0; let scrollYHistory = 0;
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<AssetResponseDto>();
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>; type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));

View File

@ -17,14 +17,14 @@
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk'; import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, Text } from '@immich/ui'; import { Button, HStack, Text } from '@immich/ui';
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { onDestroy } from 'svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@ -35,7 +35,7 @@
let pathSegments = $derived(data.path ? data.path.split('/') : []); let pathSegments = $derived(data.path ? data.path.split('/') : []);
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
const buildMap = (tags: TagResponseDto[]) => { const buildMap = (tags: TagResponseDto[]) => {
return Object.fromEntries(tags.map((tag) => [tag.value, tag])); return Object.fromEntries(tags.map((tag) => [tag.value, tag]));

View File

@ -15,7 +15,7 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@ -40,7 +40,7 @@
void assetStore.updateOptions({ isTrashed: true }); void assetStore.updateOptions({ isTrashed: true });
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction<TimelineAsset>();
const handleEmptyTrash = async () => { const handleEmptyTrash = async () => {
const isConfirmed = await dialogController.show({ const isConfirmed = await dialogController.show({

View File

@ -1,3 +1,4 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts'; import { Sync } from 'factory.ts';
@ -25,3 +26,20 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
isOffline: Sync.each(() => faker.datatype.boolean()), isOffline: Sync.each(() => faker.datatype.boolean()),
hasMetadata: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()),
}); });
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
id: Sync.each(() => faker.string.uuid()),
ratio: Sync.each(() => faker.number.int()),
ownerId: Sync.each(() => faker.string.uuid()),
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
localDateTime: Sync.each(() => faker.date.past().toISOString()),
isFavorite: Sync.each(() => faker.datatype.boolean()),
isArchived: false,
isTrashed: false,
isImage: true,
isVideo: false,
duration: '0:00:00.00000',
stack: null,
projectionType: null,
livePhotoVideoId: Sync.each(() => faker.string.uuid()),
});