mirror of
https://github.com/immich-app/immich.git
synced 2025-12-29 09:14:59 +03:00
Compare commits
2 Commits
push-ukqkw
...
push-nwxlp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5066203d07 | ||
|
|
45a5d41fdf |
@@ -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;
|
||||
|
||||
473
web/src/lib/actions/swipe-feedback.ts
Normal file
473
web/src/lib/actions/swipe-feedback.ts
Normal 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 = '';
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 = [];
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -51,8 +52,6 @@
|
||||
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;
|
||||
@@ -69,10 +68,9 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -87,8 +85,7 @@
|
||||
onAction = undefined,
|
||||
showCloseButton,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onNavigateToAsset,
|
||||
onRandom,
|
||||
copyImage = $bindable(),
|
||||
}: Props = $props();
|
||||
@@ -99,12 +96,13 @@
|
||||
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();
|
||||
@@ -220,7 +218,7 @@
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
onClose(asset);
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
@@ -252,14 +250,19 @@
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom();
|
||||
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 = order === 'previous' ? await onPrevious() : await onNext();
|
||||
hasNext = false;
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
@@ -323,6 +326,7 @@
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
@@ -396,7 +400,6 @@
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
@@ -404,6 +407,34 @@
|
||||
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>
|
||||
|
||||
<OnEvents onAssetReplace={handleAssetReplace} />
|
||||
@@ -449,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?.()}
|
||||
@@ -460,110 +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
|
||||
cursor={{ ...cursor, current: previewStackedAsset }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else}
|
||||
<VideoViewer
|
||||
assetId={previewStackedAsset.id}
|
||||
cacheKey={previewStackedAsset.thumbhash}
|
||||
projectionType={previewStackedAsset.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
{:else}
|
||||
{#key asset.id}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
{#if 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
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<VideoViewer
|
||||
assetId={asset.id}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{/if}
|
||||
{#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>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<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 { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
@@ -14,26 +14,28 @@
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { 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 { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
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;
|
||||
@@ -42,10 +44,13 @@
|
||||
let {
|
||||
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();
|
||||
@@ -59,6 +64,16 @@
|
||||
|
||||
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,
|
||||
@@ -114,21 +129,12 @@
|
||||
event.preventDefault();
|
||||
handlePromiseError(copyImage());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if ($photoZoomState.currentZoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
const handleSwipeCommit = (direction: 'left' | 'right') => {
|
||||
if (direction === 'left' && onNextAsset) {
|
||||
// Swiped left, go to next asset
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
} else if (direction === 'right' && onPreviousAsset) {
|
||||
// Swiped right, go to previous asset
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
@@ -156,36 +162,83 @@
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
onLoad?.();
|
||||
onFree?.();
|
||||
imageLoaded = true;
|
||||
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;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
preloadManager.cancelPreloadUrl(imageLoaderUrl);
|
||||
if (!imageLoaded && !imageError) {
|
||||
onFree?.();
|
||||
}
|
||||
if (imageLoaderUrl) {
|
||||
preloadManager.cancelPreloadUrl(imageLoaderUrl);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
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;
|
||||
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) {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
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>
|
||||
|
||||
@@ -209,18 +262,21 @@
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{#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={imageLoaderUrl}
|
||||
@@ -229,32 +285,34 @@
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={$photoViewerImgElement}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{/each}
|
||||
<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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
|
||||
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
|
||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
@@ -651,8 +651,6 @@
|
||||
bind:this={memoryGallery}
|
||||
>
|
||||
<GalleryViewer
|
||||
onNext={handleNextAsset}
|
||||
onPrevious={handlePreviousAsset}
|
||||
assets={currentTimelineAssets}
|
||||
viewport={galleryViewport}
|
||||
{assetInteraction}
|
||||
|
||||
@@ -148,10 +148,6 @@
|
||||
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}
|
||||
|
||||
@@ -38,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;
|
||||
@@ -57,9 +55,7 @@
|
||||
onIntersected = undefined,
|
||||
showAssetName = false,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
onPrevious = undefined,
|
||||
onNext = undefined,
|
||||
onRandom = undefined,
|
||||
onNavigateToAsset,
|
||||
onReload = undefined,
|
||||
slidingWindowOffset = 0,
|
||||
pageHeaderOffset = 0,
|
||||
@@ -89,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) {
|
||||
@@ -298,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) {
|
||||
@@ -348,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 }) => {
|
||||
@@ -393,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;
|
||||
}
|
||||
@@ -546,8 +492,7 @@
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
onAction={handleAction}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
onRandom={handleRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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';
|
||||
@@ -96,8 +97,11 @@
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -228,8 +232,7 @@
|
||||
handleAction(action);
|
||||
assetCacheManager.invalidate();
|
||||
}}
|
||||
onPrevious={() => handleNavigateToAsset(assetCursor.previousAsset)}
|
||||
onNext={() => handleNavigateToAsset(assetCursor.nextAsset)}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
onRandom={handleRandom}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
@@ -44,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;
|
||||
};
|
||||
|
||||
@@ -222,8 +212,7 @@
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={assets.length > 1}
|
||||
{onNext}
|
||||
{onPrevious}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
{onRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
@@ -43,6 +43,8 @@ export type Events = {
|
||||
// confirmed permanently deleted from server
|
||||
UserAdminDeleted: [{ id: string }];
|
||||
|
||||
AssetViewerFree: [];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
LibraryCreate: [LibraryResponseDto];
|
||||
|
||||
@@ -5,7 +5,6 @@ import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
|
||||
|
||||
let viewingAssets: string[] = $state([]);
|
||||
let viewingAssetCursor = 0;
|
||||
|
||||
onDestroy(() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
@@ -36,27 +35,16 @@
|
||||
|
||||
async function onViewAssets(assetIds: string[]) {
|
||||
viewingAssets = assetIds;
|
||||
viewingAssetCursor = 0;
|
||||
await setAssetId(assetIds[0]);
|
||||
}
|
||||
|
||||
async function navigateNext() {
|
||||
if (viewingAssetCursor < viewingAssets.length - 1) {
|
||||
await setAssetId(viewingAssets[++viewingAssetCursor]);
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
return true;
|
||||
const handleNavigateToAsset = async (currentAsset: AssetResponseDto | undefined | null) => {
|
||||
if (!currentAsset) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigatePrevious() {
|
||||
if (viewingAssetCursor > 0) {
|
||||
await setAssetId(viewingAssets[--viewingAssetCursor]);
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
await navigate({ targetRoute: 'current', assetId: currentAsset.id });
|
||||
return true;
|
||||
};
|
||||
|
||||
async function navigateRandom() {
|
||||
if (viewingAssets.length <= 0) {
|
||||
@@ -138,13 +126,12 @@
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
{#if $showAssetViewer && assetCursor.current}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
onNext={navigateNext}
|
||||
onPrevious={navigatePrevious}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
onRandom={navigateRandom}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
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';
|
||||
@@ -22,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;
|
||||
};
|
||||
|
||||
@@ -142,9 +131,8 @@
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
onNavigateToAsset={handleNavigateToAsset}
|
||||
showNavigation={assets.length > 1}
|
||||
{onNext}
|
||||
{onPrevious}
|
||||
{onRandom}
|
||||
{onAction}
|
||||
onClose={() => {
|
||||
|
||||
Reference in New Issue
Block a user