feat(web): lighter timeline buckets (#17719)

* feat(web): lighter timeline buckets

* GalleryViewer

* weird ssr

* Remove generics from AssetInteraction

* ensure keys on getAssetInfo, alt-text

* empty - trigger ci

* re-add alt-text

* test fix

* update tests

* tests

* missing import

* fix: flappy e2e test

* lint

* revert settings

* unneeded cast

* fix after merge

* missing import

* lint

* review

* lint

* avoid abbreviations

* review comment - type safety in test

* merge conflicts

* lint

* lint/abbreviations

* fix: left-over migration

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-05-17 22:57:08 -04:00 committed by GitHub
parent a65c905621
commit 0bbe70e6a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 725 additions and 471 deletions

View file

@ -2,6 +2,7 @@
import { shortcut } from '$lib/actions/shortcut';
import AlbumMap from '$lib/components/album-page/album-map.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 { AssetStore } from '$lib/stores/assets-store.svelte';
@ -16,7 +17,6 @@
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.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 ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';

View file

@ -1,20 +1,21 @@
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 = {
[AssetAction.ARCHIVE]: { asset: AssetResponseDto };
[AssetAction.UNARCHIVE]: { asset: AssetResponseDto };
[AssetAction.FAVORITE]: { asset: AssetResponseDto };
[AssetAction.UNFAVORITE]: { asset: AssetResponseDto };
[AssetAction.TRASH]: { asset: AssetResponseDto };
[AssetAction.DELETE]: { asset: AssetResponseDto };
[AssetAction.RESTORE]: { asset: AssetResponseDto };
[AssetAction.ADD]: { asset: AssetResponseDto };
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.FAVORITE]: { asset: TimelineAsset };
[AssetAction.UNFAVORITE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.ADD]: { asset: TimelineAsset };
[AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
};
export type Action = {

View file

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

View file

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

View file

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

View file

@ -2,19 +2,21 @@
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { downloadFile } from '$lib/utils/asset-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { getAssetInfo } from '@immich/sdk';
import { mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
asset: TimelineAsset;
menuItem?: boolean;
}
let { asset, menuItem = false }: Props = $props();
const onDownloadFile = () => downloadFile(asset);
const onDownloadFile = async () => downloadFile(await getAssetInfo({ id: asset.id, key: authManager.key }));
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />

View file

@ -7,6 +7,7 @@
} from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -30,7 +31,10 @@
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({
type: NotificationType.Info,

View file

@ -3,6 +3,7 @@
import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { mdiPinOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -29,7 +30,7 @@
const keptAsset = await keepThisDeleteOthers(asset, stack);
if (keptAsset) {
onAction({ type: AssetAction.UNSTACK, assets: [keptAsset] });
onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
}
};
</script>

View file

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

View file

@ -3,14 +3,15 @@
import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets, Visibility, type AssetResponseDto } from '@immich/sdk';
import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk';
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
asset: TimelineAsset;
onAction: OnAction;
preAction: PreAction;
}

View file

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

View file

@ -13,6 +13,7 @@ describe('AssetViewerNavBar component', () => {
showDownloadButton: false,
showMotionPlayButton: false,
showShareButton: false,
preAction: () => {},
onZoomImage: () => {},
onCopyImage: () => {},
onAction: () => {},

View file

@ -25,6 +25,7 @@
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
@ -138,7 +139,7 @@
{/if}
{#if !isOwner && showDownloadButton}
<DownloadAction {asset} />
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if showDetailButton}
@ -166,7 +167,7 @@
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if showDownloadButton}
<DownloadAction {asset} menuItem />
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
{/if}
{#if !isLocked}
@ -210,7 +211,7 @@
{/if}
{#if !asset.isTrashed}
<SetVisibilityAction {asset} {onAction} {preAction} />
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
<hr />
<MenuOption

View file

@ -10,6 +10,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
@ -17,6 +18,7 @@
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
@ -47,7 +49,7 @@
interface Props {
asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[];
preloadAssets?: TimelineAsset[];
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
@ -56,10 +58,10 @@
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
showCloseButton?: boolean;
onClose: (dto: { asset: AssetResponseDto }) => void;
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<AssetResponseDto | undefined>;
onRandom: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
@ -81,7 +83,7 @@
copyImage = $bindable(),
}: Props = $props();
const { setAsset } = assetViewingStore;
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
@ -121,7 +123,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadAssets.push(stack.assets[1]);
preloadAssets.push(toTimelineAsset(stack.assets[1]));
}
});
};
@ -161,7 +163,7 @@
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(asset);
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
@ -171,7 +173,7 @@
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(asset);
slideshowHistory.queue(toTimelineAsset(asset));
}
});
@ -225,7 +227,7 @@
};
const closeViewer = () => {
onClose({ asset });
onClose(asset);
};
const closeEditor = () => {
@ -292,8 +294,7 @@
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
setAsset(asset);
$restartSlideshowProgress = true;
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
});
const handleVideoStarted = () => {
@ -563,8 +564,8 @@
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
asset={stackedAsset}
onClick={(stackedAsset) => {
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
asset = stackedAsset;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}

View file

@ -12,6 +12,7 @@
resetGlobalCropStore,
rotateDegrees,
} from '$lib/stores/asset-editor.store';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { animateCropChange, recalculateCrop } from './crop-settings';
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
@ -81,7 +82,7 @@
aria-label="Crop area"
type="button"
>
<img draggable="false" src={img?.src} alt={$getAltText(asset)} />
<img draggable="false" src={img?.src} alt={$getAltText(toTimelineAsset(asset))} />
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}>
<div class="grid"></div>
<div class="corner top-left"></div>

