feat: web - view transitions from timeline to viewer, next/prev

feat: web - view transitions from timeline to viewer, next/prev

feat: web - swipe feedback - show image while swiping/dragging left/right

feat: web - swipe feedback - show image while swiping/dragging left/right
This commit is contained in:
midzelis
2025-12-08 11:36:17 +00:00
parent d13895748a
commit cceb143f4f
24 changed files with 1102 additions and 245 deletions

View File

@@ -74,6 +74,20 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
/* view transition variables */
--vt-duration-default: 250ms;
--vt-duration-hero: 350ms;
--vt-duration-slideshow: 1s;
--vt-viewer-slide-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--vt-viewer-slide-distance: 15%;
--vt-viewer-opacity-start: 0.1;
--vt-viewer-opacity-mid: 0.4;
--vt-viewer-blur-max: 4px;
--vt-viewer-blur-mid: 2px;
}
button:not(:disabled),
@@ -171,3 +185,318 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::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: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(root) {
animation: var(--vt-duration-slideshow) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-slideshow) 0s fadeIn forwards;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: var(--vt-duration-hero) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-hero) 0s fadeIn forwards;
}
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s flyOutRight forwards;
}
::view-transition-new(info) {
animation: var(--vt-duration-default) 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: var(--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: var(--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: var(--vt-duration-hero) fadeOut forwards;
align-content: center;
}
::view-transition-new(hero) {
animation: var(--vt-duration-hero) fadeIn forwards;
align-content: center;
}
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutLeft forwards;
overflow: hidden;
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInRight forwards;
overflow: hidden;
}
::view-transition-old(previous) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
}
::view-transition-old(previous-old) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
overflow: hidden;
z-index: -1;
}
::view-transition-new(previous) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
}
::view-transition-new(previous-new) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
overflow: hidden;
}
@keyframes flyInLeft {
from {
/* object-position: -25dvw; */
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
/* object-position: -25dvw; */
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes flyInRight {
from {
/* object-position: 25dvw; */
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
opacity: 1;
filter: blur(0);
}
}
/* Fly out to right */
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
/* object-position: 50dvw 0px; */
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Reduced motion: when system preference is set */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
animation-name: none;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom) {
animation: var(--vt-duration-default) fadeOut forwards;
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: var(--vt-duration-default) fadeIn forwards;
}
::view-transition-group(previous),
::view-transition-group(previous-old),
::view-transition-group(next),
::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-default) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(previous-new),
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-default) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
}

View File

