feat: view transitions

pull/24357/head
midzelis 2025-12-08 11:36:17 +07:00
parent b9a7e834ef
commit 06eebfa9c2
23 changed files with 588 additions and 86 deletions

@ -12,6 +12,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
@ -19,6 +20,7 @@
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { resetZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils'; import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker'; import { InvocationTracker } from '$lib/utils/invocationTracker';
@ -40,7 +42,7 @@
import { toastManager } from '@immich/ui'; import { toastManager } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly, slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte'; import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte'; import ActivityViewer from './activity-viewer.svelte';
@ -89,7 +91,7 @@
copyImage = $bindable(), copyImage = $bindable(),
}: Props = $props(); }: Props = $props();
const { setAssetId } = assetViewingStore; const { setAssetId, invisible } = assetViewingStore;
const { const {
restartProgress: restartSlideshowProgress, restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress, stopProgress: stopSlideshowProgress,
@ -157,7 +159,24 @@
} }
}; };
let transitionName = $state<string | null>('hero');
let equirectangularTransitionName = $state<string | null>('hero');
let detailPanelTransitionName = $state<string | null>(null);
let addInfoTransition;
let finished;
onMount(async () => { onMount(async () => {
addInfoTransition = () => {
detailPanelTransitionName = 'info';
};
eventManager.on('TransitionToAssetViewer', addInfoTransition);
eventManager.on('TransitionToTimeline', addInfoTransition);
finished = () => {
detailPanelTransitionName = null;
transitionName = null;
};
eventManager.on('Finished', finished);
// eventManager.emit('AssetViewerLoaded');
unsubscribes.push( unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })), websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })), websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
@ -199,6 +218,9 @@
} }
activityManager.reset(); activityManager.reset();
eventManager.off('TransitionToAssetViewer', addInfoTransition!);
eventManager.off('TransitionToTimeline', addInfoTransition!);
eventManager.off('Finished', finished!);
}); });
const handleGetAllAlbums = async () => { const handleGetAllAlbums = async () => {
@ -226,6 +248,7 @@
}; };
const closeViewer = () => { const closeViewer = () => {
transitionName = 'hero';
onClose?.(asset); onClose?.(asset);
}; };
@ -235,6 +258,23 @@
}); });
}; };
const startTransition = (targetTransition: string | null, targetAsset?: AssetResponseDto) => {
transitionName = targetTransition;
equirectangularTransitionName = targetTransition;
detailPanelTransitionName = 'onTop';
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('StartViewTransition', () => {
if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) {
equirectangularTransitionName = null;
}
});
eventManager.once('AssetViewerFree', () => resolve());
}),
);
};
const tracker = new InvocationTracker(); const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => { const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
@ -247,7 +287,6 @@
} }
e?.stopPropagation(); e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) { if (tracker.isActive()) {
return; return;
} }
@ -256,6 +295,7 @@
let hasNext = false; let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
startTransition(null, undefined);
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) { if (!hasNext) {
const asset = await onRandom?.(); const asset = await onRandom?.();
@ -265,6 +305,13 @@
} }
} }
} else if (onNavigateToAsset) { } else if (onNavigateToAsset) {
// only transition if the target is already preloaded, and is in a secure context
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
if (!!targetAsset && globalThis.isSecureContext && preloadManager.isPreloaded(targetAsset)) {
const targetTransition = $slideshowState === SlideshowState.PlaySlideshow ? null : order;
startTransition(targetTransition, targetAsset);
}
resetZoomState();
hasNext = order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset); hasNext = order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset);
} else { } else {
hasNext = false; hasNext = false;
@ -421,11 +468,18 @@
$effect(() => { $effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id; asset.id;
if (viewerKind !== 'PhotoViewer') { if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
eventManager.emit('AssetViewerFree'); eventManager.emit('AssetViewerFree');
} }
}); });
const isEquirectangular = (asset: AssetResponseDto) => {
return (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
);
};
const viewerKind = $derived.by(() => { const viewerKind = $derived.by(() => {
if (previewStackedAsset) { if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer'; return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
@ -433,10 +487,7 @@
if (asset.type === AssetTypeEnum.Image) { if (asset.type === AssetTypeEnum.Image) {
if (shouldPlayMotionPhoto && asset.livePhotoVideoId) { if (shouldPlayMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer'; return 'LiveVideoViewer';
} else if ( } else if (isEquirectangular(asset)) {
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) {
return 'ImagePanaramaViewer'; return 'ImagePanaramaViewer';
} else if (isShowEditor && selectedEditType === 'crop') { } else if (isShowEditor && selectedEditType === 'crop') {
return 'CropArea'; return 'CropArea';
@ -454,12 +505,16 @@
<section <section
id="immich-asset-viewer" id="immich-asset-viewer"
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black" class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
class:invisible={$invisible}
use:focusTrap use:focusTrap
bind:this={assetViewerHtmlElement} bind:this={assetViewerHtmlElement}
> >
<!-- Top navigation bar --> <!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !isShowEditor} {#if $slideshowState === SlideshowState.None && !isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"> <div
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
style:view-transition-name="exclude"
>
<AssetViewerNavBar <AssetViewerNavBar
{asset} {asset}
{album} {album}
@ -508,11 +563,12 @@
{/if} {/if}
<!-- Asset Viewer --> <!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"> <div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full items-center flex">
{#if viewerKind === 'StackPhotoViewer'} {#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer <PhotoViewer
bind:zoomToggle bind:zoomToggle
bind:copyImage bind:copyImage
{transitionName}
asset={previewStackedAsset!} asset={previewStackedAsset!}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
@ -521,6 +577,7 @@
/> />
{:else if viewerKind === 'StackVideoViewer'} {:else if viewerKind === 'StackVideoViewer'}
<VideoViewer <VideoViewer
{transitionName}
assetId={previewStackedAsset!.id} assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash} cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType} projectionType={previewStackedAsset!.exifInfo?.projectionType}
@ -534,6 +591,7 @@
/> />
{:else if viewerKind === 'LiveVideoViewer'} {:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer <VideoViewer
{transitionName}
assetId={asset.livePhotoVideoId!} assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash} cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType} projectionType={asset.exifInfo?.projectionType}
@ -544,11 +602,12 @@
{playOriginalVideo} {playOriginalVideo}
/> />
{:else if viewerKind === 'ImagePanaramaViewer'} {:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} /> <ImagePanoramaViewer bind:zoomToggle {asset} transitionName={equirectangularTransitionName} />
{:else if viewerKind === 'CropArea'} {:else if viewerKind === 'CropArea'}
<CropArea {asset} /> <CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'} {:else if viewerKind === 'PhotoViewer'}
<PhotoViewer <PhotoViewer
{transitionName}
bind:zoomToggle bind:zoomToggle
bind:copyImage bind:copyImage
{asset} {asset}
@ -560,6 +619,7 @@
/> />
{:else if viewerKind === 'VideoViewer'} {:else if viewerKind === 'VideoViewer'}
<VideoViewer <VideoViewer
{transitionName}
assetId={asset.id} assetId={asset.id}
cacheKey={asset.thumbhash} cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType} projectionType={asset.exifInfo?.projectionType}
@ -601,8 +661,9 @@
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor} {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
<div <div
transition:fly={{ duration: 150 }} transition:slide={{ axis: 'x', duration: 150 }}
id="detail-panel" id="detail-panel"
style:view-transition-name={detailPanelTransitionName}
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light" class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes" translate="yes"
> >

@ -8,11 +8,12 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
type Props = { type Props = {
transitionName?: string | null;
asset: AssetResponseDto; asset: AssetResponseDto;
zoomToggle?: (() => void) | null; zoomToggle?: (() => void) | null;
}; };
let { asset, zoomToggle = $bindable() }: Props = $props(); let { transitionName, asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => { const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@ -20,11 +21,12 @@
}; };
</script> </script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <div transition:fade={{ duration: 150 }} class="flex h-dvh w-dvw select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} {#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner /> <LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]} {:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer <PhotoSphereViewer
{transitionName}
bind:zoomToggle bind:zoomToggle
panorama={data} panorama={data}
originalPanorama={isWebCompatibleImage(asset) originalPanorama={isWebCompatibleImage(asset)

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
@ -27,6 +28,7 @@
}; };
type Props = { type Props = {
transitionName?: string | null;
panorama: string | { source: string }; panorama: string | { source: string };
originalPanorama?: string | { source: string }; originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown]; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
@ -36,6 +38,7 @@
}; };
let { let {
transitionName,
panorama, panorama,
originalPanorama, originalPanorama,
adapter = EquirectangularAdapter, adapter = EquirectangularAdapter,
@ -154,6 +157,13 @@
zoomSpeed: 0.5, zoomSpeed: 0.5,
fisheye: false, fisheye: false,
}); });
viewer.addEventListener(
'ready',
() => {
eventManager.emit('AssetViewerFree');
},
{ once: true },
);
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin); const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100] // zoomLevel range: [0, 100]
@ -190,4 +200,9 @@
</script> </script>
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} /> <svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div> <div
id="sphere"
class="h-full w-full h-dvh w-dvw mb-0"
bind:this={container}
style:view-transition-name={transitionName}
></div>

