Compare commits

..

4 Commits

31 changed files with 1647 additions and 793 deletions

View File

@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
if (!value) {
throw new Error('Ran out of random asset data');
}
return value;
};

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;

12
pnpm-lock.yaml generated
View File

@@ -752,9 +752,6 @@ importers:
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.8(svelte@5.43.3)
async-mutex:
specifier: ^0.5.0
version: 0.5.0
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -5587,9 +5584,6 @@ packages:
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
async-mutex@0.5.0:
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
async@0.2.10:
resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==}
@@ -11831,10 +11825,12 @@ packages:
whatwg-encoding@2.0.0:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@@ -17911,10 +17907,6 @@ snapshots:
async-lock@1.4.1: {}
async-mutex@0.5.0:
dependencies:
tslib: 2.8.1
async@0.2.10: {}
async@3.2.6: {}

View File

@@ -40,7 +40,6 @@
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0",
"@zoom-image/svelte": "^0.3.0",
"async-mutex": "^0.5.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"geo-coordinates-parser": "^1.7.4",

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

@@ -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,17 +10,19 @@
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@@ -50,11 +52,14 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
type HasAsset = boolean;
export type AssetCursor = {
current: AssetResponseDto;
nextAsset: AssetResponseDto | undefined | null;
previousAsset: AssetResponseDto | undefined | null;
};
interface Props {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[];
cursor: AssetCursor;
showNavigation?: boolean;
withStacked?: boolean;
isShared?: boolean;
@@ -63,16 +68,14 @@
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
showCloseButton?: boolean;
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<{ id: string } | undefined>;
onClose?: (asset: AssetResponseDto) => void;
onNavigateToAsset?: (asset: AssetResponseDto | undefined | null) => Promise<boolean>;
onRandom?: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
let {
asset = $bindable(),
preloadAssets = $bindable([]),
cursor,
showNavigation = true,
withStacked = false,
isShared = false,
@@ -82,8 +85,7 @@
onAction = undefined,
showCloseButton,
onClose,
onNext,
onPrevious,
onNavigateToAsset,
onRandom,
copyImage = $bindable(),
}: Props = $props();
@@ -94,15 +96,17 @@
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let asset = $derived(cursor.current);
let nextAsset = $derived(cursor.nextAsset);
let previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let shouldPlayMotionPhoto = $state(false);
let sharedLink = getSharedLink();
let enableDetailPanel = asset.hasMetadata;
let enableDetailPanel = $derived(asset.hasMetadata);
let slideshowStateUnsubscribe: () => void;
let shuffleSlideshowUnsubscribe: () => void;
let previewStackedAsset: AssetResponseDto | undefined = $state();
@@ -135,7 +139,7 @@
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadAssets.push(toTimelineAsset(stack.assets[1]));
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
});
};
@@ -150,18 +154,7 @@
}
};
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
};
onMount(async () => {
unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
);
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@@ -225,7 +218,7 @@
};
const closeViewer = () => {
onClose(asset);
onClose?.(asset);
};
const closeEditor = () => {
@@ -234,7 +227,9 @@
});
};
const navigateAsset = async (order?: 'previous' | 'next', e?: Event) => {
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
@@ -244,38 +239,42 @@
}
e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
let hasNext = false;
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 ($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;
}
}
} else if (onNavigateToAsset) {
hasNext =
order === 'previous'
? await onNavigateToAsset(cursor.previousAsset)
: await onNavigateToAsset(cursor.nextAsset);
} else {
hasNext = false;
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
} else {
hasNext = order === 'previous' ? await onPrevious() : await onNext();
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
});
};
// const showEditorHandler = () => {
// if (isShowActivity) {
// isShowActivity = false;
// }
// isShowEditor = !isShowEditor;
// };
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
@@ -327,6 +326,7 @@
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -378,12 +378,6 @@
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (asset) {
previewStackedAsset = undefined;
handlePromiseError(refreshStack());
}
});
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
@@ -395,13 +389,51 @@
}
});
// primarily, this is reactive on `asset`
$effect(() => {
handlePromiseError(handleGetAllAlbums());
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
handlePromiseError(ocrManager.getAssetOcr(asset.id));
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
eventManager.emit('AssetViewerFree');
}
});
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Image) {
if (shouldPlayMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
} else if (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) {
return 'ImagePanaramaViewer';
} else if (isShowEditor && selectedEditType === 'crop') {
return 'CropArea';
}
return 'PhotoViewer';
}
return 'VideoViewer';
});
</script>
@@ -448,7 +480,7 @@
{/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex">
<div class="absolute w-full flex justify-center">
<SlideshowBar
{isFullScreen}
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
@@ -459,112 +491,105 @@
</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">
<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
asset={previewStackedAsset}
{preloadAssets}
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 shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (shouldPlayMotionPhoto = 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
{asset}
{preloadAssets}
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}
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
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
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={() => (shouldPlayMotionPhoto = false)}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
onFree={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
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}
onOpenActivityTab={handleOpenActivity}
/>
</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}
onOpenActivityTab={handleOpenActivity}
/>
</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}
{#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">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>

View File

@@ -1,210 +0,0 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
import type { MockInstance } from 'vitest';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;
vi.mock('$lib/utils', async (originalImport) => {
const meta = await originalImport<typeof import('$lib/utils')>();
return {
...meta,
getAssetOriginalUrl: vi.fn(),
getAssetThumbnailUrl: vi.fn(),
};
});
describe('PhotoViewer component', () => {
let getAssetOriginalUrlSpy: MockInstance;
let getAssetThumbnailUrlSpy: MockInstance;
beforeAll(() => {
getAssetOriginalUrlSpy = vi.spyOn(utils, 'getAssetOriginalUrl');
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
vi.stubGlobal('cast', {
framework: {
CastState: {
NO_DEVICES_AVAILABLE: 'NO_DEVICES_AVAILABLE',
},
RemotePlayer: vi.fn().mockImplementation(() => ({})),
RemotePlayerEventType: {
ANY_CHANGE: 'anyChanged',
},
RemotePlayerController: vi.fn().mockImplementation(() => ({ addEventListener: vi.fn() })),
CastContext: {
getInstance: vi.fn().mockImplementation(() => ({ setOptions: vi.fn(), addEventListener: vi.fn() })),
},
CastContextEventType: {
SESSION_STATE_CHANGED: 'sessionstatechanged',
CAST_STATE_CHANGED: 'caststatechanged',
},
},
});
vi.stubGlobal('chrome', {
cast: { media: { PlayerState: { IDLE: 'IDLE' } }, AutoJoinPolicy: { ORIGIN_SCOPED: 'origin_scoped' } },
});
});
beforeEach(() => {
Element.prototype.animate = getAnimateMock();
});
afterEach(() => {
vi.resetAllMocks();
});
it('loads the thumbnail', () => {
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads the original image for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original animated image when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original animated image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
});

View File

@@ -1,67 +1,79 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { swipeFeedback } from '$lib/actions/swipe-feedback';
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 type { TimelineAsset } from '$lib/managers/timeline-manager/types';
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 { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { 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, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
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 {
asset: AssetResponseDto;
preloadAssets?: TimelineAsset[] | undefined;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
cursor: AssetCursor;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onPreviousAsset?: (() => void) | null;
onFree?: (() => void) | null;
onBusy?: (() => void) | null;
onError?: (() => void) | null;
onLoad?: (() => void) | null;
onNextAsset?: (() => void) | null;
copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
}
let {
asset,
preloadAssets = undefined,
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
sharedLink,
onPreviousAsset = null,
onNextAsset = null,
onFree = null,
onBusy = null,
onError = null,
onLoad = null,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let loader = $state<HTMLImageElement>();
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',
};
});
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
@@ -82,25 +94,6 @@
let isOcrActive = $derived(ocrManager.showOverlay);
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
copyImage = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
return;
@@ -136,42 +129,21 @@
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();
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
});
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
$effect(() => {
if (assetFileUrl) {
void cast(assetFileUrl);
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
}
});
@@ -190,36 +162,84 @@
};
const onload = () => {
onLoad?.();
onFree?.();
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
dimensions = {
width: loader?.naturalWidth ?? 1,
height: loader?.naturalHeight ?? 1,
};
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
onError?.();
onFree?.();
dimensions = {
width: loader?.naturalWidth ?? 1,
height: loader?.naturalHeight ?? 1,
};
imageError = imageLoaded = true;
};
$effect(() => {
preload(targetImageSize, preloadAssets);
});
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload, { passive: true });
loader?.addEventListener('error', onerror, { passive: true });
return () => {
loader?.removeEventListener('load', onload);
loader?.removeEventListener('error', onerror);
cancelImageUrl(imageLoaderUrl);
if (!imageLoaded && !imageError) {
onFree?.();
}
if (imageLoaderUrl) {
preloadManager.cancelPreloadUrl(imageLoaderUrl);
}
};
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash));
const imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
const previousAssetUrl = $derived(getAssetUrl({ asset: cursor.previousAsset, sharedLink }));
const nextAssetUrl = $derived(getAssetUrl({ asset: cursor.nextAsset, sharedLink }));
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 | null | null;
let lastPreviousUrl: string | undefined | null;
let lastNextUrl: string | undefined | null;
$effect(() => {
if (!lastUrl) {
untrack(() => onBusy?.());
}
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
const isPreviewedImage = imageLoaderUrl === lastPreviousUrl || imageLoaderUrl === lastNextUrl;
if (!isPreviewedImage) {
// It is a previewed image - prevent flicker - skip spinner but still let loader go through lifecycle
imageLoaded = false;
}
originalImageLoaded = false;
imageError = false;
onBusy?.();
});
}
lastUrl = imageLoaderUrl;
lastPreviousUrl = previousAssetUrl;
lastNextUrl = nextAssetUrl;
});
</script>
<svelte:document
@@ -232,64 +252,67 @@
]}
/>
{#if imageError}
<div class="h-full w-full">
<div id="broken-asset" class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
class="relative h-full select-none"
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: $photoViewerImgElement,
}}
>
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#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 }}
>
<div class="absolute" style:width={box.width} style:height={box.height} style:left={box.left} style:top={box.top}>
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img
bind:this={$photoViewerImgElement}
src={assetFileUrl}
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}
<div use:zoomImageAction={{ disabled: isOcrActive }} style:width={box.width} style:height={box.height}>
<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>
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if}
{/if}
</div>
</div>
<style>
@@ -298,6 +321,7 @@
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;