View file

@ -3,7 +3,7 @@
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { photoViewerImgElement, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
@ -13,9 +13,10 @@
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
@ -25,7 +26,7 @@
interface Props {
asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[] | undefined;
preloadAssets?: TimelineAsset[] | undefined;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
@ -69,10 +70,11 @@
$boundingBoxesArray = [];
});
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => {
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) {
preloadImageUrl(getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash));
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
@ -197,7 +199,7 @@
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img style="display:none" src={imageLoaderUrl} alt={$getAltText(asset)} {onload} {onerror} />
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
@ -213,7 +215,7 @@
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
alt={$getAltText(asset)}
alt=""
class="absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
@ -221,7 +223,7 @@
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
alt={$getAltText(asset)}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"

View file

@ -5,7 +5,7 @@
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { AssetMediaSize, Visibility } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
mdiCameraBurst,
@ -18,6 +18,7 @@
import { thumbhash } from '$lib/actions/thumbhash';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
@ -29,11 +30,11 @@
import VideoThumbnail from './video-thumbnail.svelte';
interface Props {
asset: AssetResponseDto;
asset: TimelineAsset;
groupIndex?: number;
thumbnailSize?: number | undefined;
thumbnailWidth?: number | undefined;
thumbnailHeight?: number | undefined;
thumbnailSize?: number;
thumbnailWidth?: number;
thumbnailHeight?: number;
selected?: boolean;
selectionCandidate?: boolean;
disabled?: boolean;
@ -44,10 +45,10 @@
imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean;
onClick?: ((asset: AssetResponseDto) => void) | undefined;
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
handleFocus?: (() => void) | undefined;
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
handleFocus?: () => void;
}
let {
@ -290,13 +291,13 @@
</div>
{/if}
{#if !authManager.key && showArchiveIcon && asset.isArchived}
{#if !authManager.key && showArchiveIcon && asset.visibility === Visibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon path={mdiRotate360} size="24" />
@ -309,7 +310,7 @@
<div
class={[
'absolute flex place-items-center gap-1 text-xs font-medium text-white',
asset.type == AssetTypeEnum.Image && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
asset.isImage && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
]}
>
<span class="pe-2 pt-2 flex place-items-center gap-1">
@ -329,17 +330,17 @@
curve={selected}
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.type === AssetTypeEnum.Video}
{#if asset.isVideo}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
durationInSeconds={timeToSeconds(asset.duration!)}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
{:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}

View file

@ -25,16 +25,18 @@
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type Viewport } from '$lib/stores/assets-store.svelte';
import { type TimelineAsset, type Viewport } from '$lib/stores/assets-store.svelte';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
import { getAltText } from '$lib/utils/thumbnail-util';
import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import {
mdiCardsOutline,
@ -67,6 +69,11 @@
let playerInitialized = $state(false);
let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined);
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ id: current.asset.id, key: authManager.key }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
@ -77,8 +84,8 @@
const assetInteraction = new AssetInteraction();
let progressBarController: Tween<number> | undefined = $state(undefined);
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: AssetResponseDto) => {
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
return asset;
}
@ -89,9 +96,9 @@
await goto(asHref(asset));
};
const setProgressDuration = (asset: AssetResponseDto) => {
if (asset.type === AssetTypeEnum.Video) {
const timeParts = asset.duration.split(':').map(Number);
const setProgressDuration = (asset: TimelineAsset) => {
if (asset.isVideo) {
const timeParts = asset.duration!.split(':').map(Number);
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
progressBarController = new Tween<number>(0, {
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
@ -107,7 +114,8 @@
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(AppRoute.PHOTOS);
const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []);
const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
// leaving these log statements here as comments. Very useful to figure out what's going on during dev!
// console.log(`handleAction[${callingContext}] called with: ${action}`);
@ -240,7 +248,7 @@
};
const initPlayer = () => {
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.type === AssetTypeEnum.Video && !videoPlayer;
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.isVideo && !videoPlayer;
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return;
}
@ -441,7 +449,7 @@
<div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id}
<div transition:fade class="h-full w-full">
{#if current.asset.type === AssetTypeEnum.Video}
{#if current.asset.isVideo}
<video
bind:this={videoPlayer}
autoplay
@ -458,7 +466,7 @@
<img
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
alt={current.asset.exifInfo?.description}
alt={$getAltText(current.asset)}
draggable="false"
transition:fade
/>
@ -551,8 +559,10 @@
})}
</p>
<p>
{current.asset.exifInfo?.city || ''}
{current.asset.exifInfo?.country || ''}
{#await currentMemoryAssetFull then asset}
{asset?.exifInfo?.city || ''}
{asset?.exifInfo?.country || ''}
{/await}
</p>
</div>
</div>
@ -623,7 +633,7 @@
<GalleryViewer
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={current.memory.assets}
assets={currentTimelineAssets}
viewport={galleryViewport}
{assetInteraction}
slidingWindowOffset={viewerHeight}

View file

@ -1,11 +1,12 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils';
import { AssetVisibility, Visibility } from '@immich/sdk';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { archiveAssets } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
interface Props {
onArchive?: OnArchive;
@ -23,10 +24,10 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = !unarchive;
const assets = [...getOwnedAssets()].filter((asset) => asset.isArchived !== isArchived);
const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
loading = true;
const ids = await archiveAssets(assets, isArchived);
const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility);
if (ids) {
onArchive?.(ids, isArchived);
clearSelect();

View file

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

View file

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

View file

@ -1,11 +1,14 @@
<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 { getAssetControlContext } from '../asset-select-control-bar.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
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 {
filename?: string;
@ -20,7 +23,8 @@
const assets = [...getAssets()];
if (assets.length === 1) {
clearSelect();
await downloadFile(assets[0]);
let asset = await getAssetInfo({ id: assets[0].id, key: authManager.key });
await downloadFile(asset);
return;
}

View file

@ -1,12 +1,16 @@
<script lang="ts">
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 { authManager } from '$lib/managers/auth-manager.svelte';
import type { OnLink, OnUnlink } from '$lib/utils/actions';
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 { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props {
onLink: OnLink;
@ -28,14 +32,14 @@
const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()];
if (still.type === AssetTypeEnum.Video) {
if ((still as TimelineAsset).isVideo) {
[still, motion] = [motion, still];
}
try {
loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink({ still: stillResponse, motion });
onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_link_motion_video'));
@ -46,17 +50,18 @@
const handleUnlink = async () => {
const [still] = [...getOwnedAssets()];
const motionId = still?.livePhotoVideoId;
if (!still) {
return;
}
const motionId = still.livePhotoVideoId;
if (!motionId) {
return;
}
try {
loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
const motionResponse = await getAssetInfo({ id: motionId });
onUnlink({ still: stillResponse, motion: motionResponse });
const motionResponse = await getAssetInfo({ id: motionId, key: authManager.key });
onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_motion_video'));

View file

@ -1,9 +1,10 @@
<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 { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
import { stackAssets, deleteStack } from '$lib/utils/asset-utils';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
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';
interface Props {
@ -34,7 +35,7 @@
}
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onUnstack?.(unstackedAssets);
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
}
clearSelect();
};

View file

@ -7,10 +7,11 @@
assetsSnapshot,
type AssetStore,
isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
@ -30,9 +31,9 @@
assetStore: AssetStore;
assetInteraction: AssetInteraction;
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
onSelectAssets: (asset: AssetResponseDto) => void;
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
}
let {
@ -53,7 +54,7 @@
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
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) {
assetSelectHandler(assetStore, asset, assets, groupTitle);
return;
@ -61,12 +62,12 @@
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 = (
assetStore: AssetStore,
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
asset: TimelineAsset,
assetsInDateGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
@ -90,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
hoveredDateGroup = groupTitle;

View file

@ -8,11 +8,18 @@
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
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 { searchStore } from '$lib/stores/search.svelte';
@ -23,7 +30,7 @@
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte';
@ -52,7 +59,7 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: AssetResponseDto) => void;
onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
@ -358,7 +365,10 @@
};
const toggleArchive = async () => {
await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
await archiveAssets(
assetInteraction.selectedAssets,
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
assetStore.updateAssets(assetInteraction.selectedAssets);
deselectAllAssets();
};
@ -369,7 +379,7 @@
}
};
const handleSelectAsset = (asset: AssetResponseDto) => {
const handleSelectAsset = (asset: TimelineAsset) => {
if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
@ -380,7 +390,8 @@
if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
const asset = await getAssetInfo({ id: previousAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
}
@ -391,7 +402,8 @@
const nextAsset = await assetStore.getNextAsset($viewingAsset);
if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
const asset = await getAssetInfo({ id: nextAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
}
@ -403,14 +415,14 @@
if (randomAsset) {
const preloadAsset = await assetStore.getNextAsset(randomAsset);
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []);
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
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);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
@ -428,7 +440,7 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
assetStore.removeAssets([action.asset.id]);
@ -458,7 +470,7 @@
}
};
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
@ -488,14 +500,14 @@
}
};
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => {
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => {
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
@ -515,7 +527,7 @@
}
};
const handleSelectAssets = async (asset: AssetResponseDto) => {
const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
@ -598,7 +610,7 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}

View file

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

View file

@ -5,6 +5,7 @@
import { memoryStore } from '$lib/stores/memory.store.svelte';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@ -82,7 +83,7 @@
<img
class="h-full w-full rounded-xl object-cover"
src={getAssetThumbnailUrl(memory.assets[0].id)}
alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })}
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
draggable="false"
/>
<div

View file

@ -11,7 +11,8 @@
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@ -33,7 +34,7 @@
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
let assets = $derived(sharedLink.assets);
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
@ -126,15 +127,17 @@
<section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {viewport} />
</section>
{:else}
<AssetViewer
asset={assets[0]}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
onClose={() => {}}
/>
{:else if assets.length === 1}
{#await getAssetInfo({ id: assets[0].id, key: authManager.key }) then asset}
<AssetViewer
{asset}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
onClose={() => {}}
/>
{/await}
{/if}
</section>

View file

@ -8,7 +8,7 @@
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
@ -18,7 +18,8 @@
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { type AssetResponseDto } from '@immich/sdk';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
@ -26,7 +27,7 @@
import Portal from '../portal/portal.svelte';
interface Props {
assets: AssetResponseDto[];
assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
@ -34,9 +35,9 @@
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined;
onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined;
onRandom?: (() => Promise<AssetResponseDto | undefined>) | undefined;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
}
@ -57,7 +58,7 @@
pageHeaderOffset = 0,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
let geometry: CommonJustifiedLayout | undefined = $state();
@ -83,20 +84,27 @@
if (geometry) {
containerHeight = geometry.containerHeight;
containerWidth = geometry.containerWidth;
for (const [i, asset] of assets.entries()) {
const layout = {
asset,
top: geometry.getTop(i),
left: geometry.getLeft(i),
width: geometry.getWidth(i),
height: geometry.getHeight(i),
};
// 54 is the content height of the asset-selection-app-bar
const layoutTopWithOffset = layout.top + pageHeaderOffset;
const layoutBottom = layoutTopWithOffset + layout.height;
for (const [index, asset] of assets.entries()) {
const top = geometry.getTop(index);
const left = geometry.getLeft(index);
const width = geometry.getWidth(index);
const height = geometry.getHeight(index);
const layoutTopWithOffset = top + pageHeaderOffset;
const layoutBottom = layoutTopWithOffset + height;
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
assetLayout.push({ ...layout, display });
const layout = {
asset,
top,
left,
width,
height,
display,
};
assetLayout.push(layout);
}
}
@ -109,7 +117,7 @@
let currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let slidingWindow = $state({ top: 0, bottom: 0 });
const updateSlidingWindow = () => {
@ -139,14 +147,14 @@
}
}
});
const viewAssetHandler = async (asset: AssetResponseDto) => {
const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
setAsset(assets[currentViewAssetIndex]);
await setAssetId(assets[currentViewAssetIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
};
const selectAllAssets = () => {
assetInteraction.selectAssets(assets);
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
};
const deselectAllAssets = () => {
@ -168,7 +176,7 @@
}
};
const handleSelectAssets = (asset: AssetResponseDto) => {
const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) {
return;
}
@ -191,14 +199,14 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
@ -215,12 +223,12 @@
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
};
const onSelectStart = (e: Event) => {
const onSelectStart = (event: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
e.preventDefault();
event.preventDefault();
}
};
@ -253,7 +261,10 @@
};
const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
const ids = await archiveAssets(
assetInteraction.selectedAssets,
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
@ -275,7 +286,7 @@
isShortcutModalOpen = false;
};
let shortcutList = $derived(
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
@ -305,7 +316,7 @@
const handleNext = async (): Promise<boolean> => {
try {
let asset: AssetResponseDto | undefined;
let asset: { id: string } | undefined;
if (onNext) {
asset = await onNext();
} else {
@ -329,9 +340,9 @@
}
};
const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
const handleRandom = async (): Promise<{ id: string } | undefined> => {
try {
let asset: AssetResponseDto | undefined;
let asset: { id: string } | undefined;
if (onRandom) {
asset = await onRandom();
} else {
@ -355,7 +366,7 @@
const handlePrevious = async (): Promise<boolean> => {
try {
let asset: AssetResponseDto | undefined;
let asset: { id: string } | undefined;
if (onPrevious) {
asset = await onPrevious();
} else {
@ -379,9 +390,9 @@
}
};
const navigateToAsset = async (asset?: AssetResponseDto) => {
const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) {
setAsset(asset);
await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
};
@ -392,7 +403,7 @@
case AssetAction.DELETE:
case AssetAction.TRASH: {
assets.splice(
assets.findIndex((a) => a.id === action.asset.id),
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
);
if (assets.length === 0) {
@ -400,21 +411,21 @@
} else if (currentViewAssetIndex === assets.length) {
await handlePrevious();
} else {
setAsset(assets[currentViewAssetIndex]);
await setAssetId(assets[currentViewAssetIndex].id);
}
break;
}
}
};
const assetMouseEventHandler = (asset: AssetResponseDto | null) => {
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
}
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map((selectedAsset) => selectedAsset.id));
$effect(() => {
if (!lastAssetMouseEvent) {
@ -457,39 +468,38 @@
style:height={assetLayouts.containerHeight + 'px'}
style:width={assetLayouts.containerWidth - 1 + 'px'}
>
{#each assetLayouts.assetLayout as layout, index (layout.asset.id + '-' + index)}
{@const asset = layout.asset}
{#each assetLayouts.assetLayout as layout, layoutIndex (layout.asset.id + '-' + layoutIndex)}
{@const currentAsset = layout.asset}
{#if layout.display}
<div
class="absolute"
style:overflow="clip"
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
title={showAssetName ? asset.originalFileName : ''}
>
<Thumbnail
readonly={disableAssetSelect}
onClick={(asset) => {
onClick={() => {
if (assetInteraction.selectionActive) {
handleSelectAssets(asset);
handleSelectAssets(toTimelineAsset(currentAsset));
return;
}
void viewAssetHandler(asset);
void viewAssetHandler(toTimelineAsset(currentAsset));
}}
onSelect={(asset) => handleSelectAssets(asset)}
onMouseEvent={() => assetMouseEventHandler(asset)}
onSelect={() => handleSelectAssets(toTimelineAsset(currentAsset))}
onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(currentAsset))}
{showArchiveIcon}
{asset}
selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
asset={toTimelineAsset(currentAsset)}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
thumbnailWidth={layout.width}
thumbnailHeight={layout.height}
/>
{#if showAssetName}
{#if showAssetName && !isTimelineAsset(currentAsset)}
<div
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
>
{asset.originalFileName}
{currentAsset.originalFileName}
</div>
{/if}
</div>

View file

@ -3,6 +3,7 @@
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -36,7 +37,7 @@
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id)}
alt={$getAltText(asset)}
alt={$getAltText(toTimelineAsset(asset))}
title={assetData}
class="h-60 object-cover rounded-t-xl w-full"
draggable="false"