Compare commits

...

12 Commits

42 changed files with 1972 additions and 570 deletions

View File

@@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken on').locator('visible=true');
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;

View File

@@ -148,6 +148,7 @@ export class MediaRepository {
quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: true,
})
.toFile(output);
}

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),
@@ -171,3 +174,258 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: black;
animation-duration: 250ms;
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: 250ms 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: 250ms 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(root) {
animation: 1s 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: 1s 0s fadeIn forwards;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: 350ms 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: 350ms 0s fadeIn forwards;
}
}
::view-transition-old(info) {
animation: 250ms 0s flyOutRight forwards;
}
::view-transition-new(info) {
animation: 250ms 0s flyInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
z-index: 4;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom) {
background-color: black;
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right) {
height: 100dvh;
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
background-color: black;
opacity: 1 !important;
}
::view-transition-group(exclude-leftbutton),
::view-transition-group(exclude-rightbutton),
::view-transition-group(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(exclude-leftbutton),
::view-transition-old(exclude-rightbutton),
::view-transition-old(exclude) {
visibility: hidden;
}
::view-transition-new(exclude-leftbutton),
::view-transition-new(exclude-rightbutton),
::view-transition-new(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(hero) {
animation: 350ms fadeOut forwards;
align-content: center;
}
::view-transition-new(hero) {
animation: 350ms fadeIn forwards;
align-content: center;
}
::view-transition-old(next),
::view-transition-old(next-old) {
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutLeft forwards;
overflow: hidden;
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInRight forwards;
overflow: hidden;
}
::view-transition-old(previous) {
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutRight forwards;
}
::view-transition-old(previous-old) {
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutRight forwards;
overflow: hidden;
z-index: -1;
}
::view-transition-new(previous) {
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInLeft forwards;
}
::view-transition-new(previous-new) {
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInLeft forwards;
overflow: hidden;
}
@keyframes flyInLeft {
from {
/* object-position: -25dvw; */
transform: translateX(-15%);
opacity: 0.1;
filter: blur(4px);
}
50% {
opacity: 0.4;
filter: blur(2px);
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: 0.4;
filter: blur(2px);
}
to {
/* object-position: -25dvw; */
transform: translateX(-15%);
opacity: 0.1;
filter: blur(4px);
}
}
@keyframes flyInRight {
from {
/* object-position: 25dvw; */
transform: translateX(15%);
opacity: 0.1;
filter: blur(4px);
}
50% {
opacity: 0.4;
filter: blur(2px);
}
to {
opacity: 1;
filter: blur(0);
}
}
/* Fly out to right */
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: 0.4;
filter: blur(2px);
}
to {
/* object-position: 50dvw 0px; */
transform: translateX(15%);
opacity: 0.1;
filter: blur(4px);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion) {
::view-transition-group(previous),
::view-transition-group(next) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
::view-transition-old(previous),
::view-transition-old(next) {
animation: 250ms fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(next) {
animation: 250ms fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
}

View File

@@ -0,0 +1,473 @@
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
export interface SwipeFeedbackOptions {
disabled?: boolean;
onSwipeEnd?: (offsetX: number) => void;
onSwipeMove?: (offsetX: number) => void;
/** Preview shown on left when swiping right */
leftPreviewUrl?: string | null;
/** Preview shown on right when swiping left */
rightPreviewUrl?: string | null;
/** Called after animation completes when threshold exceeded */
onSwipeCommit?: (direction: 'left' | 'right') => void;
/** Minimum pixels to activate swipe (default: 45) */
swipeThreshold?: number;
/** When changed, preview containers are reset */
currentAssetUrl?: string | null;
/** Element to apply swipe transforms to */
target?: HTMLElement | null;
}
interface SwipeAnimations {
currentImageAnimation: Animation;
previewAnimation: Animation | null;
}
/**
* Horizontal swipe gesture with visual feedback and optional preview images.
* Requires swipeSubject to be provided in options.
*/
export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions) => {
const ANIMATION_DURATION_MS = 300;
const ENABLE_SCALE_ANIMATION = false;
let target = options?.target;
let isDragging = false;
let startX = 0;
let currentOffsetX = 0;
let lastAssetUrl = options?.currentAssetUrl;
let dragStartTime: Date | null = null;
let swipeAmount = 0;
let leftAnimations: SwipeAnimations | null = null;
let rightAnimations: SwipeAnimations | null = null;
node.style.cursor = 'grab';
const getContainersForDirection = (direction: 'left' | 'right') => ({
animations: direction === 'left' ? leftAnimations : rightAnimations,
previewContainer: direction === 'left' ? rightPreviewContainer : leftPreviewContainer,
oppositeAnimations: direction === 'left' ? rightAnimations : leftAnimations,
oppositeContainer: direction === 'left' ? leftPreviewContainer : rightPreviewContainer,
});
const isValidPointerEvent = (event: PointerEvent) =>
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
const cancelAnimations = (animations: SwipeAnimations | null) => {
animations?.currentImageAnimation?.cancel();
animations?.previewAnimation?.cancel();
};
const resetContainerStyle = (container: HTMLElement | null) => {
if (!container) {
return;
}
container.style.transform = '';
container.style.transition = '';
container.style.zIndex = '-1';
container.style.display = 'none';
};
const resetPreviewContainers = () => {
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
resetContainerStyle(leftPreviewContainer);
resetContainerStyle(rightPreviewContainer);
if (target) {
target.style.transform = '';
target.style.transition = '';
target.style.opacity = '';
}
currentOffsetX = 0;
};
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
const sign = direction === 'left' ? -1 : 1;
if (isPreview) {
return [
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
];
}
return [
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
];
};
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
if (!target) {
return null;
}
target.style.transformOrigin = 'center';
const currentImageAnimation = target.animate(createAnimationKeyframes(direction, false), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
const { previewContainer } = getContainersForDirection(direction);
let previewAnimation: Animation | null = null;
if (previewContainer) {
previewContainer.style.transformOrigin = 'center';
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
}
currentImageAnimation.pause();
previewAnimation?.pause();
return { currentImageAnimation, previewAnimation };
};
let leftPreviewContainer: HTMLDivElement | null = null;
let rightPreviewContainer: HTMLDivElement | null = null;
let leftPreviewImg: HTMLImageElement | null = null;
let rightPreviewImg: HTMLImageElement | null = null;
const createPreviewContainer = (): { container: HTMLDivElement; img: HTMLImageElement } => {
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.pointerEvents = 'none';
container.style.display = 'none';
container.style.zIndex = '-1';
const img = document.createElement('img');
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'contain';
img.draggable = false;
img.alt = '';
container.append(img);
node.parentElement?.append(container);
return { container, img };
};
const ensurePreviewCreated = (
url: string | null | undefined,
container: HTMLDivElement | null,
img: HTMLImageElement | null,
) => {
if (!url || container) {
return { container, img };
}
const preview = createPreviewContainer();
preview.img.src = url;
return preview;
};
const ensurePreviewsCreated = () => {
if (options?.leftPreviewUrl && !leftPreviewContainer) {
const preview = ensurePreviewCreated(options.leftPreviewUrl, leftPreviewContainer, leftPreviewImg);
leftPreviewContainer = preview.container;
leftPreviewImg = preview.img;
}
if (options?.rightPreviewUrl && !rightPreviewContainer) {
const preview = ensurePreviewCreated(options.rightPreviewUrl, rightPreviewContainer, rightPreviewImg);
rightPreviewContainer = preview.container;
rightPreviewImg = preview.img;
}
};
const positionContainer = (container: HTMLElement | null, width: number, height: number) => {
if (!container) {
return;
}
Object.assign(container.style, {
width: `${width}px`,
height: `${height}px`,
left: '0px',
top: '0px',
});
};
const updatePreviewPositions = () => {
const parentElement = node.parentElement;
if (!parentElement) {
return;
}
const { width, height } = globalThis.getComputedStyle(parentElement);
const viewportWidth = Number.parseFloat(width);
const viewportHeight = Number.parseFloat(height);
positionContainer(leftPreviewContainer, viewportWidth, viewportHeight);
positionContainer(rightPreviewContainer, viewportWidth, viewportHeight);
};
const calculateAnimationProgress = (dragPixels: number) => Math.min(dragPixels / window.innerWidth, 1);
const pointerDown = (event: PointerEvent) => {
if (options?.disabled || !target || !isValidPointerEvent(event)) {
return;
}
isDragging = true;
startX = event.clientX;
swipeAmount = 0;
node.style.cursor = 'grabbing';
node.setPointerCapture(event.pointerId);
dragStartTime = new Date();
document.addEventListener('pointerup', pointerUp);
document.addEventListener('pointercancel', pointerUp);
ensurePreviewsCreated();
updatePreviewPositions();
event.preventDefault();
};
const setContainerVisibility = (container: HTMLElement | null, visible: boolean) => {
if (!container) {
return;
}
container.style.display = visible ? 'block' : 'none';
if (visible) {
container.style.zIndex = '1';
}
};
const updateAnimationTime = (animations: SwipeAnimations, time: number) => {
animations.currentImageAnimation.currentTime = time;
if (animations.previewAnimation) {
animations.previewAnimation.currentTime = time;
}
};
const pointerMove = (event: PointerEvent) => {
if (options?.disabled || !target || !isDragging) {
return;
}
currentOffsetX = event.clientX - startX;
swipeAmount = currentOffsetX;
const direction = currentOffsetX < 0 ? 'left' : 'right';
const animationTime = calculateAnimationProgress(Math.abs(currentOffsetX)) * ANIMATION_DURATION_MS;
const { animations, previewContainer, oppositeAnimations, oppositeContainer } =
getContainersForDirection(direction);
if (!animations) {
if (direction === 'left') {
leftAnimations = createSwipeAnimations('left');
} else {
rightAnimations = createSwipeAnimations('right');
}
setContainerVisibility(previewContainer, true);
}
const currentAnimations = direction === 'left' ? leftAnimations : rightAnimations;
if (currentAnimations) {
setContainerVisibility(previewContainer, true);
updateAnimationTime(currentAnimations, animationTime);
if (oppositeAnimations) {
cancelAnimations(oppositeAnimations);
if (direction === 'left') {
rightAnimations = null;
} else {
leftAnimations = null;
}
setContainerVisibility(oppositeContainer, false);
}
}
options?.onSwipeMove?.(currentOffsetX);
event.preventDefault();
};
const setPlaybackRate = (animations: SwipeAnimations, rate: number) => {
animations.currentImageAnimation.playbackRate = rate;
if (animations.previewAnimation) {
animations.previewAnimation.playbackRate = rate;
}
};
const playAnimations = (animations: SwipeAnimations) => {
animations.currentImageAnimation.play();
animations.previewAnimation?.play();
};
const resetPosition = () => {
if (!target) {
return;
}
const direction = currentOffsetX < 0 ? 'left' : 'right';
const { animations, previewContainer } = getContainersForDirection(direction);
if (!animations) {
currentOffsetX = 0;
return;
}
setPlaybackRate(animations, -1);
playAnimations(animations);
const handleFinish = () => {
animations.currentImageAnimation.removeEventListener('finish', handleFinish);
cancelAnimations(animations);
resetContainerStyle(previewContainer);
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
currentOffsetX = 0;
};
const commitSwipe = (direction: 'left' | 'right') => {
if (!target) {
return;
}
target.style.opacity = '0';
const { previewContainer } = getContainersForDirection(direction);
if (previewContainer) {
previewContainer.style.zIndex = '1';
}
options?.onSwipeCommit?.(direction);
};
const completeTransition = (direction: 'left' | 'right') => {
if (!target) {
return;
}
const { animations } = getContainersForDirection(direction);
if (!animations) {
return;
}
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
if (currentTime >= ANIMATION_DURATION_MS - 5) {
commitSwipe(direction);
return;
}
setPlaybackRate(animations, 1);
playAnimations(animations);
const handleFinish = () => {
if (target) {
commitSwipe(direction);
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
};
const pointerUp = (event: PointerEvent) => {
if (!isDragging || !isValidPointerEvent(event) || !target) {
return;
}
isDragging = false;
node.style.cursor = 'grab';
if (node.hasPointerCapture(event.pointerId)) {
node.releasePointerCapture(event.pointerId);
}
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
const threshold = options?.swipeThreshold ?? 45;
const velocity = Math.abs(swipeAmount) / (Date.now() - (dragStartTime?.getTime() ?? 0));
const progress = calculateAnimationProgress(Math.abs(currentOffsetX));
if (Math.abs(swipeAmount) < threshold || (velocity < 0.11 && progress <= 0.25)) {
resetPosition();
return;
}
options?.onSwipeEnd?.(currentOffsetX);
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
};
node.addEventListener('pointerdown', pointerDown);
node.addEventListener('pointermove', pointerMove);
node.addEventListener('pointerup', pointerUp);
node.addEventListener('pointercancel', pointerUp);
return {
update(newOptions?: SwipeFeedbackOptions) {
if (newOptions?.target && newOptions.target !== target) {
resetPreviewContainers();
target = newOptions.target;
}
if (newOptions?.currentAssetUrl && newOptions.currentAssetUrl !== lastAssetUrl) {
resetPreviewContainers();
lastAssetUrl = newOptions.currentAssetUrl;
}
const lastLeftPreviewUrl = options?.leftPreviewUrl;
const lastRightPreviewUrl = options?.rightPreviewUrl;
if (
lastLeftPreviewUrl &&
lastLeftPreviewUrl != newOptions?.leftPreviewUrl &&
lastLeftPreviewUrl !== newOptions?.currentAssetUrl
) {
preloadManager.cancelPreloadUrl(lastLeftPreviewUrl);
}
if (
lastRightPreviewUrl &&
lastRightPreviewUrl != newOptions?.rightPreviewUrl &&
lastRightPreviewUrl !== newOptions?.currentAssetUrl
) {
preloadManager.cancelPreloadUrl(lastRightPreviewUrl);
}
options = newOptions;
if (options?.leftPreviewUrl) {
if (leftPreviewImg) {
leftPreviewImg.src = options.leftPreviewUrl;
} else {
const preview = ensurePreviewCreated(options.leftPreviewUrl, leftPreviewContainer, leftPreviewImg);
leftPreviewContainer = preview.container;
leftPreviewImg = preview.img;
}
}
if (options?.rightPreviewUrl) {
if (rightPreviewImg) {
rightPreviewImg.src = options.rightPreviewUrl;
} else {
const preview = ensurePreviewCreated(options.rightPreviewUrl, rightPreviewContainer, rightPreviewImg);
rightPreviewContainer = preview.container;
rightPreviewImg = preview.img;
}
}
},
destroy() {
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
node.removeEventListener('pointerdown', pointerDown);
node.removeEventListener('pointermove', pointerMove);
node.removeEventListener('pointerup', pointerUp);
node.removeEventListener('pointercancel', pointerUp);
document.removeEventListener('pointerup', pointerUp);
document.removeEventListener('pointercancel', pointerUp);
leftPreviewContainer?.remove();
rightPreviewContainer?.remove();
node.style.cursor = '';
},
};
};

View File

@@ -7,13 +7,23 @@ import { thumbHashToRGBA } from 'thumbhash';
* @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString)
*/
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
const ctx = canvas.getContext('2d');
if (ctx) {
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
const pixels = ctx.createImageData(w, h);
canvas.width = w;
canvas.height = h;
pixels.data.set(rgba);
ctx.putImageData(pixels, 0, 0);
}
const render = (hash: string) => {
const ctx = canvas.getContext('2d');
if (ctx) {
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(hash));
const pixels = ctx.createImageData(w, h);
canvas.width = w;
canvas.height = h;
pixels.data.set(rgba);
ctx.putImageData(pixels, 0, 0);
}
};
render(base64ThumbHash);
return {
update({ base64ThumbHash: newHash }: { base64ThumbHash: string }) {
render(newHash);
},
};
}

View File

@@ -3,46 +3,63 @@ import { useZoomImageWheel } from '@zoom-image/svelte';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
maxZoom: 10,
});
const state = get(photoZoomState);
if (state) {
setZoomImageState(state);
}
// Store original event handlers so we can prevent them when disabled
const wheelHandler = (event: WheelEvent) => {
let unsubscribes: (() => void)[] = [];
const createZoomAction = (newOptions?: { disabled?: boolean }) => {
options = newOptions;
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
const pointerDownHandler = (event: PointerEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
// Add handlers at capture phase with higher priority
node.addEventListener('wheel', wheelHandler, { capture: true });
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
return {
update(newOptions?: { disabled?: boolean }) {
options = newOptions;
},
destroy() {
node.removeEventListener('wheel', wheelHandler, { capture: true });
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribes = [];
} else {
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
createZoomImage(node, {
maxZoom: 10,
});
const state = get(photoZoomState);
if (state) {
setZoomImageState(state);
}
node.style.overflow = 'visible';
// Store original event handlers so we can prevent them when disabled
const wheelHandler = (event: WheelEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
const pointerDownHandler = (event: PointerEvent) => {
if (options?.disabled) {
event.stopImmediatePropagation();
}
};
// Add handlers at capture phase with higher priority
node.addEventListener('wheel', wheelHandler, { capture: true });
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
unsubscribes = [
photoZoomState.subscribe(setZoomImageState),
zoomImageState.subscribe(photoZoomState.set),
() => node.removeEventListener('wheel', wheelHandler, { capture: true }),
() => node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }),
];
}
};
createZoomAction();
return {
update(newOptions?: { disabled?: boolean }) {
createZoomAction(newOptions);
},
destroy() {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribes = [];
},
};
};

View File

@@ -10,13 +10,16 @@
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { resetZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
@@ -27,7 +30,6 @@
import {
AssetJobName,
AssetTypeEnum,
getAllAlbums,
getAssetInfo,
getStack,
runAssetJobs,
@@ -37,9 +39,9 @@
type StackResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import { fly, slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
@@ -52,8 +54,6 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
type HasAsset = boolean;
export type AssetCursor = {
current: AssetResponseDto;
nextAsset?: AssetResponseDto;
@@ -71,9 +71,8 @@
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
onNavigateToAsset?: (asset: AssetResponseDto | undefined | null) => Promise<boolean>;
onRandom?: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
@@ -88,25 +87,24 @@
onAction,
onUndoDelete,
onClose,
onNext,
onPrevious,
onNavigateToAsset,
onRandom,
copyImage = $bindable(),
}: Props = $props();
const { setAssetId } = assetViewingStore;
const { setAssetId, invisible } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let asset = $derived(cursor.current);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let nextAsset = $derived(cursor.nextAsset);
let previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowEditor = $state(false);
@@ -115,9 +113,15 @@
let selectedEditType: string = $state('');
let stack: StackResponseDto | null = $state(null);
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
let zoomToggle = $state(() => void 0);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let refreshAlbumsSignal = $state(0);
const setPlayOriginalVideo = (value: boolean) => {
playOriginalVideo = value;
};
@@ -152,7 +156,26 @@
}
};
onMount(async () => {
let transitionName = $state<string | null>('hero');
let equirectangularTransitionName = $state<string | null>('hero');
let detailPanelTransitionName = $state<string | null>(null);
let addInfoTransition;
let finished;
onMount(() => {
addInfoTransition = () => {
detailPanelTransitionName = 'info';
transitionName = 'hero';
equirectangularTransitionName = 'hero';
};
eventManager.on('TransitionToAssetViewer', addInfoTransition);
eventManager.on('TransitionToTimeline', addInfoTransition);
finished = () => {
detailPanelTransitionName = null;
transitionName = null;
};
eventManager.on('Finished', finished);
unsubscribes.push(
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
@@ -170,10 +193,6 @@
}
}),
);
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
@@ -182,21 +201,13 @@
}
activityManager.reset();
eventManager.off('TransitionToAssetViewer', addInfoTransition!);
eventManager.off('TransitionToTimeline', addInfoTransition!);
eventManager.off('Finished', finished!);
});
const handleGetAllAlbums = async () => {
if (authManager.isSharedLink) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const closeViewer = () => {
transitionName = 'hero';
onClose?.(asset);
};
@@ -206,40 +217,98 @@
});
};
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 = null;
}
await tick();
navigationResolve(await navigateFn());
});
eventManager.once('AssetViewerFree', () => tick().then(resolve));
}),
types,
);
});
return navigationResult;
};
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
} else {
return;
}
}
e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
let skipped = false;
if (viewTransitionManager.skipTransitions()) {
skipped = true;
}
void tracker.invoke(async () => {
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
if (slideShowPlaying && slideShowShuffle) {
const navigate = async () => {
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!next) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
next = true;
}
}
return next;
};
// eslint-disable-next-line unicorn/prefer-ternary
if (viewTransitionManager.isSupported() && !skipped && !skipTransition) {
hasNext = await startTransition(['slideshow'], null, null, navigate);
} else {
hasNext = await navigate();
}
} else if (onNavigateToAsset) {
// only transition if the target is already preloaded, and is in a secure context
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
const navigate = async () =>
order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset);
if (viewTransitionManager.isSupported() && !skipped && !skipTransition && !!targetAsset) {
const targetTransition = slideShowPlaying ? null : order;
hasNext = await startTransition(
slideShowPlaying ? ['slideshow'] : ['viewer-nav'],
targetTransition,
targetAsset,
navigate,
);
} else {
hasNext = await navigate();
}
resetZoomState();
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
hasNext = false;
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (slideShowPlaying) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
@@ -300,13 +369,14 @@
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await handleGetAllAlbums();
refreshAlbumsSignal++;
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
@@ -365,7 +435,6 @@
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
@@ -374,7 +443,6 @@
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
@@ -392,26 +460,56 @@
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
const onAssetUpdate = (update: AssetResponseDto) => {
if (asset.id === update.id) {
asset = update;
$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.Image) {
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
} else if (isEquirectangular(asset)) {
return 'ImagePanaramaViewer';
} else if (isShowEditor && selectedEditType === 'crop') {
return 'CropArea';
}
return 'PhotoViewer';
}
return 'VideoViewer';
});
</script>
<OnEvents {onAssetReplace} {onAssetUpdate} />
<OnEvents {onAssetReplace} />
<svelte:document bind:fullscreenElement />
<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}
@@ -433,7 +531,7 @@
{/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex">
<div class="absolute w-full flex justify-center">
<SlideshowBar
{isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type}
@@ -445,122 +543,132 @@
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
{#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"
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">
{#if previewStackedAsset}
{#key previewStackedAsset.id}
{#if previewStackedAsset.type === AssetTypeEnum.Image}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
cacheKey={previewStackedAsset.thumbhash}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{/key}
{:else}
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
{#if assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{/if}
{:else}
<VideoViewer
assetId={asset.id}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
<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! }}
onPreviousAsset={() => navigateAsset('previous', true)}
onNextAsset={() => navigateAsset('next', true)}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
{transitionName}
{asset}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
nextAsset={cursor.nextAsset}
previousAsset={cursor.previousAsset}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{transitionName}
{asset}
assetId={asset.livePhotoVideoId!}
nextAsset={cursor.nextAsset}
previousAsset={cursor.previousAsset}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} transitionName={equirectangularTransitionName} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
{transitionName}
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous', true)}
onNextAsset={() => navigateAsset('next', true)}
{sharedLink}
onReady={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{transitionName}
{asset}
assetId={asset.id}
nextAsset={cursor.nextAsset}
previousAsset={cursor.previousAsset}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
disabled={!album?.isActivityEnabled}
isLiked={activityManager.isLiked}
numberOfComments={activityManager.commentCount}
numberOfLikes={activityManager.likeCount}
onFavorite={handleFavorite}
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
{/key}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
{#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"
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"
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} albums={appearsInAlbums} />
<DetailPanel {asset} {refreshAlbumsSignal} currentAlbum={album} />
</div>
{/if}

View File

@@ -14,13 +14,19 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import {
mdiCalendar,
@@ -44,11 +50,11 @@
interface Props {
asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null;
refreshAlbumsSignal?: number;
}
let { asset, albums = [], currentAlbum = null }: Props = $props();
let { asset, refreshAlbumsSignal = 0, currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -74,6 +80,17 @@
);
let previousId: string | undefined = $state();
let albums = $state<AlbumResponseDto[]>([]);
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
refreshAlbumsSignal;
if (authManager.isSharedLink) {
return;
}
handlePromiseError(getAllAlbums({ assetId: asset.id }).then((response) => (albums = response)));
});
$effect(() => {
if (!previousId) {
previousId = asset.id;

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 | null;
asset: AssetResponseDto;
zoomToggle?: (() => void) | null;
};
let { asset, zoomToggle = $bindable() }: Props = $props();
let { transitionName, asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@@ -20,11 +21,12 @@
};
</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

@@ -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 | null;
panorama: string | { source: string };
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
@@ -36,6 +38,7 @@
};
let {
transitionName,
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
@@ -110,6 +113,8 @@
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
};
const handleReady = () => eventManager.emit('AssetViewerFree');
let hasChangedResolution: boolean = false;
onMount(() => {
if (!container) {
@@ -154,6 +159,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
viewer.addEventListener('ready', handleReady);
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
@@ -178,6 +184,7 @@
onDestroy(() => {
if (viewer) {
viewer.removeEventListener('ready', handleReady);
viewer.destroy();
}
boundingBoxesUnsubscribe();
@@ -190,4 +197,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

@@ -1,76 +1,167 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { swipeFeedback } from '$lib/actions/swipe-feedback';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState, resetZoomState } from '$lib/stores/zoom-image.store';
import {
getAssetThumbnailUrl,
getAssetUrl,
targetImageSize as getTargetImageSize,
handlePromiseError,
} from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, getDimensions } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { scaleToFit } from '$lib/utils/layout-utils';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
transitionName?: string | null | undefined;
cursor: AssetCursor;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
onFree?: (() => void) | null;
onReady?: (() => void) | null;
copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
}
let {
cursor,
transitionName,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
sharedLink,
onPreviousAsset = null,
onNextAsset = null,
onReady,
onFree,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let swipeTarget = $state<HTMLElement | undefined>();
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let loader = $state<HTMLImageElement>();
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
$effect(() => {
if (loader) {
const _loader = loader;
const _src = loader.src;
const _imageLoaderUrl = imageLoaderUrl;
_loader.addEventListener('load', () => {
if (_loader.src === _src && imageLoaderUrl === _imageLoaderUrl) {
onload();
}
});
_loader.addEventListener('error', () => {
if (_loader.src === _src && imageLoaderUrl === _imageLoaderUrl) {
onerror();
}
});
}
});
resetZoomState();
onDestroy(() => {
$boundingBoxesArray = [];
});
const box = $derived.by(() => {
const { width, height } = scaledDimensions;
return {
width: width + 'px',
height: height + 'px',
left: (containerWidth - width) / 2 + 'px',
top: (containerHeight - height) / 2 + 'px',
};
});
const blurredSlideshow = $derived(
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && asset.thumbhash,
);
const transitionLetterboxLeft = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-left');
const transitionLetterboxRight = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-right');
const transitionLetterboxTop = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-top');
const transitionLetterboxBottom = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-bottom');
// Letterbox regions (the empty space around the main box)
const letterboxLeft = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (containerWidth - width) / 2;
return {
width: leftOffset + 'px',
height: containerHeight + 'px',
left: '0px',
top: '0px',
};
});
const letterboxRight = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (containerWidth - width) / 2;
const rightOffset = leftOffset;
return {
width: rightOffset + 'px',
height: containerHeight + 'px',
left: containerWidth - rightOffset + 'px',
top: '0px',
};
});
const letterboxTop = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (containerHeight - height) / 2;
const leftOffset = (containerWidth - width) / 2;
return {
width: width + 'px',
height: topOffset + 'px',
left: leftOffset + 'px',
top: '0px',
};
});
const letterboxBottom = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (containerHeight - height) / 2;
const bottomOffset = topOffset;
const leftOffset = (containerWidth - width) / 2;
return {
width: width + 'px',
height: bottomOffset + 'px',
left: leftOffset + 'px',
top: containerHeight - bottomOffset + 'px',
};
});
let ocrBoxes = $derived(
ocrManager.showOverlay && $photoViewerImgElement
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
@@ -114,21 +205,12 @@
event.preventDefault();
handlePromiseError(copyImage());
};
const onSwipe = (event: SwipeCustomEvent) => {
if ($photoZoomState.currentZoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
const handleSwipeCommit = (direction: 'left' | 'right') => {
if (direction === 'left' && onNextAsset) {
// Swiped left, go to next asset
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
} else if (direction === 'right' && onPreviousAsset) {
// Swiped right, go to previous asset
onPreviousAsset();
}
};
@@ -155,38 +237,96 @@
}
};
let lastFreedUrl: string | undefined | null;
const notifyFree = () => {
if (lastFreedUrl !== imageLoaderUrl) {
onFree?.();
lastFreedUrl = imageLoaderUrl;
}
};
const notifyReady = () => {
onReady?.();
};
const onload = () => {
imageLoaded = true;
notifyFree();
dimensions = {
width: loader?.naturalWidth ?? 1,
height: loader?.naturalHeight ?? 1,
};
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
notifyFree();
dimensions = {
width: loader?.naturalWidth ?? 1,
height: loader?.naturalHeight ?? 1,
};
imageError = true;
};
onMount(() => {
notifyReady();
return () => {
preloadManager.cancelPreloadUrl(imageLoaderUrl);
if (!imageLoaded && !imageError) {
notifyFree();
}
if (imageLoaderUrl) {
preloadManager.cancelPreloadUrl(imageLoaderUrl);
}
};
});
let imageLoaderUrl = $derived(
const imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
const previousAssetUrl = $derived(getAssetUrl({ asset: previousAsset, sharedLink }));
const nextAssetUrl = $derived(getAssetUrl({ asset: nextAsset, sharedLink }));
const thumbnailUrl = $derived(
getAssetThumbnailUrl({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
}),
);
let thumbnailPreloaded = $state(false);
const exifDimensions = $derived(
asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageHeight
? (getDimensions(asset.exifInfo) as { width: number; height: number })
: null,
);
let containerWidth = $state(0);
let containerHeight = $state(0);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
const scaledDimensions = $derived(scaleToFit(dimensions, container));
let lastUrl: string | undefined;
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
if (lastUrl !== imageLoaderUrl && imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
cancelImageUrl(lastUrl);
notifyReady();
});
}
lastUrl = imageLoaderUrl;
});
$effect(() => {
$photoViewerImgElement = loader;
});
</script>
<svelte:document
@@ -198,63 +338,125 @@
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
/>
{#if imageError}
<div id="broken-asset" class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
use:swipeFeedback={{
disabled: isOcrActive || $photoZoomState.currentZoom > 1,
onSwipeCommit: handleSwipeCommit,
leftPreviewUrl: previousAssetUrl,
rightPreviewUrl: nextAssetUrl,
currentAssetUrl: imageLoaderUrl,
target: swipeTarget,
}}
>
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
<div
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
{#if blurredSlideshow}
<canvas
id="test"
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
></canvas>
{/if}
<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>
<div
style:view-transition-name={transitionName}
data-transition-name={transitionName}
class="absolute"
style:left={box.left}
style:top={box.top}
style:width={box.width}
style:height={box.height}
bind:this={swipeTarget}
>
{#if asset.thumbhash}
<canvas data-blur use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"
></canvas>
{#if thumbnailPreloaded}
<img src={thumbnailUrl} alt={$getAltText(toTimelineAsset(asset))} class="h-full w-full absolute -z-1" />
{/if}
{/if}
{#if !imageLoaded && !asset.thumbhash && !imageError}
<div id="spinner" class="absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{/if}
{#if imageError}
<div class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
{#key imageLoaderUrl}
<div
use:zoomImageAction={{ disabled: isOcrActive }}
style:width={box.width}
style:height={box.height}
style:overflow="visible"
class="absolute"
>
<img
decoding="async"
bind:this={loader}
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
alt={$getAltText(toTimelineAsset(asset))}
class={[
'w-full',
'h-full',
$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook],
imageError && 'hidden',
]}
draggable="false"
/>
{/if}
<img
bind:this={$photoViewerImgElement}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{/key}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if}
</div>
</div>
<style>
@@ -263,9 +465,13 @@
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
[data-blur] {
visibility: hidden;
animation: 0s linear 0.1s forwards delayedVisibility;
}
</style>

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import { swipeFeedback } from '$lib/actions/swipe-feedback';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import {
autoPlayVideo,
@@ -10,15 +14,21 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl, getAssetUrl } from '$lib/utils';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
interface Props {
transitionName?: string | null;
asset: AssetResponseDto;
assetId: string;
previousAsset?: AssetResponseDto;
nextAsset?: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
@@ -30,7 +40,12 @@
}
let {
transitionName,
asset,
assetId,
previousAsset,
nextAsset,
sharedLink,
loopVideo,
cacheKey,
playOriginalVideo,
@@ -49,11 +64,31 @@
let isScrubbing = $state(false);
let showVideo = $state(false);
let containerWidth = $state(document.documentElement.clientWidth);
let containerHeight = $state(document.documentElement.clientHeight);
const exifDimensions = $derived(
asset?.exifInfo?.exifImageHeight && asset?.exifInfo.exifImageHeight
? (getDimensions(asset.exifInfo) as { width: number; height: number })
: null,
);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
const scaledDimensions = $derived(scaleToFit(dimensions, container));
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
});
$effect(
() =>
void assetCacheManager.getAsset({ ...authManager.params, id: assetId }).then((assetDto) => (asset = assetDto)),
);
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
@@ -67,6 +102,14 @@
}
});
const handleLoadedMetadata = () => {
dimensions = {
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
eventManager.emit('AssetViewerFree');
};
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
@@ -98,31 +141,50 @@
}
};
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
const handleSwipeCommit = (direction: 'left' | 'right') => {
if (direction === 'left' && onNextAsset) {
onNextAsset();
}
if (event.detail.direction === 'right') {
} else if (direction === 'right' && onPreviousAsset) {
onPreviousAsset();
}
};
let containerWidth = $state(0);
let containerHeight = $state(0);
$effect(() => {
if (isFaceEditMode.value) {
videoPlayer?.pause();
}
});
const calculateSize = () => {
const { width, height } = scaledDimensions;
const size = {
width: width + 'px',
height: height + 'px',
};
return size;
};
const box = $derived(calculateSize());
const previousAssetUrl = $derived(getAssetUrl({ asset: previousAsset, sharedLink }));
const nextAssetUrl = $derived(getAssetUrl({ asset: nextAsset, sharedLink }));
</script>
{#if showVideo}
<div
transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full select-none place-content-center place-items-center"
in:fade={{ duration: assetViewerFadeDuration }}
class="flex select-none h-full w-full place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
use:swipeFeedback={{
onSwipeCommit: handleSwipeCommit,
leftPreviewUrl: previousAssetUrl,
rightPreviewUrl: nextAssetUrl,
currentAssetUrl: assetFileUrl,
target: videoPlayer,
}}
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
@@ -134,40 +196,50 @@
/>
</div>
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
<div class="relative">
<video
style:view-transition-name={transitionName}
style:height={box.height}
style:width={box.width}
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
onloadedmetadata={() => handleLoadedMetadata()}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isLoading}
<div class="absolute inset-0 flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
</div>
{/if}
</div>
{/if}
<style>
video:focus {
outline: none;
}
</style>