@@ -3,6 +3,7 @@
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
import { photoZoomState, photoZoomTransform, resetZoomState } from '$lib/stores/zoom-image.store';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
@@ -25,6 +26,7 @@
};
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
transitionName?: string | null | undefined;
onImageReady?: () => void;
onError?: () => void;
imgElement?: HTMLImageElement;
@@ -42,6 +44,7 @@
container,
slideshowState,
slideshowLook,
transitionName,
onImageReady,
onError,
overlays,
@@ -97,6 +100,10 @@
};
});
const blurredSlideshow = $derived(
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
const thumbnailUrl = $derived(loadState.thumbnailUrl);
@@ -121,119 +128,140 @@
});
</script>
<div
class="relative h-full w-full"
style:left={renderDimensions.left}
style:top={renderDimensions.top}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
bind:this={imgContainerElement}
>
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<div style:transform-origin="0px 0px" style:transform={$photoZoomTransform} class="h-full w-full absolute">
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
</div>
{:else if showSpinner}
<div id="spinner" class="absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
<div class="relative h-full w-full">
<!-- Blurred slideshow background (full viewport) -->
{#if blurredSlideshow}
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash! }} class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
></canvas>
{/if}
<div
use:imageLoader={{
src: thumbnailUrl,
onStart: () => adaptiveImageLoader.onThumbnailStart(),
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
onError: () => adaptiveImageLoader.onThumbnailError(),
onElementCreated: (el) => (thumbnailElement = el),
imgClass: ['absolute h-full', 'w-full'],
alt: '',
role: 'presentation',
dataAttributes: {
'data-testid': 'thumbnail',
},
}}
></div>
<!-- Letterbox regions (empty space around image) -->
<Letterboxes
{transitionName}
{slideshowState}
{slideshowLook}
hasThumbhash={!!asset.thumbhash}
{scaledDimensions}
{container}
/>
{#if showBrokenAsset}
<div class="h-full w-full absolute">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{:else}
<!-- Slideshow blurred background -->
{#if thumbnailUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground}
<img
src={thumbnailUrl}
alt=""
role="presentation"
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
<!-- Main image box with transition -->
<div
style:view-transition-name={transitionName}
data-transition-name={transitionName}
class="absolute"
style:left={renderDimensions.left}
style:top={renderDimensions.top}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
bind:this={imgContainerElement}
>
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<div style:transform-origin="0px 0px" style:transform={$photoZoomTransform} class="h-full w-full absolute">
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
</div>
{:else if showSpinner}
<div id="spinner" class="absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{/if}
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
use:imageLoader={{
src: thumbnailUrl,
onStart: () => adaptiveImageLoader.onThumbnailStart(),
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
onError: () => adaptiveImageLoader.onThumbnailError(),
onElementCreated: (el) => (thumbnailElement = el),
imgClass: ['absolute h-full', 'w-full'],
alt: '',
role: 'presentation',
dataAttributes: {
'data-testid': 'thumbnail',
},
}}
></div>
{#if showBrokenAsset}
<div class="h-full w-full absolute">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{:else}
<!-- Slideshow blurred background -->
{#if thumbnailUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground}
<img
src={thumbnailUrl}
alt=""
role="presentation"
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<div
use:imageLoader={{
src: previewUrl,
onStart: () => adaptiveImageLoader.onPreviewStart(),
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
onError: () => adaptiveImageLoader.onPreviewError(),
onElementCreated: (el) => (previewElement = el),
imgClass: ['h-full', 'w-full', { imageClass }],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'preview',
},
}}
></div>
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<div
use:imageLoader={{
src: previewUrl,
onStart: () => adaptiveImageLoader.onPreviewStart(),
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
onError: () => adaptiveImageLoader.onPreviewError(),
onElementCreated: (el) => (previewElement = el),
imgClass: ['h-full', 'w-full', { imageClass }],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'preview',
},
}}
></div>
{@render overlays?.()}
</div>
{@render overlays?.()}
</div>
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<div
use:imageLoader={{
src: originalUrl,
onStart: () => adaptiveImageLoader.onOriginalStart(),
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
onError: () => adaptiveImageLoader.onOriginalError(),
onElementCreated: (el) => (originalElement = el),
imgClass: ['h-full', 'w-full', { imageClass }],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'original',
},
}}
></div>
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<div
use:imageLoader={{
src: originalUrl,
onStart: () => adaptiveImageLoader.onOriginalStart(),
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
onError: () => adaptiveImageLoader.onOriginalError(),
onElementCreated: (el) => (originalElement = el),
imgClass: ['h-full', 'w-full', { imageClass }],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'original',
},
}}
></div>
{@render overlays?.()}
</div>
{@render overlays?.()}
</div>
<!-- Use placeholder empty image to zoomImage so it can monitor mouse-wheel events and update zoom state -->
<div
class="absolute top-0"
use:zoomImageAction={{ disabled: zoomDisabled }}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<img alt="" class="absolute h-full w-full hidden" draggable="false" />
</div>
{/if}
<!-- Use placeholder empty image to zoomImage so it can monitor mouse-wheel events and update zoom state -->
<div
class="absolute top-0"
use:zoomImageAction={{ disabled: zoomDisabled }}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<img alt="" class="absolute h-full w-full hidden" draggable="false" />
</div>
{/if}
</div>
</div>
<style>

View File