View File

@@ -1,8 +1,11 @@
<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 { 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 +13,19 @@
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 {
assetId: string;
previousAsset?: AssetResponseDto | null | undefined;
nextAsset?: AssetResponseDto | undefined | null | undefined;
sharedLink?: SharedLinkResponseDto;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
@@ -31,6 +38,9 @@
let {
assetId,
previousAsset,
nextAsset,
sharedLink,
loopVideo,
cacheKey,
playOriginalVideo,
@@ -41,6 +51,8 @@
onClose = () => {},
}: Props = $props();
let asset = $state<AssetResponseDto | null>(null);
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
@@ -49,11 +61,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({ key: cacheKey ?? assetId, id: assetId }).then((assetDto) => (asset = assetDto)),
);
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
@@ -67,6 +99,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 +138,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 +193,43 @@
/>
</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>
<video
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 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}

View File

@@ -2,9 +2,13 @@
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 {
assetId: string;
previousAsset?: AssetResponseDto | null | undefined;
nextAsset?: AssetResponseDto | null | undefined;
sharedLink?: SharedLinkResponseDto;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
@@ -18,6 +22,9 @@
let {
assetId,
previousAsset,
nextAsset,
sharedLink,
projectionType,
cacheKey,
loopVideo,
@@ -37,6 +44,9 @@
{loopVideo}
{cacheKey}
{assetId}
{nextAsset}
{sharedLink}
{previousAsset}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false);
}
return {
destroy: () => cancelImageUrl(url),
destroy: () => preloadManager.cancelPreloadUrl(url),
};
}