View File

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

View File

@@ -2,9 +2,15 @@
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
interface Props {
transitionName?: string | null;
asset: AssetResponseDto;
assetId: string;
previousAsset?: AssetResponseDto;
nextAsset?: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
@@ -17,7 +23,12 @@
}
let {
transitionName,
asset,
assetId,
previousAsset,
nextAsset,
sharedLink,
projectionType,
cacheKey,
loopVideo,
@@ -31,12 +42,17 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} />
<VideoPanoramaViewer {assetId} {transitionName} />
{:else}
<VideoNativeViewer
{transitionName}
{loopVideo}
{cacheKey}
{asset}
{assetId}
{nextAsset}
{sharedLink}
{previousAsset}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}

View File

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

View File

@@ -9,8 +9,9 @@
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 { getContext, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { AppState } from '../../../routes/+layout.svelte';
interface Props {
hideNavbar?: boolean;
@@ -48,13 +49,19 @@
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
const appState = getContext('AppState') as AppState;
let isAssetViewer = $derived(appState.isAssetViewer);
</script>
<header>
{#if !hideNavbar}
{#if !hideNavbar && !isAssetViewer}
<NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
{/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
{@render header?.()}
</header>
<div
@@ -64,13 +71,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

@@ -26,7 +26,7 @@
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
@@ -651,8 +651,6 @@
bind:this={memoryGallery}
>
<GalleryViewer
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={currentTimelineAssets}
viewport={galleryViewport}
{assetInteraction}

View File

@@ -144,13 +144,7 @@
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={{ current: asset }}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
/>
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
{/await}
{/await}
{/if}

View File

@@ -35,9 +35,7 @@
onIntersected?: (() => void) | undefined;
showAssetName?: boolean;
isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
onNavigateToAsset?: (asset: AssetResponseDto | undefined) => Promise<boolean>;
onReload?: (() => void) | undefined;
pageHeaderOffset?: number;
slidingWindowOffset?: number;
@@ -54,9 +52,7 @@
onIntersected = undefined,
showAssetName = false,
isShowDeleteConfirmation = $bindable(false),
onPrevious = undefined,
onNext = undefined,
onRandom = undefined,
onNavigateToAsset,
onReload = undefined,
slidingWindowOffset = 0,
pageHeaderOffset = 0,
@@ -86,7 +82,7 @@
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
};
let currentIndex = 0;
let currentIndex = $state(0);
if (initialAssetId && assets.length > 0) {
const index = assets.findIndex(({ id }) => id === initialAssetId);
if (index !== -1) {
@@ -295,48 +291,15 @@
})(),
);
const handleNext = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
if (onNext) {
asset = await onNext();
} else {
if (currentIndex >= assets.length - 1) {
return false;
}
currentIndex = currentIndex + 1;
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
}
if (!asset) {
return false;
}
await navigateToAsset(asset);
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return false;
}
};
const handleRandom = async (): Promise<{ id: string } | undefined> => {
try {
let asset: { id: string } | undefined;
if (onRandom) {
asset = await onRandom();
} else {
if (assets.length > 0) {
const randomIndex = Math.floor(Math.random() * assets.length);
asset = assets[randomIndex];
}
}
if (!asset) {
if (assets.length === 0) {
return;
}
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
await navigateToAsset(asset);
return asset;
} catch (error) {
@@ -345,30 +308,13 @@
}
};
const handlePrevious = async (): Promise<boolean> => {
try {
let asset: { id: string } | undefined;
if (onPrevious) {
asset = await onPrevious();
} else {
if (currentIndex <= 0) {
return false;
}
currentIndex = currentIndex - 1;
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
}
if (!asset) {
return false;
}
await navigateToAsset(asset);
const handleNavigateToAsset = async (target: AssetResponseDto | undefined | null) => {
if (target) {
currentIndex = assets.indexOf(target);
await (onNavigateToAsset ? onNavigateToAsset(target) : navigateToAsset(target));
return true;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_previous_asset'));
return false;
}
return false;
};
const navigateToAsset = async (asset?: { id: string }) => {
@@ -390,9 +336,9 @@
if (assets.length === 0) {
await goto(AppRoute.PHOTOS);
} else if (currentIndex === assets.length) {
await handlePrevious();
await handleNavigateToAsset(assetCursor.previousAsset);
} else {
await setAssetId(assets[currentIndex].id);
await handleNavigateToAsset(assetCursor.nextAsset);
}
break;
}
@@ -496,8 +442,7 @@
<AssetViewer
cursor={assetCursor}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@@ -1,20 +1,15 @@
<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 { mobileDevice } from '$lib/stores/mobile-device.svelte';
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 +21,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 +33,21 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const transitionName =
animationTargetAssetId === asset.id && !mobileDevice.prefersReducedMotion ? 'hero' : undefined}
<!-- 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);
});
}),
[],
() => {
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;
@@ -106,6 +107,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(mobileDevice.maxMd);
@@ -213,7 +215,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = getScrollTarget();
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -225,7 +227,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = false;
invisible = isAssetViewerRoute(page) ? true : false;
};
// note: only modified once in afterNavigate()
@@ -243,10 +245,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
@@ -255,8 +260,13 @@
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
});
});
@@ -266,7 +276,7 @@
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting) {
if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false;
}
});
@@ -563,19 +573,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} />
@@ -607,6 +604,7 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -686,11 +684,11 @@
style:width="100%"
>
<Month
{toAssetViewerTransitionId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -704,12 +702,55 @@
{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();
});
});
}),
);
}}
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';
@@ -24,7 +25,6 @@
isShared?: boolean;
album?: AlbumResponseDto;
person?: PersonResponseDto;
removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.SET_VISIBILITY_TIMELINE | null;
}
@@ -41,7 +41,7 @@
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getNextAsset(asset, false);
@@ -52,9 +52,8 @@
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (laterTimelineAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
if (preload) {
// also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete
void getPreviousAsset(asset, false);
@@ -90,8 +89,12 @@
if (!targetAsset) {
return false;
}
// let waitForAssetViewerFree = new Promise<void>((resolve) => {
// eventManager.once('AssetViewerFree', () => resolve());
// });
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
// await waitForAssetViewerFree;
return true;
};
@@ -104,6 +107,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 };
@@ -196,18 +203,17 @@
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
}
};
onDestroy(() => {
assetCacheManager.invalidate();
});
const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
const handleUpdateOrUpload = (asset: AssetResponseDto) => {
if (asset.id === assetCursor.current.id) {
void loadCloseAssets(asset);
}
};
onMount(() => {
const unsubscribes = [
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => onAssetUpdate({ event: 'update', asset })),
websocketEvents.on('on_upload_success', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
websocketEvents.on('on_asset_update', (asset: AssetResponseDto) => handleUpdateOrUpload(asset)),
];
return () => {
for (const unsubscribe of unsubscribes) {
@@ -215,6 +221,10 @@
}
};
});
onDestroy(() => {
assetCacheManager.invalidate();
});
</script>
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
@@ -230,8 +240,7 @@
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={handleClose}
/>

