mirror of
https://github.com/immich-app/immich.git
synced 2025-12-12 07:41:02 -08:00
feat: view transitions
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
@@ -19,6 +20,7 @@
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { resetZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
@@ -40,7 +42,7 @@
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import ActivityStatus from './activity-status.svelte';
|
||||
import ActivityViewer from './activity-viewer.svelte';
|
||||
@@ -89,7 +91,7 @@
|
||||
copyImage = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId } = assetViewingStore;
|
||||
const { setAssetId, invisible } = assetViewingStore;
|
||||
const {
|
||||
restartProgress: restartSlideshowProgress,
|
||||
stopProgress: stopSlideshowProgress,
|
||||
@@ -157,7 +159,24 @@
|
||||
}
|
||||
};
|
||||
|
||||
let transitionName = $state<string | null>('hero');
|
||||
let equirectangularTransitionName = $state<string | null>('hero');
|
||||
let detailPanelTransitionName = $state<string | null>(null);
|
||||
|
||||
let addInfoTransition;
|
||||
let finished;
|
||||
onMount(async () => {
|
||||
addInfoTransition = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
};
|
||||
eventManager.on('TransitionToAssetViewer', addInfoTransition);
|
||||
eventManager.on('TransitionToTimeline', addInfoTransition);
|
||||
finished = () => {
|
||||
detailPanelTransitionName = null;
|
||||
transitionName = null;
|
||||
};
|
||||
eventManager.on('Finished', finished);
|
||||
// eventManager.emit('AssetViewerLoaded');
|
||||
unsubscribes.push(
|
||||
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
|
||||
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
|
||||
@@ -199,6 +218,9 @@
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
eventManager.off('TransitionToAssetViewer', addInfoTransition!);
|
||||
eventManager.off('TransitionToTimeline', addInfoTransition!);
|
||||
eventManager.off('Finished', finished!);
|
||||
});
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
@@ -226,6 +248,7 @@
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
transitionName = 'hero';
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
@@ -235,6 +258,23 @@
|
||||
});
|
||||
};
|
||||
|
||||
const startTransition = (targetTransition: string | null, targetAsset?: AssetResponseDto) => {
|
||||
transitionName = targetTransition;
|
||||
equirectangularTransitionName = targetTransition;
|
||||
detailPanelTransitionName = 'onTop';
|
||||
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('StartViewTransition', () => {
|
||||
if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) {
|
||||
equirectangularTransitionName = null;
|
||||
}
|
||||
});
|
||||
eventManager.once('AssetViewerFree', () => resolve());
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
@@ -247,7 +287,6 @@
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
preloadManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
@@ -256,6 +295,7 @@
|
||||
let hasNext = false;
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
startTransition(null, undefined);
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
@@ -265,6 +305,13 @@
|
||||
}
|
||||
}
|
||||
} else if (onNavigateToAsset) {
|
||||
// only transition if the target is already preloaded, and is in a secure context
|
||||
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||
if (!!targetAsset && globalThis.isSecureContext && preloadManager.isPreloaded(targetAsset)) {
|
||||
const targetTransition = $slideshowState === SlideshowState.PlaySlideshow ? null : order;
|
||||
startTransition(targetTransition, targetAsset);
|
||||
}
|
||||
resetZoomState();
|
||||
hasNext = order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(nextAsset);
|
||||
} else {
|
||||
hasNext = false;
|
||||
@@ -421,11 +468,18 @@
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset.id;
|
||||
if (viewerKind !== 'PhotoViewer') {
|
||||
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
|
||||
eventManager.emit('AssetViewerFree');
|
||||
}
|
||||
});
|
||||
|
||||
const isEquirectangular = (asset: AssetResponseDto) => {
|
||||
return (
|
||||
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
|
||||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
|
||||
);
|
||||
};
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||
@@ -433,10 +487,7 @@
|
||||
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'))
|
||||
) {
|
||||
} else if (isEquirectangular(asset)) {
|
||||
return 'ImagePanaramaViewer';
|
||||
} else if (isShowEditor && selectedEditType === 'crop') {
|
||||
return 'CropArea';
|
||||
@@ -454,12 +505,16 @@
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
class:invisible={$invisible}
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !isShowEditor}
|
||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<div
|
||||
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
@@ -508,11 +563,12 @@
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full items-center flex">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{transitionName}
|
||||
asset={previewStackedAsset!}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
@@ -521,6 +577,7 @@
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
assetId={previewStackedAsset!.id}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
@@ -534,6 +591,7 @@
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
@@ -544,11 +602,12 @@
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer bind:zoomToggle {asset} />
|
||||
<ImagePanoramaViewer bind:zoomToggle {asset} transitionName={equirectangularTransitionName} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{transitionName}
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{asset}
|
||||
@@ -560,6 +619,7 @@
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
assetId={asset.id}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
@@ -601,8 +661,9 @@
|
||||
|
||||
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
id="detail-panel"
|
||||
style:view-transition-name={detailPanelTransitionName}
|
||||
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string | null;
|
||||
asset: AssetResponseDto;
|
||||
zoomToggle?: (() => void) | null;
|
||||
};
|
||||
|
||||
let { asset, zoomToggle = $bindable() }: Props = $props();
|
||||
let { transitionName, asset, zoomToggle = $bindable() }: Props = $props();
|
||||
|
||||
const loadAssetData = async (id: string) => {
|
||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
||||
@@ -20,11 +21,12 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-dvh w-dvw select-none place-content-center place-items-center">
|
||||
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
bind:zoomToggle
|
||||
panorama={data}
|
||||
originalPanorama={isWebCompatibleImage(asset)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
@@ -27,6 +28,7 @@
|
||||
};
|
||||
|
||||
type Props = {
|
||||
transitionName?: string | null;
|
||||
panorama: string | { source: string };
|
||||
originalPanorama?: string | { source: string };
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
@@ -36,6 +38,7 @@
|
||||
};
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
panorama,
|
||||
originalPanorama,
|
||||
adapter = EquirectangularAdapter,
|
||||
@@ -154,6 +157,13 @@
|
||||
zoomSpeed: 0.5,
|
||||
fisheye: false,
|
||||
});
|
||||
viewer.addEventListener(
|
||||
'ready',
|
||||
() => {
|
||||
eventManager.emit('AssetViewerFree');
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel range: [0, 100]
|
||||
@@ -190,4 +200,9 @@
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} />
|
||||
<div class="h-full w-full mb-0" bind:this={container}></div>
|
||||
<div
|
||||
id="sphere"
|
||||
class="h-full w-full h-dvh w-dvw mb-0"
|
||||
bind:this={container}
|
||||
style:view-transition-name={transitionName}
|
||||
></div>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
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';
|
||||
@@ -12,7 +11,7 @@
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { photoZoomState, resetZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -25,9 +24,9 @@
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
asset: AssetResponseDto;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
@@ -43,6 +42,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
asset,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
@@ -58,25 +58,46 @@
|
||||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
|
||||
haveFadeTransition = true;
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
resetZoomState();
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
const calculateSize = () => {
|
||||
// Recalculate size when image is loaded/errored
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
imageLoaded || imageError;
|
||||
|
||||
const naturalWidth = loader?.naturalWidth ?? 1;
|
||||
const naturalHeight = loader?.naturalHeight ?? 1;
|
||||
|
||||
const scaleX = containerWidth / naturalWidth;
|
||||
const scaleY = containerHeight / naturalHeight;
|
||||
|
||||
// Use the smaller scale to ensure image fits (like object-fit: contain)
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
const scaledWidth = naturalWidth * scale;
|
||||
const scaledHeight = naturalHeight * scale;
|
||||
|
||||
return {
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
left: (containerWidth - scaledWidth) / 2,
|
||||
top: (containerHeight - scaledHeight) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const box = $derived(calculateSize());
|
||||
|
||||
let ocrBoxes = $derived(
|
||||
ocrManager.showOverlay && $photoViewerImgElement
|
||||
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
|
||||
@@ -225,7 +246,7 @@
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative h-full select-none"
|
||||
class="absolute h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
@@ -234,29 +255,34 @@
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
style:width={box.width + 'px'}
|
||||
style:height={box.height + 'px'}
|
||||
style:left={box.left + 'px'}
|
||||
style:top={box.top + 'px'}
|
||||
class="absolute"
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
style:view-transition-name={transitionName}
|
||||
bind:this={$photoViewerImgElement}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
class="w-full h-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
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
assetId: string;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
@@ -30,6 +31,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
assetId,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
@@ -115,12 +117,30 @@
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
const calculateSize = () => {
|
||||
const videoWidth = videoPlayer?.videoWidth ?? 1;
|
||||
const videoHeight = videoPlayer?.videoHeight ?? 1;
|
||||
|
||||
const scaleX = containerWidth / videoWidth;
|
||||
const scaleY = containerHeight / videoHeight;
|
||||
|
||||
// Use the smaller scale to ensure image fits (like object-fit: contain)
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
return {
|
||||
width: videoWidth * scale + 'px',
|
||||
height: videoHeight * scale + 'px',
|
||||
};
|
||||
};
|
||||
|
||||
let box = $derived(calculateSize());
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div
|
||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
class="flex select-none h-full w-full place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
@@ -135,14 +155,17 @@
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
style:view-transition-name={transitionName}
|
||||
style:height={box.height}
|
||||
style:width={box.width}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
onloadedmetadata={() => (box = calculateSize())}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
@@ -171,3 +194,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
video:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
assetId: string;
|
||||
}
|
||||
|
||||
const { assetId }: Props = $props();
|
||||
const { assetId, transitionName }: Props = $props();
|
||||
|
||||
const modules = Promise.all([
|
||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||
@@ -23,6 +24,7 @@
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
panorama={{ source: getAssetPlaybackUrl(assetId) }}
|
||||
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
|
||||
plugins={[videoPlugin]}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
assetId: string;
|
||||
projectionType: string | null | undefined;
|
||||
cacheKey: string | null;
|
||||
@@ -17,6 +18,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
assetId,
|
||||
projectionType,
|
||||
cacheKey,
|
||||
@@ -31,9 +33,10 @@
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {assetId} />
|
||||
<VideoPanoramaViewer {assetId} {transitionName} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{transitionName}
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
{assetId}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
|
||||
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { timeToSeconds } from '$lib/utils/date-time';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiCameraBurst,
|
||||
@@ -15,21 +23,11 @@
|
||||
mdiMotionPlayOutline,
|
||||
mdiRotate360,
|
||||
} from '@mdi/js';
|
||||
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImageThumbnail from './image-thumbnail.svelte';
|
||||
import VideoThumbnail from './video-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
groupIndex?: number;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import type { AppState } from '../../../routes/+layout.svelte';
|
||||
|
||||
interface Props {
|
||||
hideNavbar?: boolean;
|
||||
@@ -37,13 +38,17 @@
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
const appState = getContext('AppState') as AppState;
|
||||
let isAssetViewer = $derived(appState.isAssetViewer);
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
{#if !hideNavbar && !isAssetViewer}
|
||||
<NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{#if isAssetViewer}
|
||||
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||
{/if}
|
||||
{@render header?.()}
|
||||
</header>
|
||||
<div
|
||||
@@ -53,13 +58,15 @@
|
||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||
>
|
||||
{#if sidebar}
|
||||
{#if isAssetViewer}
|
||||
<div></div>
|
||||
{:else if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<UserSidebar />
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<main class="relative w-full">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
type Props = {
|
||||
animationTargetAssetId?: string | null;
|
||||
viewerAssets: ViewerAsset[];
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -26,10 +27,11 @@
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
};
|
||||
|
||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
const { animationTargetAssetId, viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props =
|
||||
$props();
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
// const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
|
||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
||||
return intersectables.filter(({ intersecting }) => intersecting);
|
||||
@@ -41,16 +43,18 @@
|
||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{@const transitionName =
|
||||
animationTargetAssetId === asset.id && !mobileDevice.prefersReducedMotion ? 'hero' : undefined}
|
||||
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionName}
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
{@render thumbnail({ asset, position })}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
@@ -11,9 +13,10 @@
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onDestroy, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
toAssetViewerTransitionId?: string | null;
|
||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
singleSelect: boolean;
|
||||
@@ -23,6 +26,7 @@
|
||||
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||
};
|
||||
let {
|
||||
toAssetViewerTransitionId,
|
||||
thumbnail: thumbnailWithGroup,
|
||||
customThumbnailLayout,
|
||||
singleSelect,
|
||||
@@ -51,6 +55,32 @@
|
||||
});
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
|
||||
let toTimelineTransitionAssetId = $state<string | null>(null);
|
||||
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
|
||||
|
||||
const transitionToTimelineCallback = ({ id }: { id: string }) => {
|
||||
const asset = monthGroup.findAssetById({ id });
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('TimelineLoaded', ({ id }) => {
|
||||
animationTargetAssetId = id;
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
() => {
|
||||
animationTargetAssetId = null;
|
||||
},
|
||||
);
|
||||
};
|
||||
eventManager.on('TransitionToTimeline', transitionToTimelineCallback);
|
||||
onDestroy(() => {
|
||||
eventManager.off('TransitionToTimeline', transitionToTimelineCallback);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
@@ -95,6 +125,7 @@
|
||||
</div>
|
||||
|
||||
<AssetLayout
|
||||
{animationTargetAssetId}
|
||||
{manager}
|
||||
viewerAssets={dayGroup.viewerAssets}
|
||||
height={dayGroup.height}
|
||||
@@ -112,4 +143,188 @@
|
||||
section {
|
||||
contain: layout paint style;
|
||||
}
|
||||
|
||||
:global(::view-transition) {
|
||||
background: black;
|
||||
animation-duration: 500ms;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(*)),
|
||||
:global(::view-transition-new(*)) {
|
||||
mix-blend-mode: normal;
|
||||
animation-duration: inherit;
|
||||
animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
|
||||
:global(::view-transition-old(*)) {
|
||||
animation-name: fadeOut forwards;
|
||||
}
|
||||
:global(::view-transition-new(*)) {
|
||||
animation-name: fadeIn forwards;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(slideshow)) {
|
||||
animation: 500ms 0s fadeOut forwards;
|
||||
}
|
||||
|
||||
:global(::view-transition-new(slideshow)) {
|
||||
animation: 500ms 0s fadeIn forwards;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(root)) {
|
||||
animation: 500ms 0s fadeOut forwards;
|
||||
animation-timing-function: inherit;
|
||||
}
|
||||
:global(::view-transition-new(root)) {
|
||||
animation: 500ms 0s fadeIn forwards;
|
||||
animation-timing-function: inherit;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(info)) {
|
||||
animation: 250ms 0s flyOutRight forwards;
|
||||
animation-timing-function: inherit;
|
||||
}
|
||||
:global(::view-transition-new(info)) {
|
||||
animation: 250ms 0s flyInRight forwards;
|
||||
animation-timing-function: inherit;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(onTop)),
|
||||
:global(::view-transition-new(onTop)) {
|
||||
z-index: 100;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(hero)) {
|
||||
animation: 350ms fadeOut forwards;
|
||||
align-content: center;
|
||||
}
|
||||
:global(::view-transition-new(hero)) {
|
||||
animation: 350ms fadeIn forwards;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
:global(::view-transition-new(exclude)) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(next)) {
|
||||
animation: 500ms flyOutLeft forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(::view-transition-new(next)) {
|
||||
animation: 500ms flyInRight forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(previous)) {
|
||||
animation: 500ms flyOutRight forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(::view-transition-new(previous)) {
|
||||
animation: 500ms flyInLeft forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
:global(::view-transition-new(navbar)) {
|
||||
z-index: 100;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
:global(::view-transition-group(previous)),
|
||||
:global(::view-transition-group(next)) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
:global(::view-transition-old(previous)),
|
||||
:global(::view-transition-old(next)) {
|
||||
animation: 500ms fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(::view-transition-new(previous)),
|
||||
:global(::view-transition-new(next)) {
|
||||
animation: 500ms fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
@keyframes -global-flyInLeft {
|
||||
from {
|
||||
transform: translateX(-100vw) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes -global-flyOutLeft {
|
||||
from {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-100vw) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes -global-flyInRight {
|
||||
from {
|
||||
transform: translateX(100vw) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fly out to right */
|
||||
@keyframes -global-flyOutRight {
|
||||
from {
|
||||
transform: translateX(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100vw) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes -global-fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes -global-fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
invisible: boolean;
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
@@ -39,6 +40,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
invisible = false,
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
@@ -437,7 +439,7 @@
|
||||
next = forward
|
||||
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||
: (focusable[(index - 1) % focusable.length] as HTMLElement);
|
||||
next.focus();
|
||||
next?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +510,7 @@
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||
class:invisible
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
style:width
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
@@ -25,9 +27,8 @@
|
||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
interface Props {
|
||||
isSelectionMode?: boolean;
|
||||
singleSelect?: boolean;
|
||||
@@ -111,6 +112,7 @@
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
let toAssetViewerTransitionId = $state<string | null>(null);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mobileDevice.maxMd);
|
||||
@@ -218,7 +220,7 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
const scrollTarget = getScrollTarget();
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
@@ -227,7 +229,9 @@
|
||||
// if the asset is not found, scroll to the top
|
||||
timelineManager.scrollTo(0);
|
||||
}
|
||||
invisible = false;
|
||||
if (!isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -245,10 +249,13 @@
|
||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
||||
});
|
||||
|
||||
const getScrollTarget = () => {
|
||||
return $gridScrollTarget?.at ?? page.params.assetId ?? null;
|
||||
};
|
||||
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||
// after successful navigation.
|
||||
afterNavigate(({ complete }) => {
|
||||
void complete.finally(() => {
|
||||
void complete.finally(async () => {
|
||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||
|
||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||
@@ -257,8 +264,13 @@
|
||||
if (isDirectNavigation) {
|
||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
}
|
||||
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,7 +280,7 @@
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -609,6 +621,7 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -688,6 +701,7 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toAssetViewerTransitionId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
@@ -707,6 +721,24 @@
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
// tag target on the 'old' snapshot
|
||||
toAssetViewerTransitionId = asset.id;
|
||||
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise((resolve) =>
|
||||
eventManager.once('AssetViewerFree', () => {
|
||||
eventManager.emit('TransitionToAssetViewer');
|
||||
resolve();
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
eventManager.once('StartViewTransition', () => {
|
||||
// remove target on the 'old' view,
|
||||
// asset-viewer will tag new target element for 'new' snapshot
|
||||
toAssetViewerTransitionId = null;
|
||||
});
|
||||
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
@@ -8,7 +9,7 @@
|
||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||
@@ -43,7 +44,7 @@
|
||||
const getNextAsset = async (currentAsset: AssetResponseDto) => {
|
||||
const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset);
|
||||
if (earlierTimelineAsset) {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: earlierTimelineAsset.id });
|
||||
const asset = assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id });
|
||||
return asset;
|
||||
}
|
||||
};
|
||||
@@ -51,7 +52,7 @@
|
||||
const getPreviousAsset = async (currentAsset: AssetResponseDto) => {
|
||||
const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset);
|
||||
if (laterTimelineAsset) {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: laterTimelineAsset.id });
|
||||
const asset = assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id });
|
||||
return asset;
|
||||
}
|
||||
};
|
||||
@@ -103,6 +104,10 @@
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
const awaitInit = new Promise<void>((resolve) => eventManager.once('StartViewTransition', resolve));
|
||||
eventManager.emit('TransitionToTimeline', { id: asset.id });
|
||||
await awaitInit;
|
||||
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
invisible = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAssetUrl } from '$lib/utils';
|
||||
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { cancelImageUrl, isImageUrlCached, preloadImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
class PreloadManager {
|
||||
@@ -17,6 +17,16 @@ class PreloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
isPreloaded(asset: AssetResponseDto | undefined) {
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
if (globalThis.isSecureContext) {
|
||||
return isImageUrlCached(getAssetUrl({ asset }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined) {
|
||||
if (!globalThis.isSecureContext || !asset) {
|
||||
return;
|
||||
|
||||
41
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
41
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
|
||||
class ViewTransitionManager {
|
||||
startTransition(domUpdateComplete: Promise<void>, finishedCallback?: () => void) {
|
||||
// good time to add view-transition-name styles (if needed)
|
||||
eventManager.emit('BeforeStartViewTransition');
|
||||
// next call will create the 'old' view snapshot
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
const transition = document.startViewTransition(async () => {
|
||||
try {
|
||||
// Good time to remove any view-transition-name styles created during
|
||||
// BeforeStartViewTransition, then trigger the actual view transition.
|
||||
eventManager.emit('StartViewTransition');
|
||||
await domUpdateComplete;
|
||||
} catch (error: unknown) {
|
||||
console.log('exception', error);
|
||||
}
|
||||
});
|
||||
// UpdateCallbackDone is a good time to add any view-transition-name styles
|
||||
// to the new DOM state, before the 'new' view snapshot is creatd
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.updateCallbackDone
|
||||
.then(() => eventManager.emit('UpdateCallbackDone'))
|
||||
.catch((error: unknown) => console.log('exception', error));
|
||||
// Both old/new snapshots are taken - pseudo elements are created, transition is
|
||||
// about to start
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.ready
|
||||
.then(() => eventManager.emit('Ready'))
|
||||
.catch((error: unknown) => console.log('exception in ready', error));
|
||||
// Transition is complete
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.finished
|
||||
.then(() => eventManager.emit('Finished'))
|
||||
.catch((error: unknown) => console.log('exception in finished', error));
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.finished.then(() => finishedCallback?.());
|
||||
}
|
||||
}
|
||||
|
||||
export const viewTransitionManager = new ViewTransitionManager();
|
||||
@@ -44,6 +44,20 @@ export type Events = {
|
||||
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
|
||||
TransitionToTimeline: [{ id: string }];
|
||||
TimelineLoaded: [{ id: string | null }];
|
||||
|
||||
TransitionToAssetViewer: [];
|
||||
AssetViewerLoaded: [];
|
||||
|
||||
RenderLoaded: [];
|
||||
|
||||
BeforeStartViewTransition: [];
|
||||
Finished: [];
|
||||
Ready: [];
|
||||
UpdateCallbackDone: [];
|
||||
StartViewTransition: [];
|
||||
|
||||
AssetViewerFree: [];
|
||||
};
|
||||
|
||||
@@ -57,11 +71,11 @@ class EventManager<EventMap extends Record<string, unknown[]>> {
|
||||
}[];
|
||||
} = {};
|
||||
|
||||
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
||||
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
|
||||
return this.addListener(key, listener, false);
|
||||
}
|
||||
|
||||
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
||||
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
|
||||
return this.addListener(key, listener, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
const invisible = writable<boolean>(false);
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
@@ -30,6 +31,7 @@ function createAssetViewingStore() {
|
||||
setAsset,
|
||||
setAssetId,
|
||||
showAssetViewer,
|
||||
invisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
|
||||
const pointerCoarse = new MediaQuery('pointer:coarse');
|
||||
const reducedMotion = new MediaQuery('prefers-reduced-motion');
|
||||
const maxMd = new MediaQuery('max-width: 767px');
|
||||
const sidebar = new MediaQuery(`min-width: 850px`);
|
||||
|
||||
@@ -14,4 +15,7 @@ export const mobileDevice = {
|
||||
get isFullSidebar() {
|
||||
return sidebar.current;
|
||||
},
|
||||
get prefersReducedMotion() {
|
||||
return reducedMotion.current;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
|
||||
export type OcrBoundingBox = {
|
||||
id: string;
|
||||
@@ -38,7 +38,7 @@ class OcrManager {
|
||||
this.#cleared = false;
|
||||
}
|
||||
await this.#ocrLoader.execute(async () => {
|
||||
this.#data = await getAssetOcr({ id });
|
||||
this.#data = await assetCacheManager.getAssetOcr(id);
|
||||
}, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,3 +2,13 @@ import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoZoomState = writable<ZoomImageWheelState>();
|
||||
|
||||
export const resetZoomState = () => {
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:display-none={$showAssetViewer}>
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<UploadCover />
|
||||
@@ -33,7 +33,4 @@
|
||||
:root {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
<script lang="ts" module>
|
||||
export class AppState {
|
||||
#isAssetViewer = $state<boolean>(false);
|
||||
set isAssetViewer(value) {
|
||||
this.#isAssetViewer = value;
|
||||
}
|
||||
get isAssetViewer() {
|
||||
return this.#isAssetViewer;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
||||
@@ -23,7 +35,7 @@
|
||||
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
||||
import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { onMount, setContext, type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import '../app.css';
|
||||
|
||||
@@ -49,6 +61,10 @@
|
||||
|
||||
let showNavigationLoadingBar = $state(false);
|
||||
|
||||
const appState = new AppState();
|
||||
appState.isAssetViewer = isAssetViewerRoute(page);
|
||||
setContext('AppState', appState);
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
@@ -74,8 +90,14 @@
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
onNavigate(({ to }) => {
|
||||
appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
});
|
||||
afterNavigate(({ to, complete }) => {
|
||||
appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
void complete.finally(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
|
||||
Reference in New Issue
Block a user