From f86455873d5b25f228cf3782b50bf44845ba7d1f Mon Sep 17 00:00:00 2001 From: midzelis Date: Mon, 8 Dec 2025 11:36:17 +0000 Subject: [PATCH] feat: web - view transitions from timeline to viewer, next/prev --- server/src/repositories/media.repository.ts | 1 + web/src/app.css | 258 +++++++++++++++ web/src/lib/actions/thumbhash.ts | 28 +- .../asset-viewer/activity-viewer.svelte | 4 +- .../asset-viewer/asset-viewer.svelte | 203 ++++++++---- .../asset-viewer/detail-panel.svelte | 25 +- .../face-editor/face-editor.svelte | 5 +- .../asset-viewer/image-panorama-viewer.svelte | 6 +- .../photo-sphere-viewer-adapter.svelte | 17 +- .../asset-viewer/photo-viewer.svelte | 307 +++++++++++++----- .../asset-viewer/video-native-viewer.svelte | 25 +- .../asset-viewer/video-panorama-viewer.svelte | 4 +- .../asset-viewer/video-wrapper-viewer.svelte | 8 +- .../assets/thumbnail/thumbnail.svelte | 18 +- .../layouts/user-page-layout.svelte | 17 +- .../components/timeline/AssetLayout.svelte | 16 +- web/src/lib/components/timeline/Month.svelte | 36 +- .../lib/components/timeline/Scrubber.svelte | 5 +- .../lib/components/timeline/Timeline.svelte | 62 +++- .../timeline/TimelineAssetViewer.svelte | 14 +- .../managers/ViewTransitionManager.svelte.ts | 127 ++++++++ web/src/lib/managers/event-manager.svelte.ts | 16 +- web/src/lib/stores/asset-viewing.store.ts | 2 + web/src/lib/stores/assets-store.svelte.ts | 2 +- web/src/lib/stores/mobile-device.svelte.ts | 4 + web/src/lib/stores/ocr.svelte.ts | 4 +- web/src/lib/stores/zoom-image.store.ts | 18 +- web/src/lib/utils/people-utils.ts | 10 +- web/src/routes/(user)/+layout.svelte | 5 +- web/src/routes/+layout.svelte | 30 +- web/src/service-worker/cache.ts | 2 +- web/src/service-worker/request.ts | 42 ++- 32 files changed, 1093 insertions(+), 228 deletions(-) create mode 100644 web/src/lib/managers/ViewTransitionManager.svelte.ts diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a8e96709ff..015015a751 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -148,6 +148,7 @@ export class MediaRepository { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', + progressive: true, }) .toFile(output); } diff --git a/web/src/app.css b/web/src/app.css index bf7601f63b..dd04c422dd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -74,6 +74,9 @@ --immich-dark-bg: 10 10 10; --immich-dark-fg: 229 231 235; --immich-dark-gray: 33 33 33; + + /* transitions */ + --immich-split-viewer-nav: enabled; } button:not(:disabled), @@ -171,3 +174,258 @@ @apply bg-subtle rounded-lg; } } + +@layer base { + ::view-transition { + background: black; + animation-duration: 250ms; + } + + ::view-transition-old(*), + ::view-transition-new(*) { + mix-blend-mode: normal; + animation-duration: inherit; + } + + ::view-transition-old(*) { + animation-name: fadeOut; + animation-fill-mode: forwards; + } + ::view-transition-new(*) { + animation-name: fadeIn; + animation-fill-mode: forwards; + } + + ::view-transition-old(root) { + animation: 250ms 0s fadeOut forwards; + } + ::view-transition-new(root) { + animation: 250ms 0s fadeIn forwards; + } + html:active-view-transition-type(slideshow) { + &::view-transition-old(root) { + animation: 1s 0s fadeOut forwards; + } + &::view-transition-new(root) { + animation: 1s 0s fadeIn forwards; + } + } + html:active-view-transition-type(viewer-nav) { + &::view-transition-old(root) { + animation: 350ms 0s fadeOut forwards; + } + &::view-transition-new(root) { + animation: 350ms 0s fadeIn forwards; + } + } + ::view-transition-old(info) { + animation: 250ms 0s flyOutRight forwards; + } + ::view-transition-new(info) { + animation: 250ms 0s flyInRight forwards; + } + + ::view-transition-group(detail-panel) { + z-index: 1; + } + ::view-transition-old(detail-panel), + ::view-transition-new(detail-panel) { + animation: none; + } + ::view-transition-group(letterbox-left), + ::view-transition-group(letterbox-right), + ::view-transition-group(letterbox-top), + ::view-transition-group(letterbox-bottom) { + z-index: 4; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom) { + background-color: black; + } + + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right) { + height: 100dvh; + } + + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + background-color: black; + opacity: 1 !important; + } + + ::view-transition-group(exclude-leftbutton), + ::view-transition-group(exclude-rightbutton), + ::view-transition-group(exclude) { + animation: none; + z-index: 5; + } + ::view-transition-old(exclude-leftbutton), + ::view-transition-old(exclude-rightbutton), + ::view-transition-old(exclude) { + visibility: hidden; + } + ::view-transition-new(exclude-leftbutton), + ::view-transition-new(exclude-rightbutton), + ::view-transition-new(exclude) { + animation: none; + z-index: 5; + } + + ::view-transition-old(hero) { + animation: 350ms fadeOut forwards; + align-content: center; + } + ::view-transition-new(hero) { + animation: 350ms fadeIn forwards; + align-content: center; + } + ::view-transition-old(next), + ::view-transition-old(next-old) { + animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutLeft forwards; + overflow: hidden; + } + + ::view-transition-new(next), + ::view-transition-new(next-new) { + animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInRight forwards; + overflow: hidden; + } + + ::view-transition-old(previous) { + animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutRight forwards; + } + ::view-transition-old(previous-old) { + animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutRight forwards; + overflow: hidden; + z-index: -1; + } + + ::view-transition-new(previous) { + animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInLeft forwards; + } + + ::view-transition-new(previous-new) { + animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInLeft forwards; + overflow: hidden; + } + + @keyframes flyInLeft { + from { + /* object-position: -25dvw; */ + transform: translateX(-15%); + opacity: 0.1; + filter: blur(4px); + } + 50% { + opacity: 0.4; + filter: blur(2px); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutLeft { + from { + opacity: 1; + filter: blur(0); + } + 50% { + opacity: 0.4; + filter: blur(2px); + } + to { + /* object-position: -25dvw; */ + transform: translateX(-15%); + opacity: 0.1; + filter: blur(4px); + } + } + + @keyframes flyInRight { + from { + /* object-position: 25dvw; */ + transform: translateX(15%); + opacity: 0.1; + filter: blur(4px); + } + 50% { + opacity: 0.4; + filter: blur(2px); + } + to { + opacity: 1; + filter: blur(0); + } + } + + /* Fly out to right */ + @keyframes flyOutRight { + from { + opacity: 1; + filter: blur(0); + } + 50% { + opacity: 0.4; + filter: blur(2px); + } + to { + /* object-position: 50dvw 0px; */ + transform: translateX(15%); + opacity: 0.1; + filter: blur(4px); + } + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + + @media (prefers-reduced-motion) { + ::view-transition-group(previous), + ::view-transition-group(next) { + width: 100% !important; + height: 100% !important; + transform: none !important; + } + + ::view-transition-old(previous), + ::view-transition-old(next) { + animation: 250ms fadeOut forwards; + transform-origin: center; + height: 100%; + width: 100%; + object-fit: contain; + overflow: hidden; + } + + ::view-transition-new(previous), + ::view-transition-new(next) { + animation: 250ms fadeIn forwards; + transform-origin: center; + height: 100%; + width: 100%; + object-fit: contain; + } + } +} diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts index e49f04dbee..d738f567a4 100644 --- a/web/src/lib/actions/thumbhash.ts +++ b/web/src/lib/actions/thumbhash.ts @@ -7,13 +7,23 @@ import { thumbHashToRGBA } from 'thumbhash'; * @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString) */ export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { - const ctx = canvas.getContext('2d'); - if (ctx) { - const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); - const pixels = ctx.createImageData(w, h); - canvas.width = w; - canvas.height = h; - pixels.data.set(rgba); - ctx.putImageData(pixels, 0, 0); - } + const render = (hash: string) => { + const ctx = canvas.getContext('2d'); + if (ctx) { + const { w, h, rgba } = thumbHashToRGBA(decodeBase64(hash)); + const pixels = ctx.createImageData(w, h); + canvas.width = w; + canvas.height = h; + pixels.data.set(rgba); + ctx.putImageData(pixels, 0, 0); + } + }; + + render(base64ThumbHash); + + return { + update({ base64ThumbHash: newHash }: { base64ThumbHash: string }) { + render(newHash); + }, + }; } diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index 7579bd2a39..4d47aca3cf 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -12,7 +12,9 @@ import { isTenMinutesApart } from '$lib/utils/timesince'; import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk'; import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui'; + import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js'; + import * as luxon from 'luxon'; import { t } from 'svelte-i18n'; import { fromAction } from 'svelte/attachments'; @@ -255,7 +257,7 @@ shortcut: { key: 'Enter' }, onShortcut: () => handleSendComment(), }))} - class="h-4.5 {disabled + class="h-[18px] {disabled ? 'cursor-not-allowed' : ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200" > diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index f0483cf49b..5c79859976 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -12,12 +12,14 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { preloadManager } from '$lib/managers/PreloadManager.svelte'; + import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; + import { resetZoomState } from '$lib/stores/zoom-image.store'; import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { InvocationTracker } from '$lib/utils/invocationTracker'; @@ -27,7 +29,6 @@ import { AssetJobName, AssetTypeEnum, - getAllAlbums, getAssetInfo, getStack, runAssetJobs, @@ -37,9 +38,9 @@ type StackResponseDto, } from '@immich/sdk'; import { toastManager } from '@immich/ui'; - import { onDestroy, onMount, untrack } from 'svelte'; + import { onDestroy, onMount, tick, untrack } from 'svelte'; import { t } from 'svelte-i18n'; - import { fly } from 'svelte/transition'; + import { fly, slide } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import ActivityStatus from './activity-status.svelte'; import ActivityViewer from './activity-viewer.svelte'; @@ -90,7 +91,7 @@ copyImage = $bindable(), }: Props = $props(); - const { setAssetId } = assetViewingStore; + const { setAssetId, invisible } = assetViewingStore; const { restartProgress: restartSlideshowProgress, stopProgress: stopSlideshowProgress, @@ -103,7 +104,6 @@ let asset = $derived(cursor.current); let nextAsset = $derived(cursor.nextAsset); let previousAsset = $derived(cursor.previousAsset); - let appearsInAlbums: AlbumResponseDto[] = $state([]); let shouldPlayMotionPhoto = $state(false); let sharedLink = getSharedLink(); let enableDetailPanel = $derived(asset.hasMetadata); @@ -117,9 +117,15 @@ let selectedEditType: string = $state(''); let stack: StackResponseDto | null = $state(null); + let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow); + let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder); + let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle); + let zoomToggle = $state(() => void 0); let playOriginalVideo = $state($alwaysLoadOriginalVideo); + let refreshAlbumsSignal = $state(0); + const setPlayOriginalVideo = (value: boolean) => { playOriginalVideo = value; }; @@ -154,7 +160,26 @@ } }; - onMount(async () => { + let transitionName = $state('hero'); + let equirectangularTransitionName = $state('hero'); + let detailPanelTransitionName = $state(null); + + let addInfoTransition; + let finished; + onMount(() => { + addInfoTransition = () => { + detailPanelTransitionName = 'info'; + transitionName = 'hero'; + equirectangularTransitionName = 'hero'; + }; + eventManager.on('TransitionToAssetViewer', addInfoTransition); + eventManager.on('TransitionToTimeline', addInfoTransition); + finished = () => { + detailPanelTransitionName = null; + transitionName = null; + }; + eventManager.on('Finished', finished); + slideshowStateUnsubscribe = slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { slideshowHistory.reset(); @@ -171,10 +196,6 @@ slideshowHistory.queue(toTimelineAsset(asset)); } }); - - if (!sharedLink) { - await handleGetAllAlbums(); - } }); onDestroy(() => { @@ -191,20 +212,11 @@ } activityManager.reset(); + eventManager.off('TransitionToAssetViewer', addInfoTransition!); + eventManager.off('TransitionToTimeline', addInfoTransition!); + eventManager.off('Finished', finished!); }); - const handleGetAllAlbums = async () => { - if (authManager.isSharedLink) { - return; - } - - try { - appearsInAlbums = await getAllAlbums({ assetId: asset.id }); - } catch (error) { - console.error('Error getting album that asset belong to', error); - } - }; - const handleOpenActivity = () => { if ($isShowDetail) { $isShowDetail = false; @@ -218,6 +230,7 @@ }; const closeViewer = () => { + transitionName = 'hero'; onClose?.(asset); }; @@ -227,45 +240,98 @@ }); }; + const startTransition = async ( + types: string[], + targetTransition: string | null, + targetAsset: AssetResponseDto | null, + navigateFn: () => Promise, + ) => { + transitionName = viewTransitionManager.getTransitionName('old', targetTransition); + equirectangularTransitionName = viewTransitionManager.getTransitionName('old', targetTransition); + detailPanelTransitionName = 'detail-panel'; + await tick(); + const navigationResult = new Promise((navigationResolve) => { + viewTransitionManager.startTransition( + new Promise((resolve) => { + eventManager.once('StartViewTransition', async () => { + transitionName = viewTransitionManager.getTransitionName('new', targetTransition); + if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) { + equirectangularTransitionName = null; + } + await tick(); + navigationResolve(await navigateFn()); + }); + eventManager.once('AssetViewerFree', () => tick().then(resolve)); + }), + types, + ); + }); + return navigationResult; + }; + const tracker = new InvocationTracker(); - const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { + const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => { if (!order) { - if ($slideshowState === SlideshowState.PlaySlideshow) { - order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next'; + if (slideShowPlaying) { + order = slideShowAscending ? 'previous' : 'next'; } else { return; } } - e?.stopPropagation(); preloadManager.cancel(asset); if (tracker.isActive()) { return; } + let skipped = false; + if (viewTransitionManager.skipTransitions()) { + skipped = true; + } + void tracker.invoke(async () => { let hasNext = false; - - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { - hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); - if (!hasNext) { - const asset = await onRandom?.(); - if (asset) { - slideshowHistory.queue(asset); - hasNext = true; + if (slideShowPlaying && slideShowShuffle) { + const navigate = async () => { + let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); + if (!next) { + const asset = await onRandom?.(); + if (asset) { + slideshowHistory.queue(asset); + next = true; + } } + return next; + }; + // eslint-disable-next-line unicorn/prefer-ternary + if (viewTransitionManager.isSupported() && !skipped && !skipTransition) { + hasNext = await startTransition(['slideshow'], null, null, navigate); + } else { + hasNext = await navigate(); } } else if (onNavigateToAsset) { - hasNext = - order === 'previous' - ? await onNavigateToAsset(cursor.previousAsset) - : await onNavigateToAsset(cursor.nextAsset); + // only transition if the target is already preloaded, and is in a secure context + const targetAsset = order === 'previous' ? previousAsset : nextAsset; + const navigate = async () => + order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset); + if (viewTransitionManager.isSupported() && !skipped && !skipTransition && !!targetAsset) { + const targetTransition = slideShowPlaying ? null : order; + hasNext = await startTransition( + slideShowPlaying ? ['slideshow'] : ['viewer-nav'], + targetTransition, + targetAsset, + navigate, + ); + } else { + hasNext = await navigate(); + } + resetZoomState(); } else { hasNext = false; } - if ($slideshowState === SlideshowState.PlaySlideshow) { + if (slideShowPlaying) { if (hasNext) { $restartSlideshowProgress = true; } else { @@ -333,7 +399,7 @@ const handleAction = async (action: Action) => { switch (action.type) { case AssetAction.ADD_TO_ALBUM: { - await handleGetAllAlbums(); + refreshAlbumsSignal++; break; } case AssetAction.REMOVE_ASSET_FROM_STACK: { @@ -391,7 +457,6 @@ const refresh = async () => { await refreshStack(); - await handleGetAllAlbums(); ocrManager.clear(); if (!sharedLink) { if (previewStackedAsset) { @@ -412,10 +477,18 @@ // eslint-disable-next-line @typescript-eslint/no-unused-expressions asset.id; if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') { + console.log('EMMITTTTT'); eventManager.emit('AssetViewerFree'); } }); + const isEquirectangular = (asset: AssetResponseDto) => { + return ( + asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + ); + }; + const viewerKind = $derived.by(() => { if (previewStackedAsset) { return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; @@ -423,10 +496,7 @@ if (asset.type === AssetTypeEnum.Image) { if (shouldPlayMotionPhoto && asset.livePhotoVideoId) { return 'LiveVideoViewer'; - } else if ( - asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || - (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) - ) { + } else if (isEquirectangular(asset)) { return 'ImagePanaramaViewer'; } else if (isShowEditor && selectedEditType === 'crop') { return 'CropArea'; @@ -444,12 +514,16 @@
{#if $slideshowState === SlideshowState.None && !isShowEditor} -
+
+
navigateAsset('previous')} />
{/if} -
+
{#if viewerKind === 'StackPhotoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onPreviousAsset={() => navigateAsset('previous', true)} + onNextAsset={() => navigateAsset('next', true)} {sharedLink} /> {:else if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'LiveVideoViewer'} {:else if viewerKind === 'ImagePanaramaViewer'} - + {:else if viewerKind === 'CropArea'} {:else if viewerKind === 'PhotoViewer'} navigateAsset('previous')} - onNextAsset={() => navigateAsset('next')} + onPreviousAsset={() => navigateAsset('previous', true)} + onNextAsset={() => navigateAsset('next', true)} {sharedLink} - onFree={() => eventManager.emit('AssetViewerFree')} + onReady={() => eventManager.emit('AssetViewerFree')} /> {:else if viewerKind === 'VideoViewer'} {#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset} -
+
navigateAsset('next')} />
{/if} {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
- ($isShowDetail = false)} /> + ($isShowDetail = false)} />
{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 2dde2c35ee..75b31f480d 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -13,13 +13,19 @@ import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; import { preferences, user } from '$lib/stores/user.store'; - import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; + import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { delay, getDimensions } from '$lib/utils/asset-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { getParentPath } from '$lib/utils/tree-utils'; - import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; + import { + AssetMediaSize, + getAllAlbums, + getAssetInfo, + type AlbumResponseDto, + type AssetResponseDto, + } from '@immich/sdk'; import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui'; import { mdiCalendar, @@ -43,12 +49,12 @@ interface Props { asset: AssetResponseDto; - albums?: AlbumResponseDto[]; currentAlbum?: AlbumResponseDto | null; + refreshAlbumsSignal?: number; onClose: () => void; } - let { asset, albums = [], currentAlbum = null, onClose }: Props = $props(); + let { asset, refreshAlbumsSignal = 0, currentAlbum = null, onClose }: Props = $props(); let showAssetPath = $state(false); let showEditFaces = $state(false); @@ -74,6 +80,17 @@ ); let previousId: string | undefined = $state(); + let albums = $state([]); + + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + refreshAlbumsSignal; + if (authManager.isSharedLink) { + return; + } + handlePromiseError(getAllAlbums({ assetId: asset.id }).then((response) => (albums = response))); + }); + $effect(() => { if (!previousId) { previousId = asset.id; diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index 01b2982efb..4925b93517 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -11,7 +11,7 @@ import { t } from 'svelte-i18n'; interface Props { - htmlElement: HTMLImageElement | HTMLVideoElement; + htmlElement: HTMLImageElement | HTMLVideoElement | undefined | null; containerWidth: number; containerHeight: number; assetId: string; @@ -78,6 +78,9 @@ }); $effect(() => { + if (!htmlElement) { + return; + } const { actualWidth, actualHeight } = getContainedSize(htmlElement); const offsetArea = { width: (containerWidth - actualWidth) / 2, diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 08ba43526d..40abab429f 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -8,11 +8,12 @@ import { fade } from 'svelte/transition'; type Props = { + transitionName?: string | null; asset: AssetResponseDto; zoomToggle?: (() => void) | null; }; - let { asset, zoomToggle = $bindable() }: Props = $props(); + let { transitionName, asset, zoomToggle = $bindable() }: Props = $props(); const loadAssetData = async (id: string) => { const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); @@ -20,11 +21,12 @@ }; -
+
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} {:then [data, { default: PhotoSphereViewer }]} import { shortcuts } from '$lib/actions/shortcut'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; @@ -27,6 +28,7 @@ }; type Props = { + transitionName?: string | null; panorama: string | { source: string }; originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; @@ -36,6 +38,7 @@ }; let { + transitionName, panorama, originalPanorama, adapter = EquirectangularAdapter, @@ -154,6 +157,13 @@ zoomSpeed: 0.5, fisheye: false, }); + viewer.addEventListener( + 'ready', + () => { + eventManager.emit('AssetViewerFree'); + }, + { once: true }, + ); const resolutionPlugin = viewer.getPlugin(ResolutionPlugin); const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { // zoomLevel range: [0, 100] @@ -190,4 +200,9 @@ -
+
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2a10a865a4..584e8391b9 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,6 +1,7 @@ @@ -251,12 +349,8 @@ { shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - + +
-
- {#if !imageLoaded} -
+ {#if blurredSlideshow} + + {/if} +
+
+
+
+
+ {#if asset.thumbhash} + + {#if thumbnailPreloaded} + {$getAltText(toTimelineAsset(asset))} + {/if} + {/if} + {#if !imageLoaded && !asset.thumbhash && !imageError} +
- {:else if !imageError} - {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} + {/if} + {#if imageError} +
+ +
+ {/if} + {#key imageLoaderUrl} +
- {/if} -
- {$getAltText(toTimelineAsset(asset))} + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{/each}
- - {#if isFaceEditMode.value} - - {/if} + {/key} + {#if isFaceEditMode.value} + {/if}
@@ -321,9 +478,13 @@ visibility: visible; } } - #broken-asset, + #spinner { visibility: hidden; animation: 0s linear 0.4s forwards delayedVisibility; } + [data-blur] { + visibility: hidden; + animation: 0s linear 0.1s forwards delayedVisibility; + } diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 88a62bb979..608da564e1 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -4,6 +4,7 @@ import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte'; import { assetViewerFadeDuration } from '$lib/constants'; import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; @@ -22,6 +23,8 @@ import { fade } from 'svelte/transition'; interface Props { + transitionName?: string | null; + asset: AssetResponseDto; assetId: string; previousAsset?: AssetResponseDto | null | undefined; nextAsset?: AssetResponseDto | undefined | null | undefined; @@ -37,6 +40,8 @@ } let { + transitionName, + asset, assetId, previousAsset, nextAsset, @@ -51,8 +56,6 @@ onClose = () => {}, }: Props = $props(); - let asset = $state(null); - let videoPlayer: HTMLVideoElement | undefined = $state(); let isLoading = $state(true); let assetFileUrl = $derived( @@ -83,7 +86,7 @@ $effect( () => - void assetCacheManager.getAsset({ key: cacheKey ?? assetId, id: assetId }).then((assetDto) => (asset = assetDto)), + void assetCacheManager.getAsset({ ...authManager.params, id: assetId }).then((assetDto) => (asset = assetDto)), ); $effect(() => { @@ -184,7 +187,7 @@ }} > {#if castManager.isCasting} -
+
{:else} -
+
- {#if isLoading} -
+
{/if} - {#if isFaceEditMode.value} {/if} @@ -233,3 +236,9 @@ {/if}
{/if} + + diff --git a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte index 1e765bc92b..98eef41c56 100644 --- a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte @@ -5,10 +5,11 @@ import { fade } from 'svelte/transition'; interface Props { + transitionName?: string | null; assetId: string; } - const { assetId }: Props = $props(); + const { assetId, transitionName }: Props = $props(); const modules = Promise.all([ import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default), @@ -23,6 +24,7 @@ {:then [PhotoSphereViewer, adapter, videoPlugin]} {#if projectionType === ProjectionType.EQUIRECTANGULAR} - + {:else} + import { thumbhash } from '$lib/actions/thumbhash'; import { ProjectionType } from '$lib/constants'; + import { authManager } from '$lib/managers/auth-manager.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; + import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; + import { moveFocus } from '$lib/utils/focus-util'; + import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { getAltText } from '$lib/utils/thumbnail-util'; + import { TUNABLES } from '$lib/utils/tunables'; import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk'; + import { Icon } from '@immich/ui'; import { mdiArchiveArrowDownOutline, mdiCameraBurst, @@ -15,21 +23,11 @@ mdiMotionPlayOutline, mdiRotate360, } from '@mdi/js'; - - import { thumbhash } from '$lib/actions/thumbhash'; - import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; - import { mobileDevice } from '$lib/stores/mobile-device.svelte'; - import { moveFocus } from '$lib/utils/focus-util'; - import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; - import { TUNABLES } from '$lib/utils/tunables'; - import { Icon } from '@immich/ui'; import { onMount } from 'svelte'; import type { ClassValue } from 'svelte/elements'; import { fade } from 'svelte/transition'; import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; - interface Props { asset: TimelineAsset; groupIndex?: number; diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 7f40bf7a6d..39aa9a1aef 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -7,7 +7,8 @@ import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; - import type { Snippet } from 'svelte'; + import { getContext, type Snippet } from 'svelte'; + import type { AppState } from '../../../routes/+layout.svelte'; interface Props { hideNavbar?: boolean; @@ -37,13 +38,17 @@ let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden'); let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full'); + const appState = getContext('AppState') as AppState; + let isAssetViewer = $derived(appState.isAssetViewer);
- {#if !hideNavbar} + {#if !hideNavbar && !isAssetViewer} openFileUploadDialog()} /> {/if} - + {#if isAssetViewer} +
+ {/if} {@render header?.()}
- {#if sidebar} + {#if isAssetViewer} +
+ {:else if sidebar} {@render sidebar()} {:else} {/if} -
+
{@render children?.()}
diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 1d3300ca71..b8f3713afc 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -2,15 +2,15 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; + import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { uploadAssetsStore } from '$lib/stores/upload'; import type { CommonPosition } from '$lib/utils/layout-utils'; import type { Snippet } from 'svelte'; - import { flip } from 'svelte/animate'; - import { scale } from 'svelte/transition'; let { isUploading } = uploadAssetsStore; type Props = { + animationTargetAssetId?: string | null; viewerAssets: ViewerAsset[]; width: number; height: number; @@ -26,10 +26,11 @@ customThumbnailLayout?: Snippet<[asset: TimelineAsset]>; }; - const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props(); + const { animationTargetAssetId, viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = + $props(); const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); - const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); + // const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const filterIntersecting = (intersectables: T[]) => { return intersectables.filter(({ intersecting }) => intersecting); @@ -41,18 +42,21 @@ {#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)} {@const position = viewerAsset.position!} {@const asset = viewerAsset.asset!} + {@const transitionName = + animationTargetAssetId === asset.id && !mobileDevice.prefersReducedMotion ? 'hero' : undefined}
+ {@render thumbnail({ asset, position })} {@render customThumbnailLayout?.(asset)}
diff --git a/web/src/lib/components/timeline/Month.svelte b/web/src/lib/components/timeline/Month.svelte index f7ffb58c43..f480aadaa9 100644 --- a/web/src/lib/components/timeline/Month.svelte +++ b/web/src/lib/components/timeline/Month.svelte @@ -1,9 +1,11 @@ {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} @@ -95,6 +128,7 @@
(null); const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); const maxMd = $derived(mobileDevice.maxMd); @@ -219,7 +221,7 @@ timelineManager.viewportWidth = rect.width; } } - const scrollTarget = $gridScrollTarget?.at; + const scrollTarget = getScrollTarget(); let scrolled = false; if (scrollTarget) { scrolled = await scrollAndLoadAsset(scrollTarget); @@ -231,7 +233,7 @@ await tick(); focusAsset(scrollTarget); } - invisible = false; + invisible = isAssetViewerRoute(page) ? true : false; }; // note: only modified once in afterNavigate() @@ -249,10 +251,13 @@ hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer; }); + const getScrollTarget = () => { + return $gridScrollTarget?.at ?? page.params.assetId ?? null; + }; // afterNavigate is only called after navigation to a new URL, {complete} will resolve // after successful navigation. afterNavigate(({ complete }) => { - void complete.finally(() => { + void complete.finally(async () => { const isAssetViewerPage = isAssetViewerRoute(page); // Set initial load state only once - if initialLoadWasAssetViewer is null, then @@ -261,8 +266,13 @@ if (isDirectNavigation) { initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer; } - void scrollAfterNavigate(); + if (!isAssetViewerPage) { + const scrollTarget = getScrollTarget(); + await tick(); + + eventManager.emit('TimelineLoaded', { id: scrollTarget }); + } }); }); @@ -272,7 +282,7 @@ const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); onMount(() => { - if (!enableRouting) { + if (!enableRouting && !isAssetViewerRoute(page)) { invisible = false; } }); @@ -613,6 +623,7 @@ {#if timelineManager.months.length > 0} { - if (typeof onThumbnailClick === 'function') { - onThumbnailClick(asset, timelineManager, dayGroup, _onClick); - } else { - _onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); + onClick={async (asset) => { + const callClickHandler = () => { + if (typeof onThumbnailClick === 'function') { + onThumbnailClick(asset, timelineManager, dayGroup, _onClick); + } else { + _onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); + } + }; + + if (!viewTransitionManager.isSupported()) { + callClickHandler(); + return; } + + // tag target on the 'old' snapshot + toAssetViewerTransitionId = asset.id; + await tick(); + + eventManager.once('StartViewTransition', () => { + toAssetViewerTransitionId = null; + callClickHandler(); + }); + + viewTransitionManager.startTransition( + new Promise((resolve) => { + eventManager.once('AssetViewerFree', async () => { + await tick(); + eventManager.emit('TransitionToAssetViewer'); + resolve(); + }); + }), + ); }} onSelect={() => { if (isSelectionMode || assetInteraction.selectionActive) { diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index de20d84722..0d43930d90 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -3,7 +3,6 @@ import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte'; import { AssetAction } from '$lib/constants'; import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; - import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; @@ -97,11 +96,12 @@ if (!targetAsset) { return false; } - let waitForAssetViewerFree = new Promise((resolve) => { - eventManager.once('AssetViewerFree', () => resolve()); - }); + // let waitForAssetViewerFree = new Promise((resolve) => { + // eventManager.once('AssetViewerFree', () => resolve()); + // }); await navigate({ targetRoute: 'current', assetId: targetAsset.id }); - await waitForAssetViewerFree; + + // await waitForAssetViewerFree; return true; }; @@ -114,6 +114,10 @@ }; const handleClose = async (asset: { id: string }) => { + const awaitInit = new Promise((resolve) => eventManager.once('StartViewTransition', resolve)); + eventManager.emit('TransitionToTimeline', { id: asset.id }); + await awaitInit; + assetViewingStore.showAssetViewer(false); invisible = true; $gridScrollTarget = { at: asset.id }; diff --git a/web/src/lib/managers/ViewTransitionManager.svelte.ts b/web/src/lib/managers/ViewTransitionManager.svelte.ts new file mode 100644 index 0000000000..f8f11b1fe4 --- /dev/null +++ b/web/src/lib/managers/ViewTransitionManager.svelte.ts @@ -0,0 +1,127 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function traceTransitionEvents(msg: string, error?: unknown) { + // console.log(msg, error); +} +class ViewTransitionManager { + #activeViewTransition = $state(null); + #finishedCallbacks: (() => void)[] = []; + + #splitViewerNavTransitionNames = true; + + constructor() { + const root = document.documentElement; + const value = getComputedStyle(root).getPropertyValue('--immich-split-viewer-nav').trim(); + this.#splitViewerNavTransitionNames = value === 'enabled'; + } + + getTransitionName = (kind: 'old' | 'new', name: string | null | undefined) => { + if (name === 'previous' || name === 'next') { + return this.#splitViewerNavTransitionNames ? name + '-' + kind : name; + } else if (name) { + return name; + } + return null; + }; + + get activeViewTransition() { + return this.#activeViewTransition; + } + + isSupported() { + return 'startViewTransition' in document; + } + + skipTransitions() { + const skippedTransitions = !!this.#activeViewTransition; + this.#activeViewTransition?.skipTransition(); + this.#notifyFinished(); + return skippedTransitions; + } + + startTransition(domUpdateComplete: Promise, types?: string[], finishedCallback?: () => unknown) { + if (!this.isSupported()) { + throw new Error('View transition API not available'); + } + if (this.#activeViewTransition) { + traceTransitionEvents('Can not start transition - one already active'); + return; + } + + // good time to add view-transition-name styles (if needed) + traceTransitionEvents('emit BeforeStartViewTransition'); + eventManager.emit('BeforeStartViewTransition'); + + // next call will create the 'old' view snapshot + let transition: ViewTransition; + try { + // eslint-disable-next-line tscompat/tscompat + transition = document.startViewTransition({ + update: async () => { + // Good time to remove any view-transition-name styles created during + // BeforeStartViewTransition, then trigger the actual view transition. + traceTransitionEvents('emit StartViewTransition'); + eventManager.emit('StartViewTransition'); + + await domUpdateComplete; + traceTransitionEvents('awaited domUpdateComplete'); + }, + types, + }); + } catch { + // eslint-disable-next-line tscompat/tscompat + transition = document.startViewTransition(async () => { + // Good time to remove any view-transition-name styles created during + // BeforeStartViewTransition, then trigger the actual view transition. + traceTransitionEvents('emit StartViewTransition'); + eventManager.emit('StartViewTransition'); + await domUpdateComplete; + traceTransitionEvents('awaited domUpdateComplete'); + }); + } + this.#activeViewTransition = transition; + this.#finishedCallbacks.push(() => { + this.#activeViewTransition = null; + }); + if (finishedCallback) { + this.#finishedCallbacks.push(finishedCallback); + } + // UpdateCallbackDone is a good time to add any view-transition-name styles + // to the new DOM state, before the 'new' view snapshot is creatd + // eslint-disable-next-line tscompat/tscompat + transition.updateCallbackDone + .then(() => { + traceTransitionEvents('emit UpdateCallbackDone'); + eventManager.emit('UpdateCallbackDone'); + }) + .catch((error: unknown) => traceTransitionEvents('error in UpdateCallbackDone', error)); + // Both old/new snapshots are taken - pseudo elements are created, transition is + // about to start + // eslint-disable-next-line tscompat/tscompat + transition.ready + .then(() => eventManager.emit('Ready')) + .catch((error: unknown) => { + this.#notifyFinished(); + traceTransitionEvents('error in Ready', error); + }); + // Transition is complete + // eslint-disable-next-line tscompat/tscompat + transition.finished + .then(() => { + traceTransitionEvents('emit Finished'); + eventManager.emit('Finished'); + }) + .catch((error: unknown) => traceTransitionEvents('error in Finished', error)); + // eslint-disable-next-line tscompat/tscompat + void transition.finished.then(() => this.#notifyFinished()); + } + + #notifyFinished() { + for (const callback of this.#finishedCallbacks) { + callback(); + } + this.#finishedCallbacks = []; + } +} + +export const viewTransitionManager = new ViewTransitionManager(); diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 40e3f00ff9..0452da0d7c 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -45,6 +45,18 @@ export type Events = { AssetViewerFree: []; + TransitionToTimeline: [{ id: string }]; + TimelineLoaded: [{ id: string | null }]; + + TransitionToAssetViewer: []; + AssetViewerLoaded: []; + + BeforeStartViewTransition: []; + Finished: []; + Ready: []; + UpdateCallbackDone: []; + StartViewTransition: []; + SystemConfigUpdate: [SystemConfigDto]; LibraryCreate: [LibraryResponseDto]; @@ -67,11 +79,11 @@ class EventManager> { }[]; } = {}; - on(key: T, listener: (...params: EventMap[T]) => void) { + on(key: T, listener: (...params: EventMap[T]) => unknown) { return this.addListener(key, listener, false); } - once(key: T, listener: (...params: EventMap[T]) => void) { + once(key: T, listener: (...params: EventMap[T]) => unknown) { return this.addListener(key, listener, true); } diff --git a/web/src/lib/stores/asset-viewing.store.ts b/web/src/lib/stores/asset-viewing.store.ts index 3cd2cd9579..f137e917f9 100644 --- a/web/src/lib/stores/asset-viewing.store.ts +++ b/web/src/lib/stores/asset-viewing.store.ts @@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store'; function createAssetViewingStore() { const viewingAssetStoreState = writable(); + const invisible = writable(false); const viewState = writable(false); const gridScrollTarget = writable(); @@ -30,6 +31,7 @@ function createAssetViewingStore() { setAsset, setAssetId, showAssetViewer, + invisible, }; } diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index cef5d98e3b..2a22f0ffb6 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -1,4 +1,4 @@ import { writable } from 'svelte/store'; -export const photoViewerImgElement = writable(null); +export const photoViewerImgElement = writable(null); export const isSelectingAllAssets = writable(false); diff --git a/web/src/lib/stores/mobile-device.svelte.ts b/web/src/lib/stores/mobile-device.svelte.ts index ee6fa87dab..bbb87da590 100644 --- a/web/src/lib/stores/mobile-device.svelte.ts +++ b/web/src/lib/stores/mobile-device.svelte.ts @@ -1,6 +1,7 @@ import { MediaQuery } from 'svelte/reactivity'; const pointerCoarse = new MediaQuery('pointer:coarse'); +const reducedMotion = new MediaQuery('prefers-reduced-motion'); const maxMd = new MediaQuery('max-width: 767px'); const sidebar = new MediaQuery(`min-width: 850px`); @@ -14,4 +15,7 @@ export const mobileDevice = { get isFullSidebar() { return sidebar.current; }, + get prefersReducedMotion() { + return reducedMotion.current; + }, }; diff --git a/web/src/lib/stores/ocr.svelte.ts b/web/src/lib/stores/ocr.svelte.ts index f68e550851..39c42875de 100644 --- a/web/src/lib/stores/ocr.svelte.ts +++ b/web/src/lib/stores/ocr.svelte.ts @@ -1,5 +1,5 @@ +import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; -import { getAssetOcr } from '@immich/sdk'; export type OcrBoundingBox = { id: string; @@ -38,7 +38,7 @@ class OcrManager { this.#cleared = false; } await this.#ocrLoader.execute(async () => { - this.#data = await getAssetOcr({ id }); + this.#data = await assetCacheManager.getAssetOcr(id); }, false); } diff --git a/web/src/lib/stores/zoom-image.store.ts b/web/src/lib/stores/zoom-image.store.ts index 2c6ee18972..3a2438e924 100644 --- a/web/src/lib/stores/zoom-image.store.ts +++ b/web/src/lib/stores/zoom-image.store.ts @@ -1,4 +1,20 @@ import type { ZoomImageWheelState } from '@zoom-image/core'; import { writable } from 'svelte/store'; -export const photoZoomState = writable(); +export const photoZoomState = writable({ + currentRotation: 0, + currentZoom: 1, + enable: true, + currentPositionX: 0, + currentPositionY: 0, +}); + +export const resetZoomState = () => { + photoZoomState.set({ + currentRotation: 0, + currentZoom: 1, + enable: true, + currentPositionX: 0, + currentPositionY: 0, + }); +}; diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index 5fb03842b8..96f7f513ea 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -24,11 +24,11 @@ export interface boundingBox { export const getBoundingBox = ( faces: Faces[], zoom: ZoomImageWheelState, - photoViewer: HTMLImageElement | null, + photoViewer: HTMLImageElement | null | undefined, ): boundingBox[] => { const boxes: boundingBox[] = []; - if (photoViewer === null) { + if (!photoViewer) { return boxes; } const clientHeight = photoViewer.clientHeight; @@ -76,9 +76,9 @@ export const zoomImageToBase64 = async ( face: AssetFaceResponseDto, assetId: string, assetType: AssetTypeEnum, - photoViewer: HTMLImageElement | null, + photoViewer: HTMLImageElement | null | undefined, ): Promise => { - let image: HTMLImageElement | null = null; + let image: HTMLImageElement | null | undefined = null; if (assetType === AssetTypeEnum.Image) { image = photoViewer; } else if (assetType === AssetTypeEnum.Video) { @@ -93,7 +93,7 @@ export const zoomImageToBase64 = async ( image = img; } - if (image === null) { + if (!image) { return null; } const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face; diff --git a/web/src/routes/(user)/+layout.svelte b/web/src/routes/(user)/+layout.svelte index e6e349fe91..bf086ca97a 100644 --- a/web/src/routes/(user)/+layout.svelte +++ b/web/src/routes/(user)/+layout.svelte @@ -24,7 +24,7 @@ }); -
+
{@render children?.()}
@@ -33,7 +33,4 @@ :root { overscroll-behavior: none; } - .display-none { - display: none; - } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 3a1d4f49f9..f4c42d854c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,5 +1,17 @@ + +