View File

@@ -26,13 +26,13 @@
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';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import {
mdiCardsOutline,
@@ -67,7 +67,7 @@
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
let currentTimelineAssets = $derived(current?.memory.assets || []);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
@@ -396,7 +396,7 @@
</p>
</div>
{#if currentTimelineAssets.some(({ isVideo }) => isVideo)}
{#if currentTimelineAssets.some((asset) => asset.type === AssetTypeEnum.Video)}
<div class="w-12.5 dark">
<IconButton
shape="round"
@@ -651,8 +651,6 @@
bind:this={memoryGallery}
>
<GalleryViewer
onNext={handleNextAsset}
onPrevious={handlePreviousAsset}
assets={currentTimelineAssets}
viewport={galleryViewport}
{assetInteraction}

View File

@@ -32,7 +32,7 @@
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
let assets = $derived(sharedLink.assets);
dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) {
@@ -68,7 +68,7 @@
};
const handleSelectAll = () => {
assetInteraction.selectAssets(assets);
assetInteraction.selectAssets(assets.map((asset) => toTimelineAsset(asset)));
};
const handleAction = async (action: Action) => {
@@ -145,13 +145,9 @@
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{asset}
cursor={{ current: asset, nextAsset: null, previousAsset: null }}
showCloseButton={false}
onAction={handleAction}
onPrevious={() => Promise.resolve(false)}
onNext={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)}
onClose={() => {}}
/>
{/await}
{/await}

View File

@@ -2,9 +2,11 @@
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
@@ -19,15 +21,16 @@
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { AssetVisibility, getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es';
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
interface Props {
initialAssetId?: string;
assets: TimelineAsset[] | AssetResponseDto[];
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
@@ -35,9 +38,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 +55,7 @@
onIntersected = undefined,
showAssetName = false,
isShowDeleteConfirmation = $bindable(false),
onPrevious = undefined,
onNext = undefined,
onRandom = undefined,
onNavigateToAsset,
onReload = undefined,
slidingWindowOffset = 0,
pageHeaderOffset = 0,
@@ -86,7 +85,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) {
@@ -229,7 +228,7 @@
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
assetInteraction.selectedAssets,
onReload,
);
@@ -242,7 +241,7 @@
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[];
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
}
};
@@ -295,48 +294,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 +311,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 +339,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;
}
@@ -424,6 +373,59 @@
selectAssetCandidates(lastAssetMouseEvent);
}
});
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = assets.indexOf(currentAsset);
if (cursor < assets.length - 1) {
const id = assets[cursor + 1].id;
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getNextAsset(asset, false);
}
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = assets.indexOf(currentAsset);
if (cursor <= 0) {
return;
}
const id = assets[cursor - 1].id;
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getPreviousAsset(asset, false);
}
return asset;
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
</script>
<svelte:document
@@ -488,10 +490,9 @@
<Portal target="body">
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
asset={$viewingAsset}
cursor={assetCursor}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@@ -1,15 +1,22 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import { AssetAction } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
import { onDestroy, onMount, untrack } from 'svelte';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
@@ -38,44 +45,71 @@
person = null,
}: Props = $props();
const handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
if (earlierTimelineAsset) {
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);
}
return asset;
}
release();
return !!laterAsset;
};
const handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => {
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
if (laterTimelineAsset) {
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);
}
return asset;
}
};
release();
return !!earlierAsset;
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => handlePromiseError(loadCloseAssets($viewingAsset)));
});
const handleNavigateToAsset = async (targetAsset: AssetResponseDto | undefined | null) => {
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;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
return { id: randomAsset.id };
}
};
@@ -97,7 +131,9 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
(await handleNavigateToAsset(assetCursor?.nextAsset)) ||
(await handleNavigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
@@ -163,20 +199,40 @@
}
}
};
onDestroy(() => {
assetCacheManager.invalidate();
});
const onAssetUpdate = ({ asset }: { event: 'upload' | 'update'; 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 })),
];
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</script>
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
cursor={assetCursor}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onAction={(action) => {
handleAction(action);
assetCacheManager.invalidate();
}}
onNavigateToAsset={handleNavigateToAsset}
onRandom={handleRandom}
onClose={handleClose}
/>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
@@ -10,7 +11,7 @@
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
@@ -43,21 +44,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;
};
@@ -102,6 +93,43 @@
const handleStack = () => {
onStack(assets);
};
const getPreviousAsset = (currentAsset: AssetResponseDto) => {
const index = getAssetIndex(currentAsset.id) - 1;
if (index < 0) {
return undefined;
}
return assets[index];
};
const getNextAsset = (currentAsset: AssetResponseDto) => {
const index = getAssetIndex(currentAsset.id) + 1;
if (index >= assets.length) {
return undefined;
}
return assets[index];
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = (currentAsset: AssetResponseDto) => {
assetCursor = {
current: currentAsset,
nextAsset: getNextAsset(currentAsset),
previousAsset: getPreviousAsset(currentAsset),
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
</script>
<svelte:document
@@ -182,10 +210,9 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
cursor={assetCursor}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
onNavigateToAsset={handleNavigateToAsset}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@@ -0,0 +1,60 @@
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
class AsyncCache<V> {
#cache = new Map<string, V>();
async getOrFetch<K>(
params: K,
fetcher: (params: K) => Promise<V>,
keySerializer: (params: K) => string = defaultSerializer,
updateCache: boolean,
): Promise<V> {
const cacheKey = keySerializer(params);
const cached = this.#cache.get(cacheKey);
if (cached) {
return cached;
}
const value = await fetcher(params);
if (value && updateCache) {
this.#cache.set(cacheKey, value);
}
return value;
}
clear() {
this.#cache.clear();
}
}
class AssetCacheManager {
#assetCache = new AsyncCache<AssetResponseDto>();
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
}
async getAssetOcr(id: string) {
return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true);
}
clearAssetCache() {
this.#assetCache.clear();
}
clearOcrCache() {
this.#ocrCache.clear();
}
invalidate() {
this.clearAssetCache();
this.clearOcrCache();
}
}
export const assetCacheManager = new AssetCacheManager();

View File

@@ -0,0 +1,38 @@
import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
class PreloadManager {
preload(asset: AssetResponseDto | undefined | null) {
if (globalThis.isSecureContext) {
preloadImageUrl(getAssetUrl({ asset }));
return;
}
if (!asset || asset.type !== AssetTypeEnum.Image) {
return;
}
const img = new Image();
const url = getAssetUrl({ asset });
if (!url) {
return;
}
img.src = url;
}
cancel(asset: AssetResponseDto | undefined | null) {
if (!globalThis.isSecureContext || !asset) {
return;
}
const url = getAssetUrl({ asset });
cancelImageUrl(url);
}
cancelPreloadUrl(url: string | undefined | null) {
if (!globalThis.isSecureContext) {
return;
}
cancelImageUrl(url);
}
}
export const preloadManager = new PreloadManager();

View File

@@ -43,6 +43,8 @@ export type Events = {
// confirmed permanently deleted from server
UserAdminDeleted: [{ id: string }];
AssetViewerFree: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];

View File

@@ -85,7 +85,7 @@
<div
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
>
<PhotoViewer bind:element={imgElement} {asset} />
<PhotoViewer bind:element={imgElement} cursor={{ current: asset, nextAsset: null, previousAsset: null }} />
</div>
</div>
</ModalBody>

View File

@@ -1,19 +1,14 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Mutex } from 'async-mutex';
import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<TimelineAsset[]>([]);
const viewState = writable<boolean>(false);
const viewingAssetMutex = new Mutex();
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
preloadAssets.set(assetsToPreload);
const setAsset = (asset: AssetResponseDto) => {
viewingAssetStoreState.set(asset);
viewState.set(true);
};
@@ -30,8 +25,6 @@ function createAssetViewingStore() {
return {
asset: readonly(viewingAssetStoreState),
mutex: viewingAssetMutex,
preloadAssets: readonly(preloadAssets),
isViewing: viewState,
gridScrollTarget,
setAsset,

View File

@@ -1,6 +1,141 @@
import { getReleaseType } from '$lib/utils';
import { getAssetUrl, getReleaseType } from '$lib/utils';
import { AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
describe('utils', () => {
describe(getAssetUrl.name, () => {
it('should return thumbnail URL for static images', () => {
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
// Should return a thumbnail URL (contains /thumbnail)
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
const url = getAssetUrl({ asset });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return original URL for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const url = getAssetUrl({ asset });
// Should return original URL (contains /original)
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return original URL for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const url = getAssetUrl({ asset });
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).toContain(asset.id);
});
it('should return original URL for animated images in shared link with download and showMetadata permissions', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL (not original) for animated images when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).not.toContain('/original');
expect(url).toContain(asset.id);
});
it('should return thumbnail URL (not original) for animated images when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
const url = getAssetUrl({ asset, sharedLink });
expect(url).toContain('/thumbnail');
expect(url).not.toContain('/original');
expect(url).toContain(asset.id);
});
});
describe(getReleaseType.name, () => {
it('should return "major" for major version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');

View File

@@ -1,10 +1,12 @@
import { defaultLang, langs, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { lang } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
MemoryType,
QueueName,
finishOAuth,
@@ -17,6 +19,7 @@ import {
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type AssetResponseDto,
type MemoryResponseDto,
type PersonResponseDto,
type ServerVersionResponseDto,
@@ -191,6 +194,40 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null };
export const getAssetUrl = ({
asset,
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto | undefined | null;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
if (!asset) {
return null;
}
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
const targetSize = targetImageSize(asset, forceOriginal);
return targetSize === 'original'
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: targetSize, cacheKey });
};
const forceUseOriginal = (asset: AssetResponseDto) => {
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
};
export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => {
if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) {
return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize;
}
return AssetMediaSize.Preview;
};
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };

View File

@@ -50,4 +50,13 @@ export class InvocationTracker {
isActive() {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>) {
const invocation = this.startInvocation();
try {
return await invocable();
} finally {
invocation.endInvocation();
}
}
}

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

@@ -1,8 +1,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { RouteId } from '$app/types';
import { AppRoute } from '$lib/constants';
import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { get } from 'svelte/store';
export type AssetGridRouteSearchParams = {
@@ -20,11 +20,12 @@ export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(u
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');
export const isLockedFolderRoute = (route?: string | null) => !!route?.startsWith('/(user)/locked');
export const isAssetViewerRoute = (target?: NavigationTarget | null) =>
!!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export const isAssetViewerRoute = (
target?: { route?: { id?: RouteId | null }; params?: Record<string, string> | null } | null,
) => !!(target?.route?.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId, slug, key }: { assetId?: string; key?: string; slug?: string }) {
return assetId ? getAssetInfo({ id: assetId, slug, key }) : undefined;
return assetId ? assetCacheManager.getAsset({ id: assetId, slug, key }, false) : undefined;
}
function currentUrlWithoutAsset() {

View File

@@ -1,8 +1,14 @@
const broadcast = new BroadcastChannel('immich');
export function cancelImageUrl(url: string) {
export function cancelImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string) {
export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url });
}

View File

@@ -1,15 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute, timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import type { PageData } from './$types';
interface Props {
@@ -21,7 +24,6 @@
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let viewingAssets: string[] = $state([]);
let viewingAssetCursor = 0;
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
@@ -33,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) {
@@ -64,6 +55,59 @@
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return asset;
}
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor < viewingAssets.length - 1) {
const id = viewingAssets[cursor + 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getNextAsset(asset, false);
}
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor <= 0) {
return;
}
const id = viewingAssets[cursor - 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getPreviousAsset(asset, false);
}
return asset;
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
</script>
{#if featureFlagsManager.value.map}
@@ -82,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
asset={$viewingAsset}
cursor={assetCursor}
showNavigation={viewingAssets.length > 1}
onNext={navigateNext}
onPrevious={navigatePrevious}
onNavigateToAsset={handleNavigateToAsset}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);