@ -4,7 +4,6 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
@ -12,7 +11,7 @@
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState, resetZoomState } from '$lib/stores/zoom-image.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@ -25,9 +24,9 @@
import { onDestroy, onMount, untrack } from 'svelte'; import { onDestroy, onMount, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props { interface Props {
transitionName?: string | null;
asset: AssetResponseDto; asset: AssetResponseDto;
element?: HTMLDivElement | undefined; element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean; haveFadeTransition?: boolean;
@ -43,6 +42,7 @@
} }
let { let {
transitionName,
asset, asset,
element = $bindable(), element = $bindable(),
haveFadeTransition = true, haveFadeTransition = true,
@ -58,25 +58,46 @@
}: Props = $props(); }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
haveFadeTransition = true;
let imageLoaded: boolean = $state(false); let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false); let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false); let imageError: boolean = $state(false);
let loader = $state<HTMLImageElement>(); let loader = $state<HTMLImageElement>();
photoZoomState.set({ resetZoomState();
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
onDestroy(() => { onDestroy(() => {
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
const calculateSize = () => {
// Recalculate size when image is loaded/errored
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
imageLoaded || imageError;
const naturalWidth = loader?.naturalWidth ?? 1;
const naturalHeight = loader?.naturalHeight ?? 1;
const scaleX = containerWidth / naturalWidth;
const scaleY = containerHeight / naturalHeight;
// Use the smaller scale to ensure image fits (like object-fit: contain)
const scale = Math.min(scaleX, scaleY);
const scaledWidth = naturalWidth * scale;
const scaledHeight = naturalHeight * scale;
return {
width: scaledWidth,
height: scaledHeight,
left: (containerWidth - scaledWidth) / 2,
top: (containerHeight - scaledHeight) / 2,
};
};
const box = $derived(calculateSize());
let ocrBoxes = $derived( let ocrBoxes = $derived(
ocrManager.showOverlay && $photoViewerImgElement ocrManager.showOverlay && $photoViewerImgElement
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement) ? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
@ -225,7 +246,7 @@
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} /> <img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div <div
bind:this={element} bind:this={element}
class="relative h-full select-none" class="absolute h-full w-full select-none"
bind:clientWidth={containerWidth} bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
> >
@ -234,29 +255,34 @@
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
{:else if !imageError} {:else if !imageError}
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<div <div
use:zoomImageAction={{ disabled: isOcrActive }} use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)} {...useSwipe(onSwipe)}
class="h-full w-full" style:width={box.width + 'px'}
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }} style:height={box.height + 'px'}
style:left={box.left + 'px'}
style:top={box.top + 'px'}
class="absolute"
> >
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img <img
style:view-transition-name={transitionName}
bind:this={$photoViewerImgElement} bind:this={$photoViewerImgElement}
src={imageLoaderUrl} src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))} alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None class="w-full h-full {$slideshowState === SlideshowState.None
? 'object-contain' ? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}" : slideshowLookCssMapping[$slideshowLook]}"
draggable="false" draggable="false"
/> />
<!-- eslint-disable-next-line svelte/require-each-key --> <!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox} {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div <div

@ -18,6 +18,7 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { interface Props {
transitionName?: string | null;
assetId: string; assetId: string;
loopVideo: boolean; loopVideo: boolean;
cacheKey: string | null; cacheKey: string | null;
@ -30,6 +31,7 @@
} }
let { let {
transitionName,
assetId, assetId,
loopVideo, loopVideo,
cacheKey, cacheKey,
@ -115,12 +117,30 @@
videoPlayer?.pause(); videoPlayer?.pause();
} }
}); });
const calculateSize = () => {
const videoWidth = videoPlayer?.videoWidth ?? 1;
const videoHeight = videoPlayer?.videoHeight ?? 1;
const scaleX = containerWidth / videoWidth;
const scaleY = containerHeight / videoHeight;
// Use the smaller scale to ensure image fits (like object-fit: contain)
const scale = Math.min(scaleX, scaleY);
return {
width: videoWidth * scale + 'px',
height: videoHeight * scale + 'px',
};
};
let box = $derived(calculateSize());
</script> </script>
{#if showVideo} {#if showVideo}
<div <div
transition:fade={{ duration: assetViewerFadeDuration }} transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full select-none place-content-center place-items-center" class="flex select-none h-full w-full place-content-center place-items-center"
bind:clientWidth={containerWidth} bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
> >
@ -135,14 +155,17 @@
</div> </div>
{:else} {:else}
<video <video
style:view-transition-name={transitionName}
style:height={box.height}
style:width={box.width}
bind:this={videoPlayer} bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo} loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo} autoplay={$autoPlayVideo}
playsinline playsinline
controls controls
disablePictureInPicture disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)} {...useSwipe(onSwipe)}
onloadedmetadata={() => (box = calculateSize())}
oncanplay={(e) => handleCanPlay(e.currentTarget)} oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded} onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)} onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
@ -171,3 +194,9 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<style>
video:focus {
outline: none;
}
</style>