@@ -13,6 +13,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
@@ -37,9 +38,9 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
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,
@@ -110,6 +111,10 @@
let fullscreenElement = $state<Element>();
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);
@@ -152,38 +157,62 @@
}
};
let transitionName = $state<string | undefined>('hero');
let equirectangularTransitionName = $state<string | undefined>('hero');
let detailPanelTransitionName = $state<string | undefined>(undefined);
let unsubscribes: (() => void)[] = [];
let addInfoTransition;
let finished;
onMount(() => {
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
});
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
});
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
addInfoTransition = () => {
detailPanelTransitionName = 'info';
transitionName = 'hero';
equirectangularTransitionName = 'hero';
};
eventManager.on('TransitionToAssetViewer', addInfoTransition);
eventManager.on('TransitionToTimeline', addInfoTransition);
finished = () => {
detailPanelTransitionName = undefined;
transitionName = undefined;
};
eventManager.on('Finished', finished);
unsubscribes.push(
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow());
}
}),
slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
}),
);
});
onDestroy(() => {
activityManager.reset();
eventManager.off('TransitionToAssetViewer', addInfoTransition!);
eventManager.off('TransitionToTimeline', addInfoTransition!);
eventManager.off('Finished', finished!);
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
destroyNextPreloader();
destroyPreviousPreloader();
});
const closeViewer = () => {
transitionName = 'hero';
onClose?.(asset);
};
@@ -196,6 +225,35 @@
isShowEditor = false;
};
const startTransition = async (
types: string[],
targetTransition: string | null,
targetAsset: AssetResponseDto | null,
navigateFn: () => Promise<boolean>,
) => {
transitionName = viewTransitionManager.getTransitionName('old', targetTransition);
equirectangularTransitionName = viewTransitionManager.getTransitionName('old', targetTransition);
detailPanelTransitionName = 'detail-panel';
await tick();
const navigationResult = new Promise<boolean>((navigationResolve) => {
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('StartViewTransition', async () => {
transitionName = viewTransitionManager.getTransitionName('new', targetTransition);
if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) {
equirectangularTransitionName = undefined;
}
await tick();
navigationResolve(await navigateFn());
});
eventManager.once('AssetViewerFree', () => tick().then(resolve));
}),
types,
);
});
return navigationResult;
};
let nextPreloader: AdaptiveImageLoader | undefined;
let previousPreloader: AdaptiveImageLoader | undefined;
let nextPreviewUrl = $state<string | undefined>();
@@ -282,10 +340,10 @@
};
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next') => {
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;
}
@@ -296,32 +354,55 @@
}
cancelPreloadsBeforeNavigation(order);
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 {
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(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 navigateToAsset(previousAsset) : await navigateToAsset(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();
}
}
if ($slideshowState !== SlideshowState.PlaySlideshow) {
return;
}
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
if (slideShowPlaying) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
}, $t('error_while_navigating'));
};
@@ -360,10 +441,11 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
if (!document.fullscreenElement) {
return;
}
document.body.style.cursor = '';
await document.exitFullscreen();
} catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
@@ -391,9 +473,10 @@
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
if (!stack) {
break;
}
cursor.current = stack.assets[0];
break;
}
case AssetAction.STACK:
@@ -463,18 +546,20 @@
if (cursor.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
// After navigation completes, reconcile preloads with full state information
updatePreloadsAfterNavigation(lastCursor, cursor);
lastCursor = cursor;
return;
}
if (!lastCursor && cursor) {
// "first time" load, start preloads
if (cursor.nextAsset) {
nextPreloader = startPreloader(cursor.nextAsset, 'next');
}
if (cursor.previousAsset) {
previousPreloader = startPreloader(cursor.previousAsset, 'previous');
}
// "first time" load, start preloads
if (cursor.nextAsset) {
nextPreloader = startPreloader(cursor.nextAsset, 'next');
}
if (cursor.previousAsset) {
previousPreloader = startPreloader(cursor.previousAsset, 'previous');
}
lastCursor = cursor;
});
@@ -494,26 +579,36 @@
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
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';
}
if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer';
if (asset.type === AssetTypeEnum.Image) {
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
} else if (isEquirectangular(asset)) {
return 'ImagePanaramaViewer';
} else if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
return 'CropArea';
}
return 'PhotoViewer';
}
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
}
if (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) {
return 'ImagePanaramaViewer';
}
if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
return 'CropArea';
}
return 'PhotoViewer';
return 'VideoViewer';
});
const showActivityStatus = $derived(
@@ -538,12 +633,16 @@
<section
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:invisible={$invisible}
use:focusTrap
bind:this={assetViewerHtmlElement}
>
<!-- Top navigation bar -->
{#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
{asset}
{album}
@@ -578,23 +677,29 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<div
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
style:view-transition-name="exclude-leftbutton"
>
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- 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'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{transitionName}
cursor={{ ...cursor, current: previewStackedAsset! }}
{sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'previous' : 'next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)}
onReady={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
{transitionName}
cursor={{ ...cursor, current: previewStackedAsset! }}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
@@ -608,6 +713,7 @@
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{transitionName}
{cursor}
assetId={asset.livePhotoVideoId!}
{sharedLink}
@@ -619,19 +725,22 @@
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} />
<ImagePanoramaViewer bind:zoomToggle {asset} transitionName={equirectangularTransitionName} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
{transitionName}
bind:zoomToggle
bind:copyImage
{cursor}
{sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)}
onReady={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{transitionName}
{cursor}
assetId={asset.id}
{sharedLink}
@@ -666,16 +775,20 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<div
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
style:view-transition-name="exclude-rightbutton"
>
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !isShowEditor}
<div
transition:fly={{ duration: 150 }}
transition:slide={{ axis: 'x', duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
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"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} />

View File

@@ -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,

View File

@@ -8,11 +8,12 @@
import { fade } from 'svelte/transition';
type Props = {
transitionName?: string;
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 @@
};
</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')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
{transitionName}
bind:zoomToggle
panorama={data}
originalPanorama={isWebCompatibleImage(asset)

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
interface Props {
transitionName?: string | null | undefined;
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
hasThumbhash: boolean;
scaledDimensions: {
width: number;
height: number;
};
container: {
width: number;
height: number;
};
}
let { transitionName, slideshowState, slideshowLook, hasThumbhash, scaledDimensions, container }: Props = $props();
const blurredSlideshow = $derived(
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && hasThumbhash,
);
const shouldShowLetterboxes = $derived(!!transitionName && transitionName !== 'hero' && !blurredSlideshow);
const transitionLetterboxLeft = $derived(shouldShowLetterboxes ? 'letterbox-left' : null);
const transitionLetterboxRight = $derived(shouldShowLetterboxes ? 'letterbox-right' : null);
const transitionLetterboxTop = $derived(shouldShowLetterboxes ? 'letterbox-top' : null);
const transitionLetterboxBottom = $derived(shouldShowLetterboxes ? 'letterbox-bottom' : null);
// Letterbox regions (the empty space around the main box)
const letterboxLeft = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (container.width - width) / 2;
return {
width: leftOffset + 'px',
height: container.height + 'px',
left: '0px',
top: '0px',
};
});
const letterboxRight = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (container.width - width) / 2;
const rightOffset = leftOffset;
return {
width: rightOffset + 'px',
height: container.height + 'px',
left: container.width - rightOffset + 'px',
top: '0px',
};
});
const letterboxTop = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (container.height - height) / 2;
const leftOffset = (container.width - width) / 2;
return {
width: width + 'px',
height: topOffset + 'px',
left: leftOffset + 'px',
top: '0px',
};
});
const letterboxBottom = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (container.height - height) / 2;
const bottomOffset = topOffset;
const leftOffset = (container.width - width) / 2;
return {
width: width + 'px',
height: bottomOffset + 'px',
left: leftOffset + 'px',
top: container.height - bottomOffset + 'px',
};
});
</script>
<!-- Letterbox regions (empty space around image) -->
<div
class="absolute"
style:view-transition-name={transitionLetterboxLeft}
style:left={letterboxLeft.left}
style:top={letterboxLeft.top}
style:width={letterboxLeft.width}
style:height={letterboxLeft.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxRight}
style:left={letterboxRight.left}
style:top={letterboxRight.top}
style:width={letterboxRight.width}
style:height={letterboxRight.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxTop}
style:left={letterboxTop.left}
style:top={letterboxTop.top}
style:width={letterboxTop.width}
style:height={letterboxTop.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxBottom}
style:left={letterboxBottom.left}
style:top={letterboxBottom.top}
style:width={letterboxBottom.width}
style:height={letterboxBottom.height}
></div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
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;
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>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
@@ -190,4 +200,9 @@
</script>
<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>