View File

@@ -22,7 +22,7 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
@@ -35,6 +35,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
type AlbumResponseDto,
type AssetResponseDto,
getPerson,
getTagById,
type MetadataSearchDto,
@@ -58,7 +59,7 @@
let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: TimelineAsset[] = $state([]);
let searchResultAssets: AssetResponseDto[] = $state([]);
let isLoading = $state(true);
let scrollY = $state(0);
let scrollYHistory = 0;
@@ -121,7 +122,7 @@
const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
searchResultAssets = searchResultAssets.filter((asset: AssetResponseDto) => !assetIdSet.has(asset.id));
};
const handleSetVisibility = (assetIds: string[]) => {
@@ -130,7 +131,7 @@
};
const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets);
assetInteraction.selectAssets(searchResultAssets.map((asset) => toTimelineAsset(asset)));
};
async function onSearchQueryUpdate() {
@@ -162,7 +163,7 @@
: await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
searchResultAssets.push(...assets.items);
nextPage = Number(assets.nextPage) || 0;
} catch (error) {

View File

@@ -1,14 +1,18 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
import type { AssetResponseDto } from '@immich/sdk';
import { getAssetInfo } from '@immich/sdk';
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import type { AssetResponseDto } from '@immich/sdk';
interface Props {
data: PageData;
@@ -19,29 +23,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;
};
@@ -65,6 +57,59 @@
const onViewAsset = async (asset: AssetResponseDto) => {
await navigate({ targetRoute: 'current', assetId: asset.id });
};
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = assets.indexOf(currentAsset);
if (cursor < assets.length - 1) {
const id = assets[cursor + 1].id;
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getNextAsset(asset, false);
}
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = assets.indexOf(currentAsset);
if (cursor <= 0) {
return;
}
const id = assets[cursor - 1].id;
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getPreviousAsset(asset, false);
}
return asset;
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
</script>
<UserPageLayout title={data.meta.title} scrollbar={true}>
@@ -85,10 +130,9 @@
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
cursor={assetCursor}
onNavigateToAsset={handleNavigateToAsset}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
{onAction}
onClose={() => {