@ -5,10 +5,11 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { interface Props {
transitionName?: string | null;
assetId: string; assetId: string;
} }
const { assetId }: Props = $props(); const { assetId, transitionName }: Props = $props();
const modules = Promise.all([ const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default), import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
@ -23,6 +24,7 @@
<LoadingSpinner /> <LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]} {:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer <PhotoSphereViewer
{transitionName}
panorama={{ source: getAssetPlaybackUrl(assetId) }} panorama={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }} originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
plugins={[videoPlugin]} plugins={[videoPlugin]}

@ -4,6 +4,7 @@
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
interface Props { interface Props {
transitionName?: string | null;
assetId: string; assetId: string;
projectionType: string | null | undefined; projectionType: string | null | undefined;
cacheKey: string | null; cacheKey: string | null;
@ -17,6 +18,7 @@
} }
let { let {
transitionName,
assetId, assetId,
projectionType, projectionType,
cacheKey, cacheKey,
@ -31,9 +33,10 @@
</script> </script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR} {#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} /> <VideoPanoramaViewer {assetId} {transitionName} />
{:else} {:else}
<VideoNativeViewer <VideoNativeViewer
{transitionName}
{loopVideo} {loopVideo}
{cacheKey} {cacheKey}
{assetId} {assetId}

@ -1,10 +1,18 @@
<script lang="ts"> <script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import { ProjectionType } from '$lib/constants'; 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 { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time'; 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 { getAltText } from '$lib/utils/thumbnail-util';
import { TUNABLES } from '$lib/utils/tunables';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk'; import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,
mdiCameraBurst, mdiCameraBurst,
@ -15,21 +23,11 @@
mdiMotionPlayOutline, mdiMotionPlayOutline,
mdiRotate360, mdiRotate360,
} from '@mdi/js'; } 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 { onMount } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte'; import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
interface Props { interface Props {
asset: TimelineAsset; asset: TimelineAsset;
groupIndex?: number; groupIndex?: number;

@ -7,7 +7,8 @@
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; 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 UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; 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 { interface Props {
hideNavbar?: boolean; hideNavbar?: boolean;
@ -37,13 +38,17 @@
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden'); let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full'); 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);
</script> </script>
<header> <header>
{#if !hideNavbar} {#if !hideNavbar && !isAssetViewer}
<NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} /> <NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
{/if} {/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
{@render header?.()} {@render header?.()}
</header> </header>
<div <div
@ -53,13 +58,15 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''} {hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}" {hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
> >
{#if sidebar} {#if isAssetViewer}
<div></div>
{:else if sidebar}
{@render sidebar()} {@render sidebar()}
{:else} {:else}
<UserSidebar /> <UserSidebar />
{/if} {/if}
<main class="relative"> <main class="relative w-full">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}> <div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()} {@render children?.()}
</div> </div>

@ -2,15 +2,16 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.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 { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore; let { isUploading } = uploadAssetsStore;
type Props = { type Props = {
animationTargetAssetId?: string | null;
viewerAssets: ViewerAsset[]; viewerAssets: ViewerAsset[];
width: number; width: number;
height: number; height: number;
@ -26,10 +27,11 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>; 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 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 = <T extends { intersecting: boolean }>(intersectables: T[]) => { const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting); return intersectables.filter(({ intersecting }) => intersecting);
@ -41,16 +43,18 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)} {#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
{@const transitionName =
animationTargetAssetId === asset.id && !mobileDevice.prefersReducedMotion ? 'hero' : undefined}
<!-- note: don't remove data-asset-id - its used by web e2e tests --> <!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div <div
data-asset-id={asset.id} data-asset-id={asset.id}
class="absolute" class="absolute"
style:view-transition-name={transitionName}
style:top={position.top + 'px'} style:top={position.top + 'px'}
style:left={position.left + 'px'} style:left={position.left + 'px'}
style:width={position.width + 'px'} style:width={position.width + 'px'}
style:height={position.height + 'px'} style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }} animate:flip={{ duration: transitionDuration }}
> >
{@render thumbnail({ asset, position })} {@render thumbnail({ asset, position })}

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte'; import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
@ -11,9 +13,10 @@
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util'; import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte'; import { onDestroy, type Snippet } from 'svelte';
type Props = { type Props = {
toAssetViewerTransitionId?: string | null;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>; thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>; customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean; singleSelect: boolean;
@ -23,6 +26,7 @@
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void; onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
}; };
let { let {
toAssetViewerTransitionId,
thumbnail: thumbnailWithGroup, thumbnail: thumbnailWithGroup,
customThumbnailLayout, customThumbnailLayout,
singleSelect, singleSelect,
@ -51,6 +55,32 @@
}); });
return getDateLocaleString(date); return getDateLocaleString(date);
}; };
let toTimelineTransitionAssetId = $state<string | null>(null);
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
const transitionToTimelineCallback = ({ id }: { id: string }) => {
const asset = monthGroup.findAssetById({ id });
if (!asset) {
return;
}
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('TimelineLoaded', ({ id }) => {
animationTargetAssetId = id;
resolve();
});
}),
() => {
animationTargetAssetId = null;
},
);
};
eventManager.on('TransitionToTimeline', transitionToTimelineCallback);
onDestroy(() => {
eventManager.off('TransitionToTimeline', transitionToTimelineCallback);
});
</script> </script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} {#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
@ -95,6 +125,7 @@
</div> </div>
<AssetLayout <AssetLayout
{animationTargetAssetId}
{manager} {manager}
viewerAssets={dayGroup.viewerAssets} viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height} height={dayGroup.height}
@ -112,4 +143,188 @@
section { section {
contain: layout paint style; contain: layout paint style;
} }
:global(::view-transition) {
background: black;
animation-duration: 500ms;
}
:global(::view-transition-old(*)),
:global(::view-transition-new(*)) {
mix-blend-mode: normal;
animation-duration: inherit;
animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
}
:global(::view-transition-old(*)) {
animation-name: fadeOut forwards;
}
:global(::view-transition-new(*)) {
animation-name: fadeIn forwards;
}
:global(::view-transition-old(slideshow)) {
animation: 500ms 0s fadeOut forwards;
}
:global(::view-transition-new(slideshow)) {
animation: 500ms 0s fadeIn forwards;
}
:global(::view-transition-old(root)) {
animation: 500ms 0s fadeOut forwards;
animation-timing-function: inherit;
}
:global(::view-transition-new(root)) {
animation: 500ms 0s fadeIn forwards;
animation-timing-function: inherit;
}
:global(::view-transition-old(info)) {
animation: 250ms 0s flyOutRight forwards;
animation-timing-function: inherit;
}
:global(::view-transition-new(info)) {
animation: 250ms 0s flyInRight forwards;
animation-timing-function: inherit;
}
:global(::view-transition-old(onTop)),
:global(::view-transition-new(onTop)) {
z-index: 100;
animation: none;
}
:global(::view-transition-old(hero)) {
animation: 350ms fadeOut forwards;
align-content: center;
}
:global(::view-transition-new(hero)) {
animation: 350ms fadeIn forwards;
align-content: center;
}
:global(::view-transition-new(exclude)) {
animation: none;
}
:global(::view-transition-old(next)) {
animation: 500ms flyOutLeft forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
}
:global(::view-transition-new(next)) {
animation: 500ms flyInRight forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
}
:global(::view-transition-old(previous)) {
animation: 500ms flyOutRight forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
}
:global(::view-transition-new(previous)) {
animation: 500ms flyInLeft forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
}
:global(::view-transition-new(navbar)) {
z-index: 100;
animation: none;
}
@media (prefers-reduced-motion) {
:global(::view-transition-group(previous)),
:global(::view-transition-group(next)) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
:global(::view-transition-old(previous)),
:global(::view-transition-old(next)) {
animation: 500ms fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
:global(::view-transition-new(previous)),
:global(::view-transition-new(next)) {
animation: 500ms fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
@keyframes -global-flyInLeft {
from {
transform: translateX(-100vw) scale(0);
opacity: 0;
}
to {
transform: translateX(0) scale(1);
opacity: 1;
}
}
@keyframes -global-flyOutLeft {
from {
transform: translateX(0) scale(1);
opacity: 1;
}
to {
transform: translateX(-100vw) scale(0);
opacity: 0;
}
}
@keyframes -global-flyInRight {
from {
transform: translateX(100vw) scale(0);
opacity: 0;
}
to {
transform: translateX(0) scale(1);
opacity: 1;
}
}
/* Fly out to right */
@keyframes -global-flyOutRight {
from {
transform: translateX(0) scale(1);
opacity: 1;
}
to {
transform: translateX(100vw) scale(0);
opacity: 0;
}
}
@keyframes -global-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes -global-fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style> </style>

@ -11,6 +11,7 @@
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
interface Props { interface Props {
invisible: boolean;
/** Offset from the top of the timeline (e.g., for headers) */ /** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number; timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */ /** Offset from the bottom of the timeline (e.g., for footers) */
@ -39,6 +40,7 @@
} }
let { let {
invisible = false,
timelineTopOffset = 0, timelineTopOffset = 0,
timelineBottomOffset = 0, timelineBottomOffset = 0,
height = 0, height = 0,
@ -437,7 +439,7 @@
next = forward next = forward
? (focusable[(index + 1) % focusable.length] as HTMLElement) ? (focusable[(index + 1) % focusable.length] as HTMLElement)
: (focusable[(index - 1) % focusable.length] as HTMLElement); : (focusable[(index - 1) % focusable.length] as HTMLElement);
next.focus(); next?.focus();
} }
} }
} }
@ -508,6 +510,7 @@
aria-valuemin={toScrollY(0)} aria-valuemin={toScrollY(0)}
data-id="scrubber" data-id="scrubber"
class="absolute end-0 z-1 select-none hover:cursor-row-resize" class="absolute end-0 z-1 select-none hover:cursor-row-resize"
class:invisible
style:padding-top={PADDING_TOP + 'px'} style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'} style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width style:width