View File

@@ -26,6 +26,7 @@
cursor: AssetCursor;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
transitionName?: string;
onReady?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
copyImage?: () => Promise<void>;
@@ -36,6 +37,7 @@
cursor,
element = $bindable(),
sharedLink,
transitionName,
onReady,
onSwipe,
copyImage = $bindable(),
@@ -162,6 +164,7 @@
onError={() => onReady?.()}
bind:imgElement={$photoViewerImgElement}
bind:imgContainerElement
{transitionName}
>
{#snippet overlays()}
<!-- eslint-disable-next-line svelte/require-each-key -->

View File

@@ -6,6 +6,7 @@
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import {
autoPlayVideo,
@@ -108,6 +109,7 @@
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
eventManager.emit('AssetViewerFree');
};
const handleCanPlay = async (video: HTMLVideoElement) => {

View File

@@ -5,10 +5,11 @@
import { fade } from 'svelte/transition';
interface Props {
transitionName?: string;
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 @@
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer
{transitionName}
panorama={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
plugins={[videoPlugin]}

View File

@@ -6,6 +6,7 @@
import type { SharedLinkResponseDto } from '@immich/sdk';
interface Props {
transitionName?: string;
cursor: AssetCursor;
assetId: string;
sharedLink?: SharedLinkResponseDto;
@@ -20,6 +21,7 @@
}
let {
transitionName,
cursor,
assetId,
sharedLink,
@@ -35,9 +37,10 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} />
<VideoPanoramaViewer {assetId} {transitionName} />
{:else}
<VideoNativeViewer
{transitionName}
{loopVideo}
{cacheKey}
{cursor}

View File

@@ -6,10 +6,11 @@
import { useActions, type ActionArray } from '$lib/actions/use-actions';
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 { appManager } from '$lib/managers/app-manager.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import type { Snippet } from 'svelte';
import { type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -44,12 +45,17 @@
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
let isAssetViewer = $derived(appManager.isAssetViewer);
</script>
<header>
{#if !hideNavbar}
{#if !hideNavbar && !isAssetViewer}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
</header>
<div
tabindex="-1"
@@ -58,13 +64,15 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>
{#if sidebar}
{#if isAssetViewer}
<div></div>
{:else if sidebar}
{@render sidebar()}
{:else}
<UserSidebar />
{/if}
<main class="relative">
<main class="relative w-full">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()}
</div>

View File

@@ -1,20 +1,14 @@
<script lang="ts">
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 { 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;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
@@ -26,10 +20,7 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { 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 { animationTargetAssetId, viewerAssets, width, height, thumbnail, customThumbnailLayout }: Props = $props();
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
@@ -41,18 +32,20 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
data-transition-name={transitionName}
style:view-transition-name={transitionName}
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + '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 customThumbnailLayout?.(asset)}
</div>

View File

@@ -1,34 +1,36 @@
<script lang="ts">
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 type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
import { onDestroy, tick, type Snippet } from 'svelte';
type Props = {
toAssetViewerTransitionId?: string | null;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
};
let {
toAssetViewerTransitionId,
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
}: Props = $props();
@@ -51,6 +53,34 @@
});
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;
void tick().then(resolve);
});
}),
['timeline'],
() => {
animationTargetAssetId = null;
},
);
};
if (viewTransitionManager.isSupported()) {
eventManager.on('TransitionToTimeline', transitionToTimelineCallback);
onDestroy(() => {
eventManager.off('TransitionToTimeline', transitionToTimelineCallback);
});
}
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
@@ -95,7 +125,7 @@
</div>
<AssetLayout
{manager}
{animationTargetAssetId}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}

View File

@@ -11,6 +11,7 @@
import { fade, fly } from 'svelte/transition';
interface Props {
invisible: boolean;
/** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */
@@ -39,6 +40,7 @@
}
let {
invisible = false,
timelineTopOffset = 0,
timelineBottomOffset = 0,
height = 0,
@@ -437,7 +439,7 @@
next = forward
? (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)}
data-id="scrubber"
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
class:invisible
style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width

View File

@@ -12,6 +12,8 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.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 { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -28,7 +30,6 @@
import { DateTime } from 'luxon';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
isSelectionMode?: boolean;
singleSelect?: boolean;
@@ -104,6 +105,7 @@
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0);
let toAssetViewerTransitionId = $state<string | null>(null);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mediaQueryManager.maxMd);
@@ -211,7 +213,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = getScrollTarget();
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -223,7 +225,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = false;
invisible = isAssetViewerRoute(page) ? true : false;
};
// note: only modified once in afterNavigate()
@@ -241,10 +243,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
@@ -253,8 +258,13 @@
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
});
});
@@ -264,7 +274,7 @@
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting) {
if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false;
}
});
@@ -561,19 +571,6 @@
isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length);
};
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -604,6 +601,7 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -683,11 +681,11 @@
style:width="100%"
>
<Month
{toAssetViewerTransitionId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -701,12 +699,56 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
onClick={async (asset) => {
const onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const dispatchClick = () => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, onClick);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
};
const hasThumbnailClick = typeof onThumbnailClick === 'function';
const selectingAssets = isSelectionMode || assetInteraction.selectionActive;
if (!viewTransitionManager.isSupported() || hasThumbnailClick || selectingAssets) {
dispatchClick();
return;
}
// tag target on the 'old' snapshot
toAssetViewerTransitionId = asset.id;
await tick();
eventManager.once('StartViewTransition', () => {
toAssetViewerTransitionId = null;
dispatchClick();
});
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('AssetViewerFree', () => {
void tick().then(() => {
eventManager.emit('TransitionToAssetViewer');
resolve();
});
});
}),
['viewer'],
);
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {

View File

@@ -4,6 +4,7 @@
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';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -97,6 +98,10 @@
};
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);
invisible = true;
$gridScrollTarget = { at: asset.id };