View File

@@ -23,7 +23,6 @@
let { assets, onResolve, onStack }: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
let selectedAssetIds = $state(new SvelteSet<string>());
@@ -44,21 +43,11 @@
assetViewingStore.showAssetViewer(false);
});
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
const handleNavigateToAsset = async (asset: AssetResponseDto | undefined | null) => {
if (!asset) {
return false;
}
await onViewAsset(assets[index]);
return true;
};
const onPrevious = async () => {
const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) {
return false;
}
await onViewAsset(assets[index]);
await onViewAsset(asset);
return true;
};
@@ -191,8 +180,7 @@
<AssetViewer
cursor={assetCursor}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
onNavigateToAsset={handleNavigateToAsset}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

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 null;
};
get activeViewTransition() {
return this.#activeViewTransition;
}
isSupported() {
return 'startViewTransition' in document;
}
skipTransitions() {
const skippedTransitions = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#notifyFinished();
return skippedTransitions;
}
startTransition(domUpdateComplete: Promise<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

@@ -45,6 +45,20 @@ export type Events = {
// confirmed permanently deleted from server
UserAdminDeleted: [{ id: string }];
AssetViewerFree: [];
TransitionToTimeline: [{ id: string }];
TimelineLoaded: [{ id: string | null }];
TransitionToAssetViewer: [];
AssetViewerLoaded: [];
BeforeStartViewTransition: [];
Finished: [];
Ready: [];
UpdateCallbackDone: [];
StartViewTransition: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];
@@ -67,11 +81,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,7 +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>();
@@ -31,6 +31,7 @@ function createAssetViewingStore() {
setAsset,
setAssetId,
showAssetViewer,
invisible,
};
}