@ -11,6 +11,8 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@ -25,9 +27,8 @@
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk'; import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte'; import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
interface Props { interface Props {
isSelectionMode?: boolean; isSelectionMode?: boolean;
singleSelect?: boolean; singleSelect?: boolean;
@ -111,6 +112,7 @@
// Overall scroll percentage through the entire timeline (0-1) // Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0); let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0); let scrubberWidth = $state(0);
let toAssetViewerTransitionId = $state<string | null>(null);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mobileDevice.maxMd); const maxMd = $derived(mobileDevice.maxMd);
@ -218,7 +220,7 @@
timelineManager.viewportWidth = rect.width; timelineManager.viewportWidth = rect.width;
} }
} }
const scrollTarget = $gridScrollTarget?.at; const scrollTarget = getScrollTarget();
let scrolled = false; let scrolled = false;
if (scrollTarget) { if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget); scrolled = await scrollAndLoadAsset(scrollTarget);
@ -227,7 +229,9 @@
// if the asset is not found, scroll to the top // if the asset is not found, scroll to the top
timelineManager.scrollTo(0); timelineManager.scrollTo(0);
} }
invisible = false; if (!isAssetViewerRoute(page)) {
invisible = false;
}
}; };
// note: only modified once in afterNavigate() // note: only modified once in afterNavigate()
@ -245,10 +249,13 @@
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer; 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 // afterNavigate is only called after navigation to a new URL, {complete} will resolve
// after successful navigation. // after successful navigation.
afterNavigate(({ complete }) => { afterNavigate(({ complete }) => {
void complete.finally(() => { void complete.finally(async () => {
const isAssetViewerPage = isAssetViewerRoute(page); const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then // Set initial load state only once - if initialLoadWasAssetViewer is null, then
@ -257,8 +264,13 @@
if (isDirectNavigation) { if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer; initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
} }
void scrollAfterNavigate(); void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
}); });
}); });
@ -268,7 +280,7 @@
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => { onMount(() => {
if (!enableRouting) { if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false; invisible = false;
} }
}); });
@ -609,6 +621,7 @@
{#if timelineManager.months.length > 0} {#if timelineManager.months.length > 0}
<Scrubber <Scrubber
{timelineManager} {timelineManager}
{invisible}
height={timelineManager.viewportHeight} height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight} timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight} timelineBottomOffset={timelineManager.bottomSectionHeight}
@ -688,6 +701,7 @@
style:width="100%" style:width="100%"
> >
<Month <Month
{toAssetViewerTransitionId}
{assetInteraction} {assetInteraction}
{customThumbnailLayout} {customThumbnailLayout}
{singleSelect} {singleSelect}
@ -707,6 +721,24 @@
{albumUsers} {albumUsers}
{groupIndex} {groupIndex}
onClick={(asset) => { onClick={(asset) => {
// tag target on the 'old' snapshot
toAssetViewerTransitionId = asset.id;
viewTransitionManager.startTransition(
new Promise((resolve) =>
eventManager.once('AssetViewerFree', () => {
eventManager.emit('TransitionToAssetViewer');
resolve();
}),
),
);
eventManager.once('StartViewTransition', () => {
// remove target on the 'old' view,
// asset-viewer will tag new target element for 'new' snapshot
toAssetViewerTransitionId = null;
});
if (typeof onThumbnailClick === 'function') { if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick); onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else { } else {

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@ -8,7 +9,7 @@
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk'; import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore; let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
@ -43,7 +44,7 @@
const getNextAsset = async (currentAsset: AssetResponseDto) => { const getNextAsset = async (currentAsset: AssetResponseDto) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset); const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) { if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id }); const asset = assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
return asset; return asset;
} }
}; };
@ -51,7 +52,7 @@
const getPreviousAsset = async (currentAsset: AssetResponseDto) => { const getPreviousAsset = async (currentAsset: AssetResponseDto) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset); const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (laterTimelineAsset) { if (laterTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id }); const asset = assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
return asset; return asset;
} }
}; };
@ -103,6 +104,10 @@
}; };
const handleClose = async (asset: { id: string }) => { const handleClose = async (asset: { id: string }) => {
const awaitInit = new Promise<void>((resolve) => eventManager.once('StartViewTransition', resolve));
eventManager.emit('TransitionToTimeline', { id: asset.id });
await awaitInit;
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
invisible = true; invisible = true;
$gridScrollTarget = { at: asset.id }; $gridScrollTarget = { at: asset.id };

@ -1,5 +1,5 @@
import { getAssetUrl } from '$lib/utils'; import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; import { cancelImageUrl, isImageUrlCached, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
class PreloadManager { class PreloadManager {
@ -17,6 +17,16 @@ class PreloadManager {
} }
} }
isPreloaded(asset: AssetResponseDto | undefined) {
if (!asset) {
return false;
}
if (globalThis.isSecureContext) {
return isImageUrlCached(getAssetUrl({ asset }));
}
return false;
}
cancel(asset: AssetResponseDto | undefined) { cancel(asset: AssetResponseDto | undefined) {
if (!globalThis.isSecureContext || !asset) { if (!globalThis.isSecureContext || !asset) {
return; return;

@ -0,0 +1,41 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
class ViewTransitionManager {
startTransition(domUpdateComplete: Promise<void>, finishedCallback?: () => void) {
// good time to add view-transition-name styles (if needed)
eventManager.emit('BeforeStartViewTransition');
// next call will create the 'old' view snapshot
// eslint-disable-next-line tscompat/tscompat
const transition = document.startViewTransition(async () => {
try {
// Good time to remove any view-transition-name styles created during
// BeforeStartViewTransition, then trigger the actual view transition.
eventManager.emit('StartViewTransition');
await domUpdateComplete;
} catch (error: unknown) {
console.log('exception', error);
}
});
// 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(() => eventManager.emit('UpdateCallbackDone'))
.catch((error: unknown) => console.log('exception', 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) => console.log('exception in ready', error));
// Transition is complete
// eslint-disable-next-line tscompat/tscompat
transition.finished
.then(() => eventManager.emit('Finished'))
.catch((error: unknown) => console.log('exception in finished', error));
// eslint-disable-next-line tscompat/tscompat
void transition.finished.then(() => finishedCallback?.());
}
}
export const viewTransitionManager = new ViewTransitionManager();

@ -44,6 +44,20 @@ export type Events = {
ReleaseEvent: [ReleaseEvent]; ReleaseEvent: [ReleaseEvent];
TransitionToTimeline: [{ id: string }];
TimelineLoaded: [{ id: string | null }];
TransitionToAssetViewer: [];
AssetViewerLoaded: [];
RenderLoaded: [];
BeforeStartViewTransition: [];
Finished: [];
Ready: [];
UpdateCallbackDone: [];
StartViewTransition: [];
AssetViewerFree: []; AssetViewerFree: [];
}; };
@ -57,11 +71,11 @@ class EventManager<EventMap extends Record<string, unknown[]>> {
}[]; }[];
} = {}; } = {};
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) { on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
return this.addListener(key, listener, false); return this.addListener(key, listener, false);
} }
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) { once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
return this.addListener(key, listener, true); return this.addListener(key, listener, true);
} }