View File

@@ -49,6 +49,7 @@
$locale = newLocale;
}
};
let editedLocale = $derived(findLocale($locale).code);
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
let selectedOption = $derived({

View File

@@ -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<ViewTransition | null>(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 undefined;
};
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<unknown>, 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();

View File

@@ -0,0 +1,13 @@
class AppManager {
#isAssetViewer = $state<boolean>(false);
set isAssetViewer(value: boolean) {
this.#isAssetViewer = value;
}
get isAssetViewer() {
return this.#isAssetViewer;
}
}
export const appManager = new AppManager();

View File

@@ -72,6 +72,19 @@ export type Events = {
SessionLocked: [];
TransitionToTimeline: [{ id: string }];
TimelineLoaded: [{ id: string | null }];
TransitionToAssetViewer: [];
AssetViewerLoaded: [];
AssetViewerFree: [];
BeforeStartViewTransition: [];
Finished: [];
Ready: [];
UpdateCallbackDone: [];
StartViewTransition: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];
@@ -95,11 +108,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);
}
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);
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
@@ -8,6 +8,7 @@
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import { appManager } from '$lib/managers/app-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -58,6 +59,8 @@
let showNavigationLoadingBar = $state(false);
appManager.isAssetViewer = isAssetViewerRoute(page);
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -83,8 +86,14 @@
showNavigationLoadingBar = true;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
onNavigate(({ to }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
});
afterNavigate(({ to, complete }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
void complete.finally(() => {
showNavigationLoadingBar = false;
});
});
const { serverRestarting } = websocketStore;