mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 11:09:21 +03:00
feat: swipe feedback
refactor: replace onPreviousAsset/onNextAsset with onSwipe refactor: make InvocationTracker.invoke accept catch/finally callbacks
This commit is contained in:
@@ -37,7 +37,6 @@
|
|||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
@@ -186,53 +185,57 @@
|
|||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const tracker = new InvocationTracker();
|
const getNavigationTarget = () => {
|
||||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||||
if (!order) {
|
return $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
} else {
|
||||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
return 'skip';
|
||||||
} else {
|
}
|
||||||
return;
|
};
|
||||||
|
|
||||||
|
const completeNavigation = async (target: 'previous' | 'next') => {
|
||||||
|
preloadManager.cancelBeforeNavigation(target);
|
||||||
|
let hasNext: boolean;
|
||||||
|
|
||||||
|
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||||
|
hasNext = target === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||||
|
if (!hasNext) {
|
||||||
|
const asset = await onRandom?.();
|
||||||
|
if (asset) {
|
||||||
|
slideshowHistory.queue(asset);
|
||||||
|
hasNext = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
hasNext =
|
||||||
|
target === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadManager.cancelBeforeNavigation(order);
|
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||||
|
|
||||||
if (tracker.isActive()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void tracker.invoke(async () => {
|
if (hasNext) {
|
||||||
let hasNext: boolean;
|
$restartSlideshowProgress = true;
|
||||||
|
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||||
|
await setAssetId(slideshowStartAssetId);
|
||||||
|
$restartSlideshowProgress = true;
|
||||||
|
} else {
|
||||||
|
await handleStopSlideshow();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
const tracker = new InvocationTracker();
|
||||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
const navigateAsset = (target: 'previous' | 'next' | 'skip') => {
|
||||||
if (!hasNext) {
|
if (target === 'skip' || tracker.isActive()) {
|
||||||
const asset = await onRandom?.();
|
return;
|
||||||
if (asset) {
|
}
|
||||||
slideshowHistory.queue(asset);
|
|
||||||
hasNext = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hasNext =
|
|
||||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
void tracker.invoke(
|
||||||
return;
|
() => completeNavigation(target),
|
||||||
}
|
(error: unknown) => handleError(error, $t('error_while_navigating')),
|
||||||
|
() => eventManager.emit('ViewerFinishNavigate'),
|
||||||
if (hasNext) {
|
);
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
|
||||||
// Loop back to starting asset
|
|
||||||
await setAssetId(slideshowStartAssetId);
|
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
} else {
|
|
||||||
await handleStopSlideshow();
|
|
||||||
}
|
|
||||||
}, $t('error_while_navigating'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -419,24 +422,6 @@
|
|||||||
assetViewerManager.isShowDetailPanel &&
|
assetViewerManager.isShowDetailPanel &&
|
||||||
!assetViewerManager.isShowEditor,
|
!assetViewerManager.isShowEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSwipe = (event: SwipeCustomEvent) => {
|
|
||||||
if (assetViewerManager.zoom > 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ocrManager.showOverlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.detail.direction === 'left') {
|
|
||||||
navigateAsset('next');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.detail.direction === 'right') {
|
|
||||||
navigateAsset('previous');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||||
@@ -492,26 +477,26 @@
|
|||||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||||
{#if viewerKind === 'StackVideoViewer'}
|
{#if viewerKind === 'StackVideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
asset={previewStackedAsset!}
|
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||||
|
assetId={previewStackedAsset!.id}
|
||||||
cacheKey={previewStackedAsset!.thumbhash}
|
cacheKey={previewStackedAsset!.thumbhash}
|
||||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||||
loopVideo={true}
|
loopVideo={true}
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||||
onNextAsset={() => navigateAsset('next')}
|
|
||||||
onClose={closeViewer}
|
onClose={closeViewer}
|
||||||
onVideoEnded={() => navigateAsset()}
|
onVideoEnded={() => navigateAsset(getNavigationTarget())}
|
||||||
onVideoStarted={handleVideoStarted}
|
onVideoStarted={handleVideoStarted}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
/>
|
/>
|
||||||
{:else if viewerKind === 'LiveVideoViewer'}
|
{:else if viewerKind === 'LiveVideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
{asset}
|
{cursor}
|
||||||
assetId={asset.livePhotoVideoId!}
|
assetId={asset.livePhotoVideoId!}
|
||||||
|
{sharedLink}
|
||||||
cacheKey={asset.thumbhash}
|
cacheKey={asset.thumbhash}
|
||||||
projectionType={asset.exifInfo?.projectionType}
|
projectionType={asset.exifInfo?.projectionType}
|
||||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||||
onNextAsset={() => navigateAsset('next')}
|
|
||||||
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
|
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
/>
|
/>
|
||||||
@@ -520,17 +505,21 @@
|
|||||||
{:else if viewerKind === 'CropArea'}
|
{:else if viewerKind === 'CropArea'}
|
||||||
<CropArea {asset} />
|
<CropArea {asset} />
|
||||||
{:else if viewerKind === 'PhotoViewer'}
|
{:else if viewerKind === 'PhotoViewer'}
|
||||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
<PhotoViewer
|
||||||
|
cursor={{ ...cursor, current: asset }}
|
||||||
|
{sharedLink}
|
||||||
|
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||||
|
/>
|
||||||
{:else if viewerKind === 'VideoViewer'}
|
{:else if viewerKind === 'VideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
{asset}
|
{cursor}
|
||||||
|
{sharedLink}
|
||||||
cacheKey={asset.thumbhash}
|
cacheKey={asset.thumbhash}
|
||||||
projectionType={asset.exifInfo?.projectionType}
|
projectionType={asset.exifInfo?.projectionType}
|
||||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||||
onNextAsset={() => navigateAsset('next')}
|
|
||||||
onClose={closeViewer}
|
onClose={closeViewer}
|
||||||
onVideoEnded={() => navigateAsset()}
|
onVideoEnded={() => navigateAsset(getNavigationTarget())}
|
||||||
onVideoStarted={handleVideoStarted}
|
onVideoStarted={handleVideoStarted}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||||
|
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { toastManager } from '@immich/ui';
|
import { toastManager } from '@immich/ui';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { fromAction } from 'svelte/attachments';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { AssetCursor } from './asset-viewer.svelte';
|
import type { AssetCursor } from './asset-viewer.svelte';
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
sharedLink?: SharedLinkResponseDto;
|
sharedLink?: SharedLinkResponseDto;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
onSwipe?: (direction: 'left' | 'right') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||||
@@ -163,6 +164,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
|
|
||||||
|
let swipeFeedbackReset = $state<(() => void) | undefined>();
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
asset.id;
|
||||||
|
untrack(() => swipeFeedbackReset?.());
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -176,14 +184,17 @@
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<SwipeFeedback
|
||||||
bind:this={element}
|
bind:element
|
||||||
class="relative h-full w-full select-none"
|
class="relative h-full w-full select-none"
|
||||||
bind:clientWidth={containerWidth}
|
bind:clientWidth={containerWidth}
|
||||||
bind:clientHeight={containerHeight}
|
bind:clientHeight={containerHeight}
|
||||||
role="presentation"
|
disabled={!onSwipe || ocrManager.showOverlay || assetViewerManager.zoom > 1}
|
||||||
use:zoomImageAction={{ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }}
|
disableSwipeLeft={!cursor.nextAsset}
|
||||||
{...useSwipe((event) => onSwipe?.(event))}
|
disableSwipeRight={!cursor.previousAsset}
|
||||||
|
bind:reset={swipeFeedbackReset}
|
||||||
|
onSwipe={onSwipe ?? (() => {})}
|
||||||
|
{@attach fromAction(zoomImageAction, () => ({ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }))}
|
||||||
>
|
>
|
||||||
<AdaptiveImage
|
<AdaptiveImage
|
||||||
{asset}
|
{asset}
|
||||||
@@ -252,4 +263,30 @@
|
|||||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
{#snippet leftPreview()}
|
||||||
|
{#if cursor.previousAsset}
|
||||||
|
<AdaptiveImage
|
||||||
|
asset={cursor.previousAsset}
|
||||||
|
{sharedLink}
|
||||||
|
{container}
|
||||||
|
imageClass="object-contain"
|
||||||
|
slideshowState={$slideshowState}
|
||||||
|
slideshowLook={$slideshowLook}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet rightPreview()}
|
||||||
|
{#if cursor.nextAsset}
|
||||||
|
<AdaptiveImage
|
||||||
|
asset={cursor.nextAsset}
|
||||||
|
{sharedLink}
|
||||||
|
{container}
|
||||||
|
imageClass="object-contain"
|
||||||
|
slideshowState={$slideshowState}
|
||||||
|
slideshowLook={$slideshowLook}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SwipeFeedback>
|
||||||
|
|||||||
393
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
393
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
type SwipeProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
disableSwipeLeft?: boolean;
|
||||||
|
disableSwipeRight?: boolean;
|
||||||
|
onSwipeEnd?: (offsetX: number) => void;
|
||||||
|
onSwipeMove?: (offsetX: number) => void;
|
||||||
|
onSwipe: (direction: 'left' | 'right') => void;
|
||||||
|
element?: HTMLDivElement;
|
||||||
|
clientWidth?: number;
|
||||||
|
clientHeight?: number;
|
||||||
|
reset?: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
leftPreview?: Snippet;
|
||||||
|
rightPreview?: Snippet;
|
||||||
|
} & HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
disabled = false,
|
||||||
|
disableSwipeLeft = false,
|
||||||
|
disableSwipeRight = false,
|
||||||
|
onSwipeEnd,
|
||||||
|
onSwipeMove,
|
||||||
|
onSwipe,
|
||||||
|
element = $bindable(),
|
||||||
|
clientWidth = $bindable(),
|
||||||
|
clientHeight = $bindable(),
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
reset = $bindable(),
|
||||||
|
children,
|
||||||
|
leftPreview,
|
||||||
|
rightPreview,
|
||||||
|
...restProps
|
||||||
|
}: SwipeProps = $props();
|
||||||
|
|
||||||
|
interface SwipeAnimations {
|
||||||
|
currentImageAnimation: Animation;
|
||||||
|
previewAnimation: Animation | null;
|
||||||
|
abortController: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_DURATION_MS = 300;
|
||||||
|
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
|
||||||
|
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
|
||||||
|
const SWIPE_THRESHOLD = 45;
|
||||||
|
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
|
||||||
|
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
|
||||||
|
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
|
||||||
|
const MIN_PROGRESS_THRESHOLD = 0.25;
|
||||||
|
const ENABLE_SCALE_ANIMATION = false;
|
||||||
|
|
||||||
|
let contentElement: HTMLElement | undefined = $state();
|
||||||
|
let leftPreviewContainer: HTMLDivElement | undefined = $state();
|
||||||
|
let rightPreviewContainer: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let startX = $state(0);
|
||||||
|
let currentOffsetX = $state(0);
|
||||||
|
let dragStartTime: number | null = $state(null);
|
||||||
|
|
||||||
|
let leftAnimations: SwipeAnimations | null = $state(null);
|
||||||
|
let rightAnimations: SwipeAnimations | null = $state(null);
|
||||||
|
let isSwipeInProgress = $state(false);
|
||||||
|
|
||||||
|
const cursorStyle = $derived(disabled ? '' : isDragging ? 'grabbing' : 'grab');
|
||||||
|
|
||||||
|
const isValidPointerEvent = (event: PointerEvent) =>
|
||||||
|
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
|
||||||
|
|
||||||
|
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
|
||||||
|
if (!contentElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
contentElement.style.transformOrigin = 'center';
|
||||||
|
|
||||||
|
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
|
||||||
|
duration: ANIMATION_DURATION_MS,
|
||||||
|
easing: 'linear',
|
||||||
|
fill: 'both',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview slides in from opposite side of swipe direction
|
||||||
|
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
|
||||||
|
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();
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
return { currentImageAnimation, previewAnimation, abortController };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
|
||||||
|
animations.currentImageAnimation.currentTime = time;
|
||||||
|
if (animations.previewAnimation) {
|
||||||
|
animations.previewAnimation.currentTime = time;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
|
||||||
|
animations.currentImageAnimation.playbackRate = playbackRate;
|
||||||
|
if (animations.previewAnimation) {
|
||||||
|
animations.previewAnimation.playbackRate = playbackRate;
|
||||||
|
}
|
||||||
|
animations.currentImageAnimation.play();
|
||||||
|
animations.previewAnimation?.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAnimations = (animations: SwipeAnimations | null) => {
|
||||||
|
if (!animations) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animations.abortController.abort();
|
||||||
|
animations.currentImageAnimation.cancel();
|
||||||
|
animations.previewAnimation?.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startDrag(event);
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = (event: PointerEvent) => {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging = true;
|
||||||
|
startX = event.clientX;
|
||||||
|
currentOffsetX = 0;
|
||||||
|
|
||||||
|
element.setPointerCapture(event.pointerId);
|
||||||
|
dragStartTime = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOffsetX = event.clientX - startX;
|
||||||
|
const direction = rawOffsetX < 0 ? 'left' : 'right';
|
||||||
|
|
||||||
|
if ((direction === 'left' && disableSwipeLeft) || (direction === 'right' && disableSwipeRight)) {
|
||||||
|
currentOffsetX = 0;
|
||||||
|
cancelAnimations(leftAnimations);
|
||||||
|
cancelAnimations(rightAnimations);
|
||||||
|
leftAnimations = null;
|
||||||
|
rightAnimations = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOffsetX = rawOffsetX;
|
||||||
|
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
|
||||||
|
|
||||||
|
if (direction === 'left') {
|
||||||
|
if (!leftAnimations) {
|
||||||
|
leftAnimations = createSwipeAnimations('left');
|
||||||
|
}
|
||||||
|
if (leftAnimations) {
|
||||||
|
setAnimationTime(leftAnimations, animationTime);
|
||||||
|
}
|
||||||
|
if (rightAnimations) {
|
||||||
|
cancelAnimations(rightAnimations);
|
||||||
|
rightAnimations = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!rightAnimations) {
|
||||||
|
rightAnimations = createSwipeAnimations('right');
|
||||||
|
}
|
||||||
|
if (rightAnimations) {
|
||||||
|
setAnimationTime(rightAnimations, animationTime);
|
||||||
|
}
|
||||||
|
if (leftAnimations) {
|
||||||
|
cancelAnimations(leftAnimations);
|
||||||
|
leftAnimations = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSwipeMove?.(currentOffsetX);
|
||||||
|
event.preventDefault(); // Prevent scrolling during drag
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
|
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
if (element.hasPointerCapture(event.pointerId)) {
|
||||||
|
element.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
|
||||||
|
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
|
||||||
|
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(currentOffsetX) < SWIPE_THRESHOLD ||
|
||||||
|
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
|
||||||
|
) {
|
||||||
|
resetPosition();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSwipeInProgress = true;
|
||||||
|
|
||||||
|
onSwipeEnd?.(currentOffsetX);
|
||||||
|
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPosition = () => {
|
||||||
|
if (!contentElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = currentOffsetX < 0 ? 'left' : 'right';
|
||||||
|
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||||
|
|
||||||
|
if (!animations) {
|
||||||
|
currentOffsetX = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnimation(animations, -1);
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
cancelAnimations(animations);
|
||||||
|
if (direction === 'left') {
|
||||||
|
leftAnimations = null;
|
||||||
|
} else {
|
||||||
|
rightAnimations = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||||
|
signal: animations.abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentOffsetX = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeTransition = (direction: 'left' | 'right') => {
|
||||||
|
if (!contentElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||||
|
if (!animations) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
|
||||||
|
|
||||||
|
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
|
||||||
|
onSwipe(direction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playAnimation(animations, 1);
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
if (contentElement) {
|
||||||
|
onSwipe(direction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||||
|
signal: animations.abortController.signal,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPreviewContainers = () => {
|
||||||
|
cancelAnimations(leftAnimations);
|
||||||
|
cancelAnimations(rightAnimations);
|
||||||
|
leftAnimations = null;
|
||||||
|
rightAnimations = null;
|
||||||
|
|
||||||
|
if (contentElement) {
|
||||||
|
contentElement.style.transform = '';
|
||||||
|
contentElement.style.transition = '';
|
||||||
|
contentElement.style.opacity = '';
|
||||||
|
}
|
||||||
|
currentOffsetX = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishSwipeInProgress = () => {
|
||||||
|
isSwipeInProgress = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSwipeFeedback = () => {
|
||||||
|
resetPreviewContainers();
|
||||||
|
finishSwipeInProgress();
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
reset = resetSwipeFeedback;
|
||||||
|
onMount(() =>
|
||||||
|
eventManager.on({
|
||||||
|
ViewerFinishNavigate: finishSwipeInProgress,
|
||||||
|
ResetSwipeFeedback: resetSwipeFeedback,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
resetSwipeFeedback();
|
||||||
|
if (element) {
|
||||||
|
element.style.cursor = '';
|
||||||
|
}
|
||||||
|
if (contentElement) {
|
||||||
|
contentElement.style.cursor = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
|
||||||
|
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...restProps}
|
||||||
|
bind:this={element}
|
||||||
|
bind:clientWidth
|
||||||
|
bind:clientHeight
|
||||||
|
style:cursor={cursorStyle}
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
{#if leftPreview}
|
||||||
|
<!-- Swiping right reveals left preview -->
|
||||||
|
<div
|
||||||
|
bind:this={leftPreviewContainer}
|
||||||
|
class="absolute inset-0"
|
||||||
|
style:pointer-events="none"
|
||||||
|
style:display={rightAnimations ? 'block' : 'none'}
|
||||||
|
>
|
||||||
|
{@render leftPreview()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if rightPreview}
|
||||||
|
<!-- Swiping left reveals right preview -->
|
||||||
|
<div
|
||||||
|
bind:this={rightPreviewContainer}
|
||||||
|
class="absolute inset-0"
|
||||||
|
style:pointer-events="none"
|
||||||
|
style:display={leftAnimations ? 'block' : 'none'}
|
||||||
|
>
|
||||||
|
{@render rightPreview()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||||
|
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
|
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import { assetViewerFadeDuration } from '$lib/constants';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
@@ -10,56 +13,83 @@
|
|||||||
videoViewerMuted,
|
videoViewerMuted,
|
||||||
videoViewerVolume,
|
videoViewerVolume,
|
||||||
} from '$lib/stores/preferences.store';
|
} from '$lib/stores/preferences.store';
|
||||||
|
import { slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { scaleToFit } from '$lib/utils/container-utils';
|
||||||
|
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetId: string;
|
cursor: AssetCursor;
|
||||||
|
assetId?: string;
|
||||||
|
sharedLink?: SharedLinkResponseDto;
|
||||||
loopVideo: boolean;
|
loopVideo: boolean;
|
||||||
cacheKey: string | null;
|
cacheKey: string | null;
|
||||||
playOriginalVideo: boolean;
|
playOriginalVideo: boolean;
|
||||||
onPreviousAsset?: () => void;
|
onSwipe: (direction: 'left' | 'right') => void;
|
||||||
onNextAsset?: () => void;
|
|
||||||
onVideoEnded?: () => void;
|
onVideoEnded?: () => void;
|
||||||
onVideoStarted?: () => void;
|
onVideoStarted?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
cursor,
|
||||||
assetId,
|
assetId,
|
||||||
|
sharedLink,
|
||||||
loopVideo,
|
loopVideo,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
playOriginalVideo,
|
playOriginalVideo,
|
||||||
onPreviousAsset = () => {},
|
onSwipe,
|
||||||
onNextAsset = () => {},
|
|
||||||
onVideoEnded = () => {},
|
onVideoEnded = () => {},
|
||||||
onVideoStarted = () => {},
|
onVideoStarted = () => {},
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const asset = $derived(cursor.current);
|
||||||
|
const previousAsset = $derived(cursor.previousAsset);
|
||||||
|
const nextAsset = $derived(cursor.nextAsset);
|
||||||
|
const effectiveAssetId = $derived(assetId ?? asset.id);
|
||||||
|
|
||||||
|
const { slideshowState, slideshowLook } = slideshowStore;
|
||||||
|
|
||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let assetFileUrl = $derived(
|
let assetFileUrl = $derived(
|
||||||
playOriginalVideo
|
playOriginalVideo
|
||||||
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
|
? getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Original, cacheKey })
|
||||||
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
|
: getAssetPlaybackUrl({ id: effectiveAssetId, cacheKey }),
|
||||||
);
|
);
|
||||||
|
let previousAssetFileUrl = $state<string | undefined>();
|
||||||
let isScrubbing = $state(false);
|
let isScrubbing = $state(false);
|
||||||
let showVideo = $state(false);
|
let showVideo = $state(false);
|
||||||
|
|
||||||
|
let containerWidth = $state(document.documentElement.clientWidth);
|
||||||
|
let containerHeight = $state(document.documentElement.clientHeight);
|
||||||
|
|
||||||
|
const assetDimensions = $derived(
|
||||||
|
(asset.width ?? 0) > 0 && (asset.height ?? 0) > 0 ? { width: asset.width!, height: asset.height! } : null,
|
||||||
|
);
|
||||||
|
const container = $derived({
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
});
|
||||||
|
let dimensions = $derived(assetDimensions ?? { width: 1, height: 1 });
|
||||||
|
const scaledDimensions = $derived(scaleToFit(dimensions, container));
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Show video after mount to ensure fading in.
|
// Show video after mount to ensure fading in.
|
||||||
showVideo = true;
|
showVideo = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// reactive on `assetFileUrl` changes
|
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) {
|
||||||
if (assetFileUrl) {
|
previousAssetFileUrl = assetFileUrl;
|
||||||
videoPlayer?.load();
|
untrack(() => {
|
||||||
|
isLoading = true;
|
||||||
|
videoPlayer?.load();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,6 +99,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
dimensions = {
|
||||||
|
width: videoPlayer?.videoWidth ?? 1,
|
||||||
|
height: videoPlayer?.videoHeight ?? 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||||
try {
|
try {
|
||||||
if (!video.paused && !isScrubbing) {
|
if (!video.paused && !isScrubbing) {
|
||||||
@@ -100,76 +137,116 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSwipe = (event: SwipeCustomEvent) => {
|
|
||||||
if (event.detail.direction === 'left') {
|
|
||||||
onNextAsset();
|
|
||||||
}
|
|
||||||
if (event.detail.direction === 'right') {
|
|
||||||
onPreviousAsset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let containerWidth = $state(0);
|
|
||||||
let containerHeight = $state(0);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isFaceEditMode.value) {
|
if (isFaceEditMode.value) {
|
||||||
videoPlayer?.pause();
|
videoPlayer?.pause();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const calculateSize = () => {
|
||||||
|
const { width, height } = scaledDimensions;
|
||||||
|
|
||||||
|
const size = {
|
||||||
|
width: width + 'px',
|
||||||
|
height: height + 'px',
|
||||||
|
};
|
||||||
|
|
||||||
|
return size;
|
||||||
|
};
|
||||||
|
|
||||||
|
const box = $derived(calculateSize());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showVideo}
|
<SwipeFeedback
|
||||||
<div
|
class="flex select-none h-full w-full place-content-center place-items-center"
|
||||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
bind:clientWidth={containerWidth}
|
||||||
class="flex h-full select-none place-content-center place-items-center"
|
bind:clientHeight={containerHeight}
|
||||||
bind:clientWidth={containerWidth}
|
{onSwipe}
|
||||||
bind:clientHeight={containerHeight}
|
>
|
||||||
>
|
{#if showVideo}
|
||||||
{#if castManager.isCasting}
|
<div
|
||||||
<div class="place-content-center h-full place-items-center">
|
in:fade={{ duration: assetViewerFadeDuration }}
|
||||||
<VideoRemoteViewer
|
class="flex h-full w-full place-content-center place-items-center"
|
||||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
>
|
||||||
{onVideoStarted}
|
{#if castManager.isCasting}
|
||||||
{onVideoEnded}
|
<div class="place-content-center h-full place-items-center">
|
||||||
{assetFileUrl}
|
<VideoRemoteViewer
|
||||||
/>
|
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||||
</div>
|
{onVideoStarted}
|
||||||
{:else}
|
{onVideoEnded}
|
||||||
<video
|
{assetFileUrl}
|
||||||
bind:this={videoPlayer}
|
/>
|
||||||
loop={$loopVideoPreference && loopVideo}
|
</div>
|
||||||
autoplay={$autoPlayVideo}
|
{:else}
|
||||||
playsinline
|
<div class="relative">
|
||||||
controls
|
<video
|
||||||
disablePictureInPicture
|
style:height={box.height}
|
||||||
class="h-full object-contain"
|
style:width={box.width}
|
||||||
{...useSwipe(onSwipe)}
|
bind:this={videoPlayer}
|
||||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
loop={$loopVideoPreference && loopVideo}
|
||||||
onended={onVideoEnded}
|
autoplay={$autoPlayVideo}
|
||||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
playsinline
|
||||||
onseeking={() => (isScrubbing = true)}
|
controls
|
||||||
onseeked={() => (isScrubbing = false)}
|
disablePictureInPicture
|
||||||
onplaying={(e) => {
|
onloadedmetadata={() => handleLoadedMetadata()}
|
||||||
e.currentTarget.focus();
|
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||||
}}
|
onended={onVideoEnded}
|
||||||
onclose={() => onClose()}
|
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||||
muted={$videoViewerMuted}
|
onseeking={() => (isScrubbing = true)}
|
||||||
bind:volume={$videoViewerVolume}
|
onseeked={() => (isScrubbing = false)}
|
||||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
onplaying={(e) => {
|
||||||
src={assetFileUrl}
|
e.currentTarget.focus();
|
||||||
>
|
}}
|
||||||
</video>
|
onclose={() => onClose()}
|
||||||
|
muted={$videoViewerMuted}
|
||||||
|
bind:volume={$videoViewerVolume}
|
||||||
|
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||||
|
src={assetFileUrl}
|
||||||
|
>
|
||||||
|
</video>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="absolute flex place-content-center place-items-center">
|
<div class="absolute inset-0 flex place-content-center place-items-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isFaceEditMode.value}
|
||||||
|
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} assetId={effectiveAssetId} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{#if isFaceEditMode.value}
|
{/if}
|
||||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
{#snippet leftPreview()}
|
||||||
{/if}
|
{#if previousAsset}
|
||||||
|
<AdaptiveImage
|
||||||
|
asset={previousAsset}
|
||||||
|
{sharedLink}
|
||||||
|
container={{ width: containerWidth, height: containerHeight }}
|
||||||
|
imageClass="object-contain"
|
||||||
|
slideshowState={$slideshowState}
|
||||||
|
slideshowLook={$slideshowLook}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/snippet}
|
||||||
{/if}
|
|
||||||
|
{#snippet rightPreview()}
|
||||||
|
{#if nextAsset}
|
||||||
|
<AdaptiveImage
|
||||||
|
asset={nextAsset}
|
||||||
|
{sharedLink}
|
||||||
|
container={{ width: containerWidth, height: containerHeight }}
|
||||||
|
imageClass="object-contain"
|
||||||
|
slideshowState={$slideshowState}
|
||||||
|
slideshowLook={$slideshowLook}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SwipeFeedback>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
video:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,50 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||||
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
||||||
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
|
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
|
||||||
import { ProjectionType } from '$lib/constants';
|
import { ProjectionType } from '$lib/constants';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { SharedLinkResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
cursor: AssetCursor;
|
||||||
assetId?: string;
|
assetId?: string;
|
||||||
|
sharedLink?: SharedLinkResponseDto;
|
||||||
projectionType: string | null | undefined;
|
projectionType: string | null | undefined;
|
||||||
cacheKey: string | null;
|
cacheKey: string | null;
|
||||||
loopVideo: boolean;
|
loopVideo: boolean;
|
||||||
playOriginalVideo: boolean;
|
playOriginalVideo: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onPreviousAsset?: () => void;
|
onSwipe: (direction: 'left' | 'right') => void;
|
||||||
onNextAsset?: () => void;
|
|
||||||
onVideoEnded?: () => void;
|
onVideoEnded?: () => void;
|
||||||
onVideoStarted?: () => void;
|
onVideoStarted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
asset,
|
cursor,
|
||||||
assetId,
|
assetId,
|
||||||
|
sharedLink,
|
||||||
projectionType,
|
projectionType,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
loopVideo,
|
loopVideo,
|
||||||
playOriginalVideo,
|
playOriginalVideo,
|
||||||
onPreviousAsset,
|
onSwipe,
|
||||||
onClose,
|
onClose,
|
||||||
onNextAsset,
|
|
||||||
onVideoEnded,
|
onVideoEnded,
|
||||||
onVideoStarted,
|
onVideoStarted,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const effectiveAssetId = $derived(assetId ?? asset.id);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||||
<VideoPanoramaViewer {asset} />
|
<VideoPanoramaViewer asset={cursor.current} />
|
||||||
{:else}
|
{:else}
|
||||||
<VideoNativeViewer
|
<VideoNativeViewer
|
||||||
{loopVideo}
|
{loopVideo}
|
||||||
{cacheKey}
|
{cacheKey}
|
||||||
assetId={effectiveAssetId}
|
{cursor}
|
||||||
|
{assetId}
|
||||||
|
{sharedLink}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
{onPreviousAsset}
|
{onSwipe}
|
||||||
{onNextAsset}
|
|
||||||
{onVideoEnded}
|
{onVideoEnded}
|
||||||
{onVideoStarted}
|
{onVideoStarted}
|
||||||
{onClose}
|
{onClose}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import type {
|
|||||||
export type Events = {
|
export type Events = {
|
||||||
AppInit: [];
|
AppInit: [];
|
||||||
|
|
||||||
|
ResetSwipeFeedback: [];
|
||||||
|
ViewerFinishNavigate: [];
|
||||||
|
|
||||||
AuthLogin: [LoginResponseDto];
|
AuthLogin: [LoginResponseDto];
|
||||||
AuthLogout: [];
|
AuthLogout: [];
|
||||||
AuthUserLoaded: [UserAdminResponseDto];
|
AuthUserLoaded: [UserAdminResponseDto];
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
||||||
* This class helps manage concurrent operations by tracking which invocations are active
|
* This class helps manage concurrent operations by tracking which invocations are active
|
||||||
@@ -53,14 +51,19 @@ export class InvocationTracker {
|
|||||||
return this.invocationsStarted !== this.invocationsEnded;
|
return this.invocationsStarted !== this.invocationsEnded;
|
||||||
}
|
}
|
||||||
|
|
||||||
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
|
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
|
||||||
const invocation = this.startInvocation();
|
const invocation = this.startInvocation();
|
||||||
try {
|
try {
|
||||||
return await invocable();
|
return await invocable();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleError(error, localizedMessage);
|
if (catchCallback) {
|
||||||
|
catchCallback(error);
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
invocation.endInvocation();
|
invocation.endInvocation();
|
||||||
|
finallyCallback?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user