@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() { function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>(); const viewingAssetStoreState = writable<AssetResponseDto>();
const invisible = writable<boolean>(false);
const viewState = writable<boolean>(false); const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
@ -30,6 +31,7 @@ function createAssetViewingStore() {
setAsset, setAsset,
setAssetId, setAssetId,
showAssetViewer, showAssetViewer,
invisible,
}; };
} }

@ -1,6 +1,7 @@
import { MediaQuery } from 'svelte/reactivity'; import { MediaQuery } from 'svelte/reactivity';
const pointerCoarse = new MediaQuery('pointer:coarse'); const pointerCoarse = new MediaQuery('pointer:coarse');
const reducedMotion = new MediaQuery('prefers-reduced-motion');
const maxMd = new MediaQuery('max-width: 767px'); const maxMd = new MediaQuery('max-width: 767px');
const sidebar = new MediaQuery(`min-width: 850px`); const sidebar = new MediaQuery(`min-width: 850px`);
@ -14,4 +15,7 @@ export const mobileDevice = {
get isFullSidebar() { get isFullSidebar() {
return sidebar.current; return sidebar.current;
}, },
get prefersReducedMotion() {
return reducedMotion.current;
},
}; };

@ -1,5 +1,5 @@
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { getAssetOcr } from '@immich/sdk';
export type OcrBoundingBox = { export type OcrBoundingBox = {
id: string; id: string;
@ -38,7 +38,7 @@ class OcrManager {
this.#cleared = false; this.#cleared = false;
} }
await this.#ocrLoader.execute(async () => { await this.#ocrLoader.execute(async () => {
this.#data = await getAssetOcr({ id }); this.#data = await assetCacheManager.getAssetOcr(id);
}, false); }, false);
} }

