view transitions

pull/24357/head
midzelis 2025-12-13 00:53:56 +00:00
parent ea1ef3df13
commit f7b08fcd95
10 changed files with 481 additions and 340 deletions

View File

@ -74,6 +74,9 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
}
button:not(:disabled),

View File

@ -35,10 +35,6 @@ interface SwipeAnimations {
export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions) => {
// Animation configuration
const ANIMATION_DURATION_MS = 300;
// Drag sensitivity: pixels needed to reach 100% animation progress
// Higher value = less sensitive (need to drag further)
// Lower value = more sensitive (animation advances quickly)
const DRAG_DISTANCE_FOR_FULL_ANIMATION = 400;
// Enable/disable scaling effect during animation
const ENABLE_SCALE_ANIMATION = false;
@ -103,7 +99,7 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
}
const duration = ANIMATION_DURATION_MS;
const easing = 'cubic-bezier(0.33, 1, 0.68, 1)'; // Match Month.svelte:156
const easing = 'linear'; // Linear easing to match drag rate
// Set transform origin to center for proper scaling
imgElement.style.transformOrigin = 'center';
@ -118,17 +114,11 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
? [
// flyOutLeft - Month.svelte:280-289
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(-20vw)${scale(0.8)}`, opacity: '1', offset: 0.2 },
{ transform: `translateX(-50vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(-80vw)${scale(0.2)}`, opacity: '0', offset: 0.8 },
{ transform: `translateX(-100vw)${scale(0)}`, opacity: '0', offset: 1 },
]
: [
// flyOutRight - Month.svelte:303-312
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(20vw)${scale(0.8)}`, opacity: '1', offset: 0.2 },
{ transform: `translateX(50vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(80vw)${scale(0.2)}`, opacity: '0', offset: 0.8 },
{ transform: `translateX(100vw)${scale(0)}`, opacity: '0', offset: 1 },
],
{
@ -252,126 +242,134 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
}
};
/**
* Calculates animation progress (0-1) based on drag distance.
* Maps pixel drag distance to viewport width to match the animation's vw-based transforms.
* @param dragPixels - Absolute drag distance in pixels
* @returns Progress value between 0 and 1
*/
const calculateAnimationProgress = (dragPixels: number): number => {
// The animation moves from 0 to 100vw, so map pixel drag to viewport width
const viewportWidth = window.innerWidth;
const dragInViewportUnits = dragPixels / viewportWidth;
return Math.min(dragInViewportUnits, 1);
};
const pointerDown = (event: PointerEvent) => {
if (options?.disabled || !imgElement) {
return;
}
// Only handle single pointer (mouse or single touch)
if (event.isPrimary) {
isDragging = true;
startX = event.clientX;
// Change cursor to grabbing
node.style.cursor = 'grabbing';
// Capture pointer so we continue to receive events even if mouse moves outside element
node.setPointerCapture(event.pointerId);
dragStartTime = new Date();
// Also add document listeners as fallback
document.addEventListener('pointerup', pointerUp);
document.addEventListener('pointercancel', pointerUp);
// Ensure preview containers are created and positioned
ensurePreviewsCreated();
updatePreviewPositions();
// Note: We don't create animations here - they're lazy-created in pointerMove
// when we know which direction the user is swiping
event.preventDefault();
}
};
const pointerMove = (event: PointerEvent) => {
if (options?.disabled || !imgElement) {
// Only handle primary mouse button (0) and single touch (isPrimary)
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) {
return;
}
if (isDragging) {
currentOffsetX = event.clientX - startX;
swipeAmount = currentOffsetX;
isDragging = true;
startX = event.clientX;
swipeAmount = 0;
// Determine which direction we're swiping
const isSwipingLeft = currentOffsetX < 0;
const isSwipingRight = currentOffsetX > 0;
// Change cursor to grabbing
node.style.cursor = 'grabbing';
// Capture pointer so we continue to receive events even if mouse moves outside element
node.setPointerCapture(event.pointerId);
dragStartTime = new Date();
// Lazy create animations when first needed
if (isSwipingLeft && !leftAnimations) {
leftAnimations = createSwipeAnimations('left');
// Ensure the right preview container is visible
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'block';
rightPreviewContainer.style.zIndex = '1';
}
} else if (isSwipingRight && !rightAnimations) {
rightAnimations = createSwipeAnimations('right');
// Ensure the left preview container is visible
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'block';
leftPreviewContainer.style.zIndex = '1';
}
}
// Also add document listeners as fallback
document.addEventListener('pointerup', pointerUp);
document.addEventListener('pointercancel', pointerUp);
// Calculate progress based on absolute drag distance
// Using a threshold distance to map to full animation (0-1)
const progress = Math.min(Math.abs(currentOffsetX) / DRAG_DISTANCE_FOR_FULL_ANIMATION, 1);
// Ensure preview containers are created and positioned
ensurePreviewsCreated();
updatePreviewPositions();
// Map progress to animation time
const animationTime = progress * ANIMATION_DURATION_MS;
console.log(
`Animation progress: ${(progress * 100).toFixed(1)}% | Time: ${animationTime.toFixed(1)}ms | Offset: ${currentOffsetX.toFixed(1)}px`,
);
// Note: We don't create animations here - they're lazy-created in pointerMove
// when we know which direction the user is swiping
if (isSwipingLeft && leftAnimations) {
// Ensure the right preview container is visible
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'block';
rightPreviewContainer.style.zIndex = '1';
}
// Scrub left animations forward
leftAnimations.currentImageAnimation.currentTime = animationTime;
if (leftAnimations.previewAnimation) {
leftAnimations.previewAnimation.currentTime = animationTime;
}
// Cancel and recreate right animations to prevent conflicts on imgElement
if (rightAnimations) {
rightAnimations.currentImageAnimation.cancel();
if (rightAnimations.previewAnimation) {
rightAnimations.previewAnimation.cancel();
}
rightAnimations = null;
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'none';
}
}
} else if (isSwipingRight && rightAnimations) {
// Ensure the left preview container is visible
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'block';
leftPreviewContainer.style.zIndex = '1';
}
// Scrub right animations forward
rightAnimations.currentImageAnimation.currentTime = animationTime;
if (rightAnimations.previewAnimation) {
rightAnimations.previewAnimation.currentTime = animationTime;
}
// Cancel and recreate left animations to prevent conflicts on imgElement
if (leftAnimations) {
leftAnimations.currentImageAnimation.cancel();
if (leftAnimations.previewAnimation) {
leftAnimations.previewAnimation.cancel();
}
leftAnimations = null;
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'none';
}
}
}
// Notify about swipe movement
options?.onSwipeMove?.(currentOffsetX);
event.preventDefault();
event.preventDefault();
};
const pointerMove = (event: PointerEvent) => {
if (options?.disabled || !imgElement || !isDragging) {
return;
}
currentOffsetX = event.clientX - startX;
swipeAmount = currentOffsetX;
// Determine which direction we're swiping
const isSwipingLeft = currentOffsetX < 0;
const isSwipingRight = currentOffsetX > 0;
// Lazy create animations when first needed
if (isSwipingLeft && !leftAnimations) {
leftAnimations = createSwipeAnimations('left');
// Ensure the right preview container is visible
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'block';
rightPreviewContainer.style.zIndex = '1';
}
} else if (isSwipingRight && !rightAnimations) {
rightAnimations = createSwipeAnimations('right');
// Ensure the left preview container is visible
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'block';
leftPreviewContainer.style.zIndex = '1';
}
}
// Calculate animation progress based on drag distance
const progress = calculateAnimationProgress(Math.abs(currentOffsetX));
const animationTime = progress * ANIMATION_DURATION_MS;
if (isSwipingLeft && leftAnimations) {
// Ensure the right preview container is visible
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'block';
rightPreviewContainer.style.zIndex = '1';
}
// Scrub left animations forward
leftAnimations.currentImageAnimation.currentTime = animationTime;
if (leftAnimations.previewAnimation) {
leftAnimations.previewAnimation.currentTime = animationTime;
}
// Cancel and recreate right animations to prevent conflicts on imgElement
if (rightAnimations) {
rightAnimations.currentImageAnimation.cancel();
if (rightAnimations.previewAnimation) {
rightAnimations.previewAnimation.cancel();
}
rightAnimations = null;
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'none';
}
}
} else if (isSwipingRight && rightAnimations) {
// Ensure the left preview container is visible
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'block';
leftPreviewContainer.style.zIndex = '1';
}
// Scrub right animations forward
rightAnimations.currentImageAnimation.currentTime = animationTime;
if (rightAnimations.previewAnimation) {
rightAnimations.previewAnimation.currentTime = animationTime;
}
// Cancel and recreate left animations to prevent conflicts on imgElement
if (leftAnimations) {
leftAnimations.currentImageAnimation.cancel();
if (leftAnimations.previewAnimation) {
leftAnimations.previewAnimation.cancel();
}
leftAnimations = null;
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'none';
}
}
}
// Notify about swipe movement
options?.onSwipeMove?.(currentOffsetX);
event.preventDefault();
};
const resetPosition = () => {
@ -383,33 +381,36 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
const activeAnimations = currentOffsetX < 0 ? leftAnimations : rightAnimations;
const activePreviewContainer = currentOffsetX < 0 ? rightPreviewContainer : leftPreviewContainer;
if (activeAnimations) {
// Reverse the animation back to 0
activeAnimations.currentImageAnimation.playbackRate = -1;
if (activeAnimations.previewAnimation) {
activeAnimations.previewAnimation.playbackRate = -1;
}
// Play from current position back to start
activeAnimations.currentImageAnimation.play();
activeAnimations.previewAnimation?.play();
// Listen for finish event to clean up
const handleFinish = () => {
activeAnimations.currentImageAnimation.removeEventListener('finish', handleFinish);
// Reset to original state
activeAnimations.currentImageAnimation.cancel();
activeAnimations.previewAnimation?.cancel();
// Hide the preview container after animation completes
if (activePreviewContainer) {
activePreviewContainer.style.display = 'none';
activePreviewContainer.style.zIndex = '-1';
}
};
activeAnimations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
if (!activeAnimations) {
currentOffsetX = 0;
return;
}
// Reverse the animation back to 0
activeAnimations.currentImageAnimation.playbackRate = -1;
if (activeAnimations.previewAnimation) {
activeAnimations.previewAnimation.playbackRate = -1;
}
// Play from current position back to start
activeAnimations.currentImageAnimation.play();
activeAnimations.previewAnimation?.play();
// Listen for finish event to clean up
const handleFinish = () => {
activeAnimations.currentImageAnimation.removeEventListener('finish', handleFinish);
// Reset to original state
activeAnimations.currentImageAnimation.cancel();
activeAnimations.previewAnimation?.cancel();
// Hide the preview container after animation completes
if (activePreviewContainer) {
activePreviewContainer.style.display = 'none';
activePreviewContainer.style.zIndex = '-1';
}
};
activeAnimations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
currentOffsetX = 0;
};
@ -430,110 +431,116 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
// Get the active animations
const activeAnimations = direction === 'left' ? leftAnimations : rightAnimations;
if (activeAnimations) {
// Get current time before modifying animation
const currentTime = Number(activeAnimations.currentImageAnimation.currentTime) || 0;
console.log(`Committing transition from ${currentTime}ms / ${ANIMATION_DURATION_MS}ms`);
// If animation is already at or near the end, skip to finish immediately
if (currentTime >= ANIMATION_DURATION_MS - 5) {
console.log('Animation already complete, finishing immediately');
// Keep the preview visible by hiding the main image but showing the preview
imgElement.style.opacity = '0';
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
}
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
return;
}
// Ensure playback rate is forward (in case it was reversed)
activeAnimations.currentImageAnimation.playbackRate = 1;
if (activeAnimations.previewAnimation) {
activeAnimations.previewAnimation.playbackRate = 1;
}
// Play the animation to completion from current position
activeAnimations.currentImageAnimation.play();
activeAnimations.previewAnimation?.play();
// Listen for animation finish
const handleFinish = () => {
if (!imgElement) {
return;
}
activeAnimations.currentImageAnimation.removeEventListener('finish', handleFinish);
// Keep the preview visible by hiding the main image but showing the preview
// The preview is now centered, and we want it to stay visible while the new component loads
imgElement.style.opacity = '0';
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
}
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
};
activeAnimations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
if (!activeAnimations) {
return;
}
};
const pointerUp = (event: PointerEvent) => {
if (isDragging) {
// Get current time before modifying animation
const currentTime = Number(activeAnimations.currentImageAnimation.currentTime) || 0;
console.log(`Committing transition from ${currentTime}ms / ${ANIMATION_DURATION_MS}ms`);
// If animation is already at or near the end, skip to finish immediately
if (currentTime >= ANIMATION_DURATION_MS - 5) {
console.log('Animation already complete, finishing immediately');
// Keep the preview visible by hiding the main image but showing the preview
imgElement.style.opacity = '0';
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
}
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
return;
}
// Ensure playback rate is forward (in case it was reversed)
activeAnimations.currentImageAnimation.playbackRate = 1;
if (activeAnimations.previewAnimation) {
activeAnimations.previewAnimation.playbackRate = 1;
}
// Play the animation to completion from current position
activeAnimations.currentImageAnimation.play();
activeAnimations.previewAnimation?.play();
// Listen for animation finish
const handleFinish = () => {
if (!imgElement) {
return;
}
isDragging = false;
// Reset cursor
node.style.cursor = 'grab';
// Release pointer capture
if (node.hasPointerCapture(event.pointerId)) {
node.releasePointerCapture(event.pointerId);
}
// Remove document listeners
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
const threshold = options?.swipeThreshold ?? 45;
activeAnimations.currentImageAnimation.removeEventListener('finish', handleFinish);
const timeTaken = Date.now() - (dragStartTime?.getTime() ?? 0);
const velocity = Math.abs(swipeAmount) / timeTaken;
// Keep the preview visible by hiding the main image but showing the preview
// The preview is now centered, and we want it to stay visible while the new component loads
imgElement.style.opacity = '0';
// Calculate animation progress (same calculation as in pointerMove)
const progress = Math.min(Math.abs(currentOffsetX) / DRAG_DISTANCE_FOR_FULL_ANIMATION, 1);
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
// Commit if EITHER:
// 1. High velocity (fast swipe) OR
// 2. Animation progress is over 25%
const hasEnoughVelocity = velocity >= 0.11;
const hasEnoughProgress = progress > 0.25;
if (Math.abs(swipeAmount) < threshold || (!hasEnoughVelocity && !hasEnoughProgress)) {
resetPosition();
return;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
}
const commitDirection = currentOffsetX > 0 ? 'right' : 'left';
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
};
// Call onSwipeEnd callback
options?.onSwipeEnd?.(currentOffsetX);
activeAnimations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
};
// complete the transition animation
completeTransition(commitDirection);
const pointerUp = (event: PointerEvent) => {
console.log('up', event);
if (!isDragging || !event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) {
return;
}
if (!imgElement) {
return;
}
isDragging = false;
// Reset cursor
node.style.cursor = 'grab';
// Release pointer capture
if (node.hasPointerCapture(event.pointerId)) {
node.releasePointerCapture(event.pointerId);
}
// Remove document listeners
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
const threshold = options?.swipeThreshold ?? 45;
const timeTaken = Date.now() - (dragStartTime?.getTime() ?? 0);
const velocity = Math.abs(swipeAmount) / timeTaken;
// Calculate animation progress (same calculation as in pointerMove)
const progress = calculateAnimationProgress(Math.abs(currentOffsetX));
// Commit if EITHER:
// 1. High velocity (fast swipe) OR
// 2. Animation progress is over 25%
const hasEnoughVelocity = velocity >= 0.11;
const hasEnoughProgress = progress > 0.25;
if (Math.abs(swipeAmount) < threshold || (!hasEnoughVelocity && !hasEnoughProgress)) {
resetPosition();
return;
}
const commitDirection = currentOffsetX > 0 ? 'right' : 'left';
// Call onSwipeEnd callback
options?.onSwipeEnd?.(currentOffsetX);
// complete the transition animation
completeTransition(commitDirection);
};
// Add event listeners