View File

@@ -1,4 +1,4 @@
import { writable } from 'svelte/store';
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
export const photoViewerImgElement = writable<HTMLImageElement | null | undefined>(null);
export const isSelectingAllAssets = writable(false);

View File

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

View File

@@ -1,3 +1,4 @@
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { getAssetOcr } from '@immich/sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -30,6 +31,7 @@ describe('OcrManager', () => {
beforeEach(() => {
// Reset the singleton state before each test
ocrManager.clear();
assetCacheManager.clearOcrCache();
vi.clearAllMocks();
});

View File

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

View File

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

View File

@@ -130,3 +130,19 @@ export type CommonPosition = {
width: number;
height: number;
};
// Scales dimensions to fit within a container (like object-fit: contain)
export const scaleToFit = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
) => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.min(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};

View File

@@ -24,11 +24,11 @@ export interface boundingBox {
export const getBoundingBox = (
faces: Faces[],
zoom: ZoomImageWheelState,
photoViewer: HTMLImageElement | null,
photoViewer: HTMLImageElement | null | undefined,
): boundingBox[] => {
const boxes: boundingBox[] = [];
if (photoViewer === null) {
if (!photoViewer) {
return boxes;
}
const clientHeight = photoViewer.clientHeight;
@@ -76,9 +76,9 @@ export const zoomImageToBase64 = async (
face: AssetFaceResponseDto,
assetId: string,
assetType: AssetTypeEnum,
photoViewer: HTMLImageElement | null,
photoViewer: HTMLImageElement | null | undefined,
): Promise<string | null> => {
let image: HTMLImageElement | null = null;
let image: HTMLImageElement | null | undefined = null;
if (assetType === AssetTypeEnum.Image) {
image = photoViewer;
} else if (assetType === AssetTypeEnum.Video) {
@@ -93,7 +93,7 @@ export const zoomImageToBase64 = async (
image = img;
}
if (image === null) {
if (!image) {
return null;
}
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;

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

@@ -24,7 +24,6 @@
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
@@ -36,27 +35,16 @@
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
viewingAssetCursor = 0;
await setAssetId(assetIds[0]);
}
async function navigateNext() {
if (viewingAssetCursor < viewingAssets.length - 1) {
await setAssetId(viewingAssets[++viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
const handleNavigateToAsset = async (currentAsset: AssetResponseDto | undefined | null) => {
if (!currentAsset) {
return false;
}
return false;
}
async function navigatePrevious() {
if (viewingAssetCursor > 0) {
await setAssetId(viewingAssets[--viewingAssetCursor]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return true;
}
return false;
}
await navigate({ targetRoute: 'current', assetId: currentAsset.id });
return true;
};
async function navigateRandom() {
if (viewingAssets.length <= 0) {
@@ -138,13 +126,12 @@
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer}
{#if $showAssetViewer && assetCursor.current}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={assetCursor}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onNavigateToAsset={handleNavigateToAsset}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@@ -20,29 +20,17 @@
let assets = $derived(data.assets);
let asset = $derived(data.asset);
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
$effect(() => {
if (asset) {
setAsset(asset);
}
});
const onNext = async () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
const handleNavigateToAsset = async (asset: AssetResponseDto | undefined | null) => {
if (!asset) {
return false;
}
await onViewAsset(assets[index]);
return true;
};
const onPrevious = async () => {
const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) {
return false;
}
await onViewAsset(assets[index]);
await onViewAsset(asset);
return true;
};
@@ -93,9 +81,8 @@
<Portal target="body">
<AssetViewer
cursor={assetCursor}
onNavigateToAsset={handleNavigateToAsset}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
{onAction}
onClose={() => {

View File

@@ -1,5 +1,17 @@
<script lang="ts" module>
export class AppState {
#isAssetViewer = $state<boolean>(false);
set isAssetViewer(value) {
this.#isAssetViewer = value;
}
get isAssetViewer() {
return this.#isAssetViewer;
}
}
</script>
<script lang="ts">
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';
@@ -30,7 +42,7 @@
type ActionItem,
} from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
import { onMount, type Snippet } from 'svelte';
import { onMount, setContext, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import '../app.css';
@@ -56,6 +68,10 @@
let showNavigationLoadingBar = $state(false);
const appState = new AppState();
appState.isAssetViewer = isAssetViewerRoute(page);
setContext('AppState', appState);
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -81,8 +97,14 @@
showNavigationLoadingBar = true;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
onNavigate(({ to }) => {
appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
});
afterNavigate(({ to, complete }) => {
appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
void complete.finally(() => {
showNavigationLoadingBar = false;
});
});
$effect.pre(() => {

View File

@@ -30,7 +30,7 @@ export const put = async (key: string, response: Response) => {
return;
}
cache.put(key, response.clone());
await cache.put(key, response.clone());
};
export const prune = async () => {

View File

@@ -1,6 +1,6 @@
import { get, put } from './cache';
const pendingRequests = new Map<string, AbortController>();
const pendingRequests = new Map<string, { abort: AbortController; callbacks: ((canceled: boolean) => void)[] }>();
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
@@ -31,6 +31,11 @@ export const handlePreload = async (request: URL | Request) => {
}
};
const canceledResponse = () => {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
};
export const handleRequest = async (request: URL | Request) => {
const cacheKey = getCacheKey(request);
const cachedResponse = await get(cacheKey);
@@ -38,36 +43,53 @@ export const handleRequest = async (request: URL | Request) => {
return cachedResponse;
}
let canceled = false;
try {
const requestSignals = pendingRequests.get(cacheKey);
if (requestSignals) {
const canceled = await new Promise<boolean>((resolve) => requestSignals.callbacks.push(resolve));
if (canceled) {
return canceledResponse();
}
const cachedResponse = await get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
}
const cancelToken = new AbortController();
pendingRequests.set(cacheKey, cancelToken);
pendingRequests.set(cacheKey, { abort: cancelToken, callbacks: [] });
const response = await fetch(request, { signal: cancelToken.signal });
assertResponse(response);
put(cacheKey, response);
await put(cacheKey, response);
return response;
} catch (error) {
if (error.name === 'AbortError') {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
if (error instanceof Error && error.name === 'AbortError') {
canceled = true;
return canceledResponse();
}
console.log('Not an abort error', error);
throw error;
} finally {
const requestSignals = pendingRequests.get(cacheKey);
pendingRequests.delete(cacheKey);
if (requestSignals) {
for (const callback of requestSignals.callbacks) {
callback(canceled);
}
}
}
};
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
const requestSignals = pendingRequests.get(cacheKey);
if (!requestSignals) {
return;
}
pendingRequest.abort();
pendingRequests.delete(cacheKey);
requestSignals.abort.abort();
};