@ -2,3 +2,13 @@ import type { ZoomImageWheelState } from '@zoom-image/core';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export const photoZoomState = writable<ZoomImageWheelState>(); export const photoZoomState = writable<ZoomImageWheelState>();
export const resetZoomState = () => {
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
};

@ -24,7 +24,7 @@
}); });
</script> </script>
<div class:display-none={$showAssetViewer}> <div>
{@render children?.()} {@render children?.()}
</div> </div>
<UploadCover /> <UploadCover />
@ -33,7 +33,4 @@
:root { :root {
overscroll-behavior: none; overscroll-behavior: none;
} }
.display-none {
display: none;
}
</style> </style>

@ -1,5 +1,17 @@
<script lang="ts" module>
export class AppState {
#isAssetViewer = $state<boolean>(false);
set isAssetViewer(value) {
this.#isAssetViewer = value;
}
get isAssetViewer() {
return this.#isAssetViewer;
}
}
</script>
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte'; import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
@ -23,7 +35,7 @@
import { isAssetViewerRoute } from '$lib/utils/navigation'; import { isAssetViewerRoute } from '$lib/utils/navigation';
import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui'; import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js'; import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
import { onMount, type Snippet } from 'svelte'; import { onMount, setContext, type Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import '../app.css'; import '../app.css';
@ -49,6 +61,10 @@
let showNavigationLoadingBar = $state(false); let showNavigationLoadingBar = $state(false);
const appState = new AppState();
appState.isAssetViewer = isAssetViewerRoute(page);
setContext('AppState', appState);
const getMyImmichLink = () => { const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app'); return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
}; };
@ -74,8 +90,14 @@
showNavigationLoadingBar = true; showNavigationLoadingBar = true;
}); });
afterNavigate(() => { onNavigate(({ to }) => {
showNavigationLoadingBar = false; appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
});
afterNavigate(({ to, complete }) => {
appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
void complete.finally(() => {
showNavigationLoadingBar = false;
});
}); });
$effect.pre(() => { $effect.pre(() => {