View File

@ -40,7 +40,7 @@
type StackResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly, slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
@ -167,6 +167,7 @@
if (viewTransitionManager.activeViewTransition) {
transitionName = 'hero';
console.log('setting name initial');
equirectangularTransitionName = 'hero';
}
let addInfoTransition;
@ -180,6 +181,7 @@
finished = () => {
detailPanelTransitionName = null;
transitionName = null;
console.log('setting null');
};
eventManager.on('Finished', finished);
// eventManager.emit('AssetViewerLoaded');
@ -264,19 +266,23 @@
});
};
const startTransition = (targetTransition: string | null, targetAsset?: AssetResponseDto) => {
transitionName = targetTransition;
equirectangularTransitionName = targetTransition;
detailPanelTransitionName = 'onTop';
const startTransition = async (targetTransition: string | null, targetAsset?: AssetResponseDto) => {
transitionName = viewTransitionManager.getTransitionName('old', targetTransition);
console.log('transitionName', transitionName);
equirectangularTransitionName = viewTransitionManager.getTransitionName('old', targetTransition);
detailPanelTransitionName = 'detail-panel';
await tick();
debugger;
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('StartViewTransition', () => {
transitionName = viewTransitionManager.getTransitionName('new', targetTransition);
console.log(transitionName);
if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) {
equirectangularTransitionName = null;
}
});
eventManager.once('AssetViewerFree', () => resolve());
eventManager.once('AssetViewerFree', () => tick().then(resolve()));
}),
);
};
@ -297,11 +303,18 @@
}
void tracker.invoke(async () => {
let hasNext = false;
let skipped = false;
if (viewTransitionManager.skipTransitions()) {
await tick();
skipped = true;
console.log('was skipped');
}
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
console.log('$slideshowState', $slideshowState, skipTransition);
if (!skipTransition) {
startTransition(null, undefined);
await startTransition('slideshow', undefined);
}
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
@ -314,12 +327,19 @@
} else if (onNavigateToAsset) {
// only transition if the target is already preloaded, and is in a secure context
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
if (!skipTransition && !!targetAsset && globalThis.isSecureContext && preloadManager.isPreloaded(targetAsset)) {
const preloaded = await preloadManager.isPreloaded(targetAsset);
if (!skipTransition && !!targetAsset && globalThis.isSecureContext && preloaded) {
const targetTransition = $slideshowState === SlideshowState.PlaySlideshow ? null : order;
startTransition(targetTransition, targetAsset);
console.log('sta', $slideshowState);
await startTransition(targetTransition, targetAsset);
} else {
console.log('not');
}
resetZoomState();
console.log('about to');
hasNext = order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset);
console.log('done to');
} else {
hasNext = false;
}
@ -435,7 +455,6 @@
};
const handleAboutToNavigate = (target: { direction: 'left' | 'right'; nextWidth: number; nextHeight: number }) => {
debugger;
nextSizeHint = {
width: target.nextWidth,
height: target.nextHeight,

View File

@ -27,7 +27,7 @@
import { t } from 'svelte-i18n';
interface Props {
transitionName?: string | null;
transitionName?: string | null | undefined;
asset: AssetResponseDto;
previousAsset?: AssetResponseDto;
nextAsset?: AssetResponseDto;
@ -85,6 +85,7 @@
onDestroy(() => {
$boundingBoxesArray = [];
});
$inspect(transitionName).with(console.log.bind(null, 'transit'));
const box = $derived.by(() => {
const { width, height } = scaleToFit(naturalWidth, naturalHeight, containerWidth, containerHeight);
@ -107,15 +108,24 @@
const handlePreCommit = (direction: 'left' | 'right', nextWidth: number, nextHeight: number) => {
// Scale the preview dimensions to fit within the viewport (like object-fit: contain)
// This prevents flashing when small images are scaled up by scaleToFit
const { width: scaledWidth, height: scaledHeight } = scaleToFit(
nextWidth,
nextHeight,
containerWidth,
containerHeight,
);
console.log('nextSize', nextWidth, nextHeight, scaledWidth, scaledHeight);
let width = nextWidth;
let height = nextHeight;
if (direction === 'right' && nextAsset?.exifInfo?.exifImageWidth && nextAsset?.exifInfo?.exifImageHeight) {
width = nextAsset.exifInfo.exifImageWidth;
height = nextAsset.exifInfo.exifImageHeight;
} else if (
direction === 'left' &&
previousAsset?.exifInfo?.exifImageWidth &&
previousAsset?.exifInfo?.exifImageHeight
) {
width = previousAsset.exifInfo.exifImageWidth;
height = previousAsset.exifInfo.exifImageHeight;
}
const box = scaleToFit(width, height, containerWidth, containerHeight);
console.log('nextSize', nextWidth, nextHeight, box);
// onAboutToNavigate?.({ direction, nextWidth: scaledWidth, nextHeight: scaledHeight });
onAboutToNavigate?.({ direction, nextWidth, nextHeight });
onAboutToNavigate?.({ direction, nextWidth: box.width, nextHeight: box.height });
};
const handleSwipeCommit = (direction: 'left' | 'right') => {
@ -225,8 +235,6 @@
let naturalWidth = $derived(nextSizeHint?.width ?? 1);
let naturalHeight = $derived(nextSizeHint?.height ?? 1);
$inspect(naturalWidth).with(console.log.bind(null, 'natW'));
$inspect(naturalHeight).with(console.log.bind(null, 'natH'));
let lastUrl: string | undefined | null;
let lastPreviousUrl: string | undefined | null;
let lastNextUrl: string | undefined | null;

View File

@ -13,7 +13,7 @@
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { onDestroy, type Snippet } from 'svelte';
import { onDestroy, tick, type Snippet } from 'svelte';
type Props = {
toAssetViewerTransitionId?: string | null;
@ -67,8 +67,9 @@
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('TimelineLoaded', ({ id }) => {
eventManager.once('TimelineLoaded', async ({ id }) => {
animationTargetAssetId = id;
await tick();
resolve();
});
}),
@ -153,47 +154,47 @@
: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;
animation-name: fadeOut;
animation-fill-mode: 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;
animation-name: fadeIn;
animation-fill-mode: 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;
:global(::view-transition-old(detail-panel)),
:global(::view-transition-new(detail-panel)) {
z-index: 3;
animation: none;
}
:global(::view-transition-group(exclude)) {
animation: none;
z-index: 2;
}
:global(::view-transition-old(exclude)) {
visibility: hidden;
}
:global(::view-transition-new(exclude)) {
animation: none;
z-index: 2;
}
:global(::view-transition-old(hero)) {
animation: 350ms fadeOut forwards;
@ -203,98 +204,111 @@
animation: 350ms fadeIn forwards;
align-content: center;
}
:global(::view-transition-new(exclude)) {
animation: none;
}
:global(::view-transition-old(next)) {
:global(::view-transition-old(next)),
:global(::view-transition-old(next-old)) {
animation: 250ms flyOutLeft forwards;
transform-origin: center;
/* transform-origin: center; */
height: 100%;
/* display: flex; */
object-fit: contain;
/* margin: auto; */
/* transform: translateY(50%); */
/* width: auto;
left: 50dvw;
top: 50dvh;
transform: translate3d(-50%, -50%, 0); */
/* object-fit: contain;
height: 100vh;
width: 100vw;
z-index: 10; */
overflow: hidden;
}
:global(::view-transition-new(next)) {
animation: 250ms flyInRight forwards;
transform-origin: center;
:global(::view-transition-new(next)),
:global(::view-transition-new(next-new)) {
animation: 250ms fadeIn forwards;
height: 100%;
/* display: flex; */
object-fit: contain;
/* transform-origin: center;
width: auto;
left: 50dvw;
top: 50dvh;
transform: translate3d(-50%, -50%, 0); */
/* height: 100%;
object-fit: contain;
height: 100vh;
width: 100vw; */
overflow: hidden;
}
:global(::view-transition-old(previous)) {
animation: 1s flyOutRight forwards;
}
:global(::view-transition-old(previous-old)) {
animation: 250ms flyOutRight forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
/* transform-origin: center; */
/* height: 100%;
object-fit: contain;
height: 100vh;
width: 100vw;
z-index: 10; */
overflow: hidden;
}
:global(::view-transition-new(previous)) {
animation: 250ms flyInLeft forwards;
transform-origin: center;
animation: 1s flyOutRight forwards;
}
:global(::view-transition-new(previous-new)) {
animation: 250ms fadeIn forwards;
height: 100%;
object-fit: contain;
}
:global(::view-transition-new(navbar)) {
z-index: 100;
animation: none;
/* transform-origin: center; */
/* height: 100%;
object-fit: contain;
height: 100vh;
width: 100vw; */
overflow: hidden;
}
@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: 250ms fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
:global(::view-transition-new(previous)),
:global(::view-transition-new(next)) {
animation: 250ms fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
@keyframes -global-flyInLeft {
from {
transform: translateX(-100vw);
/* transform: translateX(-50dvw); */
object-position: -25dvw;
opacity: 0;
}
to {
transform: translateX(0);
/* transform: translateX(0); */
object-position: 0px 0px;
opacity: 1;
}
}
@keyframes -global-flyOutLeft {
from {
transform: translateX(0);
/* transform: translateX(0); */
object-position: 0px;
opacity: 1;
}
to {
transform: translateX(-100vw);
/* transform: translateX(-50dvw); */
object-position: -25dvw;
opacity: 0;
}
}
@keyframes -global-flyInRight {
from {
transform: translateX(100vw);
/* transform: translateX(50dvw); */
object-position: 25dvw;
opacity: 0;
}
to {
transform: translateX(0);
/* transform: translateX(0); */
object-position: 0px;
opacity: 1;
}
}
@ -302,11 +316,13 @@
/* Fly out to right */
@keyframes -global-flyOutRight {
from {
transform: translateX(0);
/* transform: translateX(0); */
opacity: 1;
}
to {
transform: translateX(100vw);
/* transform: translateX(50dvw); */
object-position: 50dvw 0px;
opacity: 0;
}
}
@ -327,4 +343,32 @@
opacity: 0;
}
}
@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: 250ms fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
:global(::view-transition-new(previous)),
:global(::view-transition-new(next)) {
animation: 250ms fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
</style>

View File

@ -243,6 +243,7 @@
// and a new route is being navigated to. It will never be called on direct
// navigations by the browser.
beforeNavigate(({ from, to }) => {
console.log('BEFORE NAV');
timelineManager.suspendTransitions = true;
const isNavigatingToAssetViewer = isAssetViewerRoute(to);
const isNavigatingFromAssetViewer = isAssetViewerRoute(from);
@ -256,6 +257,7 @@
// after successful navigation.
afterNavigate(({ complete }) => {
void complete.finally(async () => {
console.log('AFTER nav');
const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
@ -732,8 +734,9 @@
viewTransitionManager.startTransition(
new Promise((resolve) =>
eventManager.once('AssetViewerFree', () => {
eventManager.once('AssetViewerFree', async () => {
toAssetViewerTransitionId = null;
await tick();
eventManager.emit('TransitionToAssetViewer');
resolve();
}),

View File

@ -87,11 +87,12 @@
if (!targetAsset) {
return false;
}
let waitForAssetViewerFree = new Promise<void>((resolve) => {
eventManager.once('AssetViewerFree', () => resolve());
});
// let waitForAssetViewerFree = new Promise<void>((resolve) => {
// eventManager.once('AssetViewerFree', () => resolve());
// });
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
await waitForAssetViewerFree;
// await waitForAssetViewerFree;
return true;
};

View File

@ -22,7 +22,9 @@ class PreloadManager {
return false;
}
if (globalThis.isSecureContext) {
return isImageUrlCached(getAssetUrl({ asset }));
const img = getAssetUrl({ asset });
return isImageUrlCached(img);
}
return false;
}

View File

@ -2,12 +2,45 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
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 null;
};
get activeViewTransition() {
return this.#activeViewTransition;
}
skipTransitions() {
const skippedTransitions = !!this.#activeViewTransition;
if (skippedTransitions) {
console.log('skipped!');
}
this.#activeViewTransition?.skipTransition();
this.#notifyFinished();
return skippedTransitions;
}
startTransition(domUpdateComplete: Promise<void>, finishedCallback?: () => void) {
if (this.#activeViewTransition) {
console.error('Can not start transition - one already active');
return;
}
// good time to add view-transition-name styles (if needed)
eventManager.emit('BeforeStartViewTransition');
// next call will create the 'old' view snapshot
@ -23,28 +56,47 @@ class ViewTransitionManager {
}
});
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(() => eventManager.emit('UpdateCallbackDone'))
.catch((error: unknown) => console.log('exception', error));
.then(() => {
console.log('update done');
eventManager.emit('UpdateCallbackDone');
})
.catch((error: unknown) => console.log('exception in update', 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));
.catch((error: unknown) => {
this.#notifyFinished();
console.log('exception in ready', error);
});
// Transition is complete
// eslint-disable-next-line tscompat/tscompat
transition.finished
.then(() => eventManager.emit('Finished'))
.then(() => {
eventManager.emit('Finished');
console.log('finished');
})
.catch((error: unknown) => console.log('exception in finished', error));
// eslint-disable-next-line tscompat/tscompat
void transition.finished.then(() => {
finishedCallback?.();
this.#activeViewTransition = null;
});
void transition.finished.then(() => this.#notifyFinished());
}
#notifyFinished() {
console.log('finishedCallbacks len', this.#finishedCallbacks.length);
for (const callback of this.#finishedCallbacks) {
callback();
}
this.#finishedCallbacks = [];
}
}

View File

@ -75,6 +75,8 @@ export const handleCancel = (url: URL) => {
export const handleIsUrlCached = async (url: URL) => {
const cacheKey = getCacheKey(url);
const isImageUrlCached = !!(await get(cacheKey));
console.log('cacheKey', cacheKey, isImageUrlCached);
replyIsImageUrlCached(url.pathname + url.search + url.hash, isImageUrlCached);
};