Compare commits

..

8 Commits

Author SHA1 Message Date
midzelis
cceb143f4f feat: web - view transitions from timeline to viewer, next/prev
feat: web - view transitions from timeline to viewer, next/prev

feat: web - swipe feedback - show image while swiping/dragging left/right

feat: web - swipe feedback - show image while swiping/dragging left/right
2026-01-22 03:48:55 +00:00
midzelis
d13895748a feat: swipe feedback
refactor: replace onPreviousAsset/onNextAsset with onSwipe
2026-01-22 03:45:26 +00:00
midzelis
b84be3307b feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte
preload thumbs and previews
2026-01-22 02:31:17 +00:00
midzelis
5ddc2af765 Merge with dependent PRs 2026-01-22 01:11:50 +00:00
midzelis
89ac6670f4 rename: preloadManager to imageManager 2026-01-22 01:11:50 +00:00
midzelis
d2d6cabf1d refactor: use assetCacheManager for OCR data 2026-01-22 01:00:22 +00:00
midzelis
289ccfc7e0 refactor: rename mobileDevice to mediaQueryManager 2026-01-22 00:58:05 +00:00
midzelis
3df9e255c6 feat: remove Cache API, rework preload(), cancel() and fetch()
perf - replace broadcast channel with direct postMessage
2026-01-22 00:15:03 +00:00
58 changed files with 2932 additions and 677 deletions

View File

@@ -502,12 +502,7 @@ jobs:
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test --project=chromium
if: ${{ !cancelled() }}
- name: Run ui tests (web)
env:
CI: true
run: npx playwright test --project=ui
run: npx playwright test
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0

View File

@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
{
name: 'ui',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.ui-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
// name: 'parallel tests',
// use: { ...devices['Desktop Chrome'] },
// testMatch: /.*\.parallel-e2e-spec\.ts/,
// fullyParallel: true,
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
// },
// {
// name: 'firefox',

View File

@@ -1,10 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
@@ -26,31 +23,44 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const box = await thumbnail.boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await expect(original).toBeInViewport();
await expect(original).toHaveAttribute('src', /original/);
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const box = await thumbnail.boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const initialSrc = await thumbnail.getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
await expect(preview).not.toHaveAttribute('src', initialSrc!);
});
});

View File

@@ -74,6 +74,20 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
/* view transition variables */
--vt-duration-default: 250ms;
--vt-duration-hero: 350ms;
--vt-duration-slideshow: 1s;
--vt-viewer-slide-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--vt-viewer-slide-distance: 15%;
--vt-viewer-opacity-start: 0.1;
--vt-viewer-opacity-mid: 0.4;
--vt-viewer-blur-max: 4px;
--vt-viewer-blur-mid: 2px;
}
button:not(:disabled),
@@ -171,3 +185,318 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(root) {
animation: var(--vt-duration-slideshow) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-slideshow) 0s fadeIn forwards;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: var(--vt-duration-hero) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-hero) 0s fadeIn forwards;
}
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s flyOutRight forwards;
}
::view-transition-new(info) {
animation: var(--vt-duration-default) 0s flyInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
z-index: 4;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom) {
background-color: var(--color-black);
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right) {
height: 100dvh;
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
background-color: var(--color-black);
opacity: 1 !important;
}
::view-transition-group(exclude-leftbutton),
::view-transition-group(exclude-rightbutton),
::view-transition-group(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(exclude-leftbutton),
::view-transition-old(exclude-rightbutton),
::view-transition-old(exclude) {
visibility: hidden;
}
::view-transition-new(exclude-leftbutton),
::view-transition-new(exclude-rightbutton),
::view-transition-new(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(hero) {
animation: var(--vt-duration-hero) fadeOut forwards;
align-content: center;
}
::view-transition-new(hero) {
animation: var(--vt-duration-hero) fadeIn forwards;
align-content: center;
}
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutLeft forwards;
overflow: hidden;
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInRight forwards;
overflow: hidden;
}
::view-transition-old(previous) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
}
::view-transition-old(previous-old) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyOutRight forwards;
overflow: hidden;
z-index: -1;
}
::view-transition-new(previous) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
}
::view-transition-new(previous-new) {
animation: var(--vt-duration-default) var(--vt-viewer-slide-easing) flyInLeft forwards;
overflow: hidden;
}
@keyframes flyInLeft {
from {
/* object-position: -25dvw; */
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
/* object-position: -25dvw; */
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes flyInRight {
from {
/* object-position: 25dvw; */
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
opacity: 1;
filter: blur(0);
}
}
/* Fly out to right */
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
50% {
opacity: var(--vt-viewer-opacity-mid);
filter: blur(var(--vt-viewer-blur-mid));
}
to {
/* object-position: 50dvw 0px; */
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Reduced motion: when system preference is set */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
animation-name: none;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom) {
animation: var(--vt-duration-default) fadeOut forwards;
}
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: var(--vt-duration-default) fadeIn forwards;
}
::view-transition-group(previous),
::view-transition-group(previous-old),
::view-transition-group(next),
::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-default) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(previous-new),
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-default) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
}

View File

@@ -0,0 +1,201 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import type { ClassValue } from 'svelte/elements';
/**
* Converts a ClassValue to a string suitable for className assignment.
* Handles strings, arrays, and objects similar to how clsx works.
*/
function classValueToString(value: ClassValue | undefined): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value
.map((v) => classValueToString(v))
.filter(Boolean)
.join(' ');
}
// Object/dictionary case
return Object.entries(value)
.filter(([, v]) => v)
.map(([k]) => k)
.join(' ');
}
export interface ImageLoaderProperties {
imgClass?: ClassValue;
alt?: string;
draggable?: boolean;
role?: string;
style?: string;
title?: string | null;
loading?: 'lazy' | 'eager';
dataAttributes?: Record<string, string>;
}
export interface ImageSourceProperty {
src: string | undefined;
}
export interface ImageLoaderCallbacks {
onStart?: () => void;
onLoad?: () => void;
onError?: (error: Error) => void;
onElementCreated?: (element: HTMLImageElement) => void;
}
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => {
if (params.alt !== undefined) {
img.alt = params.alt;
}
if (params.draggable !== undefined) {
img.draggable = params.draggable;
}
if (params.imgClass) {
img.className = classValueToString(params.imgClass);
}
if (params.role) {
img.role = params.role;
}
if (params.style !== undefined) {
img.setAttribute('style', params.style);
}
if (params.title !== undefined && params.title !== null) {
img.title = params.title;
}
if (params.loading !== undefined) {
img.loading = params.loading;
}
if (params.dataAttributes) {
for (const [key, value] of Object.entries(params.dataAttributes)) {
img.setAttribute(key, value);
}
}
};
const cleanupImageElement = (
imgElement: HTMLImageElement,
currentSrc: string | undefined,
handleLoad: () => void,
handleError: () => void,
) => {
cancelImageUrl(currentSrc);
if (imgElement) {
imgElement.removeEventListener('load', handleLoad);
imgElement.removeEventListener('error', handleError);
imgElement.remove();
}
};
const createImageElement = (
src: string | undefined,
properties: ImageLoaderProperties,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
onElementCreated?: (imgElement: HTMLImageElement) => void,
) => {
if (!src) {
return undefined;
}
const img = document.createElement('img');
updateImageAttributes(img, properties);
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
onStart?.();
if (src) {
img.src = src;
onElementCreated?.(img);
}
return img;
};
export function loadImage(
src: string,
properties: ImageLoaderProperties,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
) {
const img = createImageElement(src, properties, onLoad, onError, onStart);
if (!img) {
return () => void 0;
}
return () => cleanupImageElement(img, src, onLoad, onError);
}
export type LoadImageFunction = typeof loadImage;
/**
* 1. Creates and appends an <img> element to the parent
* 2. Coordinates with service worker before src triggers fetch
* 3. Adds load/error listeners
* 4. Cancels SW request when element is removed from DOM
*/
export function imageLoader(
node: HTMLElement,
params: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks,
) {
let currentSrc = params.src;
let currentCallbacks = params;
let imgElement: HTMLImageElement | undefined = undefined;
const handleLoad = () => {
currentCallbacks.onLoad?.();
};
const handleError = () => {
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
};
const handleElementCreated = (img: HTMLImageElement) => {
if (img) {
node.append(img);
// const a = document.createElement('p');
// a.classList.add('absolute', 'h-full', 'w-full', 'top-0');
// a.textContent = img.src;
// node.append(a);
currentCallbacks.onElementCreated?.(img);
}
};
const createImage = () => {
imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated);
};
createImage();
return {
update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) {
// If src changed, recreate the image element
if (newParams.src !== currentSrc) {
cleanupImageElement(imgElement!, currentSrc, handleLoad, handleError);
currentSrc = newParams.src;
currentCallbacks = newParams;
createImage();
return;
}
currentCallbacks = newParams;
if (!imgElement) {
return;
}
updateImageAttributes(imgElement, newParams);
},
destroy() {
if (imgElement) {
cleanupImageElement(imgElement, currentSrc, handleLoad, handleError);
}
},
};
}

View File

@@ -9,10 +9,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
initialState: state,
});
let isUpdatingFromInstance = false;
let isUpdatingFromStore = false;
const unsubscribes = [
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
photoZoomState.subscribe((state) => {
if (isUpdatingFromInstance || options?.disabled) {
return;
}
isUpdatingFromStore = true;
zoomInstance.setState(state);
isUpdatingFromStore = false;
}),
zoomInstance.subscribe(({ state }) => {
if (isUpdatingFromStore || options?.disabled) {
return;
}
isUpdatingFromInstance = true;
photoZoomState.set(state);
isUpdatingFromInstance = false;
}),
];

View File

@@ -13,7 +13,7 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -114,7 +114,7 @@
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
</a>
{/snippet}

View File

@@ -0,0 +1,277 @@
<script lang="ts">
import { imageLoader } from '$lib/actions/image-loader.svelte';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
import { photoZoomState, photoZoomTransform, resetZoomState } from '$lib/stores/zoom-image.store';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, untrack, type Snippet } from 'svelte';
interface Props {
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
zoomDisabled?: boolean;
imageClass?: string;
container: {
width: number;
height: number;
};
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
transitionName?: string | null | undefined;
onImageReady?: () => void;
onError?: () => void;
imgElement?: HTMLImageElement;
imgContainerElement?: HTMLElement;
overlays?: Snippet;
}
let {
imgElement = $bindable<HTMLImageElement | undefined>(),
imgContainerElement = $bindable<HTMLElement | undefined>(),
asset,
sharedLink,
zoomDisabled = false,
imageClass = '',
container,
slideshowState,
slideshowLook,
transitionName,
onImageReady,
onError,
overlays,
}: Props = $props();
let previousLoader = $state<AdaptiveImageLoader>();
let previousAssetId: string | undefined;
let previousSharedLinkId: string | undefined;
const adaptiveImageLoader = $derived.by(() => {
if (previousAssetId === asset.id && previousSharedLinkId === sharedLink?.id) {
return previousLoader!;
}
return untrack(() => {
previousAssetId = asset.id;
previousSharedLinkId = sharedLink?.id;
previousLoader?.destroy();
resetZoomState();
const loader = new AdaptiveImageLoader(asset, sharedLink, {
currentZoomFn: () => $photoZoomState.currentZoom,
onImageReady,
onError,
});
previousLoader = loader;
return loader;
});
});
onDestroy(() => adaptiveImageLoader.destroy());
const imageDimensions = $derived.by(() => {
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
return { width: asset.width!, height: asset.height! };
}
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
return getDimensions(asset.exifInfo) as { width: number; height: number };
}
return { width: 1, height: 1 };
});
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
const renderDimensions = $derived.by(() => {
const { width, height } = scaledDimensions;
return {
width: width + 'px',
height: height + 'px',
left: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
};
});
const blurredSlideshow = $derived(
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
const thumbnailUrl = $derived(loadState.thumbnailUrl);
const previewUrl = $derived(loadState.previewUrl);
const originalUrl = $derived(loadState.originalUrl);
const showSpinner = $derived(!asset.thumbhash && loadState.quality === 'basic');
const showBrokenAsset = $derived(loadState.hasError && loadState.quality !== 'loading-original');
// Effect: Upgrade to original when user zooms in
$effect(() => {
if ($photoZoomState.currentZoom > 1 && loadState.quality === 'preview') {
void adaptiveImageLoader.triggerOriginal();
}
});
let thumbnailElement = $state<HTMLImageElement>();
let previewElement = $state<HTMLImageElement>();
let originalElement = $state<HTMLImageElement>();
// Effect: Synchronize highest quality element as main imgElement
$effect(() => {
imgElement = originalElement ?? previewElement ?? thumbnailElement;
});
</script>
<div class="relative h-full w-full">
<!-- Blurred slideshow background (full viewport) -->
{#if blurredSlideshow}
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash! }} class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
></canvas>
{/if}
<!-- Letterbox regions (empty space around image) -->
<Letterboxes
{transitionName}
{slideshowState}
{slideshowLook}
hasThumbhash={!!asset.thumbhash}
{scaledDimensions}
{container}
/>
<!-- Main image box with transition -->
<div
style:view-transition-name={transitionName}
data-transition-name={transitionName}
class="absolute"
style:left={renderDimensions.left}
style:top={renderDimensions.top}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
bind:this={imgContainerElement}
>
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<div style:transform-origin="0px 0px" style:transform={$photoZoomTransform} class="h-full w-full absolute">
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
</div>
{:else if showSpinner}
<div id="spinner" class="absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{/if}
<div
use:imageLoader={{
src: thumbnailUrl,
onStart: () => adaptiveImageLoader.onThumbnailStart(),
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
onError: () => adaptiveImageLoader.onThumbnailError(),
onElementCreated: (el) => (thumbnailElement = el),
imgClass: ['absolute h-full', 'w-full'],
alt: '',
role: 'presentation',
dataAttributes: {
'data-testid': 'thumbnail',
},
}}
></div>
{#if showBrokenAsset}
<div class="h-full w-full absolute">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{:else}
<!-- Slideshow blurred background -->
{#if thumbnailUrl && slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground}
<img
src={thumbnailUrl}
alt=""
role="presentation"
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<div
use:imageLoader={{
src: previewUrl,
onStart: () => adaptiveImageLoader.onPreviewStart(),
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
onError: () => adaptiveImageLoader.onPreviewError(),
onElementCreated: (el) => (previewElement = el),
imgClass: ['h-full', 'w-full', { imageClass }],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'preview',
},
}}
></div>
{@render overlays?.()}
</div>
<div
class="absolute top-0"
style:transform-origin="0px 0px"
style:transform={$photoZoomTransform}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<div
use:imageLoader={{
src: originalUrl,
onStart: () => adaptiveImageLoader.onOriginalStart(),
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
onError: () => adaptiveImageLoader.onOriginalError(),
onElementCreated: (el) => (originalElement = el),
imgClass: ['h-full', 'w-full', { imageClass }],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'original',
},
}}
></div>
{@render overlays?.()}
</div>
<!-- Use placeholder empty image to zoomImage so it can monitor mouse-wheel events and update zoom state -->
<div
class="absolute top-0"
use:zoomImageAction={{ disabled: zoomDisabled }}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
<img alt="" class="absolute h-full w-full hidden" draggable="false" />
</div>
{/if}
</div>
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk';
@@ -54,7 +54,7 @@
onMultiSelect();
};
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
let mouseOver = $state(false);
const onMouseEnter = () => {
if (usingMobileDevice) {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import { loadImage } from '$lib/actions/image-loader.svelte';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
@@ -12,24 +13,24 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-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 { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
getAllAlbums,
getAssetInfo,
getStack,
type AlbumResponseDto,
@@ -37,9 +38,9 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import { fly, slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
@@ -90,13 +91,12 @@
copyImage = $bindable(),
}: Props = $props();
const { setAssetId } = assetViewingStore;
const { setAssetId, invisible } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
@@ -104,14 +104,17 @@
const asset = $derived(cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowEditor = $state(false);
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null);
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
let zoomToggle = $state(() => void 0);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
@@ -124,32 +127,57 @@
return;
}
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
if (!asset.stack) {
return;
}
stack = await getStack({ id: asset.stack.id });
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
});
};
const handleFavorite = async () => {
if (album && album.isActivityEnabled) {
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
if (stack?.assets[1]) {
untrack(() => {
const loader = new AdaptiveImageLoader(stack!.assets[1], undefined, undefined, loadImage);
loader.start();
});
}
};
onMount(async () => {
const handleFavorite = async () => {
if (!album || !album.isActivityEnabled) {
return;
}
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
};
let transitionName = $state<string | undefined>('hero');
let equirectangularTransitionName = $state<string | undefined>('hero');
let detailPanelTransitionName = $state<string | undefined>(undefined);
let unsubscribes: (() => void)[] = [];
let addInfoTransition;
let finished;
onMount(() => {
addInfoTransition = () => {
detailPanelTransitionName = 'info';
transitionName = 'hero';
equirectangularTransitionName = 'hero';
};
eventManager.on('TransitionToAssetViewer', addInfoTransition);
eventManager.on('TransitionToTimeline', addInfoTransition);
finished = () => {
detailPanelTransitionName = undefined;
transitionName = undefined;
};
eventManager.on('Finished', finished);
unsubscribes.push(
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
@@ -167,82 +195,209 @@
}
}),
);
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
activityManager.reset();
eventManager.off('TransitionToAssetViewer', addInfoTransition!);
eventManager.off('TransitionToTimeline', addInfoTransition!);
eventManager.off('Finished', finished!);
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
activityManager.reset();
destroyNextPreloader();
destroyPreviousPreloader();
});
const handleGetAllAlbums = async () => {
if (authManager.isSharedLink) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const closeViewer = () => {
transitionName = 'hero';
onClose?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
console.log(asset);
const refreshedAsset = await getAssetInfo({ id: asset.id });
console.log(refreshedAsset);
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
}
isShowEditor = false;
};
const tracker = new InvocationTracker();
const startTransition = async (
types: string[],
targetTransition: string | null,
targetAsset: AssetResponseDto | null,
navigateFn: () => Promise<boolean>,
) => {
transitionName = viewTransitionManager.getTransitionName('old', targetTransition);
equirectangularTransitionName = viewTransitionManager.getTransitionName('old', targetTransition);
detailPanelTransitionName = 'detail-panel';
await tick();
const navigationResult = new Promise<boolean>((navigationResolve) => {
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('StartViewTransition', async () => {
transitionName = viewTransitionManager.getTransitionName('new', targetTransition);
if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) {
equirectangularTransitionName = undefined;
}
await tick();
navigationResolve(await navigateFn());
});
eventManager.once('AssetViewerFree', () => tick().then(resolve));
}),
types,
);
});
return navigationResult;
};
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
let nextPreloader: AdaptiveImageLoader | undefined;
let previousPreloader: AdaptiveImageLoader | undefined;
let nextPreviewUrl = $state<string | undefined>();
let previousPreviewUrl = $state<string | undefined>();
const setPreviewUrl = (direction: 'next' | 'previous', url: string | undefined) => {
if (direction === 'next') {
nextPreviewUrl = url;
} else {
previousPreviewUrl = url;
}
};
const startPreloader = (asset: AssetResponseDto | undefined, direction: 'next' | 'previous') => {
if (!asset) {
return;
}
const loader = new AdaptiveImageLoader(
asset,
undefined,
{
currentZoomFn: () => 1,
onQualityUpgrade: (url) => setPreviewUrl(direction, url),
},
loadImage,
);
loader.start();
return loader;
};
const destroyPreviousPreloader = () => {
previousPreloader?.destroy();
previousPreloader = undefined;
previousPreviewUrl = undefined;
};
const destroyNextPreloader = () => {
nextPreloader?.destroy();
nextPreloader = undefined;
nextPreviewUrl = undefined;
};
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
setPreviewUrl(direction, undefined);
if (direction === 'next') {
destroyPreviousPreloader();
return;
}
destroyNextPreloader();
};
const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => {
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
const shouldDestroyPrevious = movedForward || !movedBackward;
const shouldDestroyNext = movedBackward || !movedForward;
if (movedForward) {
// When moving forward: old next becomes current, shift preview URLs
const oldNextUrl = nextPreviewUrl;
destroyPreviousPreloader();
previousPreviewUrl = oldNextUrl;
destroyNextPreloader();
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
} else if (movedBackward) {
// When moving backward: old previous becomes current, shift preview URLs
const oldPreviousUrl = previousPreviewUrl;
destroyNextPreloader();
nextPreviewUrl = oldPreviousUrl;
destroyPreviousPreloader();
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
} else {
// Non-adjacent navigation (e.g., slideshow random) - clear everything
if (shouldDestroyPrevious) {
destroyPreviousPreloader();
}
if (shouldDestroyNext) {
destroyNextPreloader();
}
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
}
};
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
} else {
return;
}
}
e?.stopPropagation();
preloadManager.cancel(asset);
if (tracker.isActive()) {
return;
}
cancelPreloadsBeforeNavigation(order);
let skipped = false;
if (viewTransitionManager.skipTransitions()) {
skipped = true;
}
void tracker.invoke(async () => {
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
if (slideShowPlaying && slideShowShuffle) {
const navigate = async () => {
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!next) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
next = true;
}
}
return next;
};
// eslint-disable-next-line unicorn/prefer-ternary
if (viewTransitionManager.isSupported() && !skipped && !skipTransition) {
hasNext = await startTransition(['slideshow'], null, null, navigate);
} else {
hasNext = await navigate();
}
} else {
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
// only transition if the target is already preloaded, and is in a secure context
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
const navigate = async () =>
order === 'previous' ? await navigateToAsset(previousAsset) : await navigateToAsset(nextAsset);
if (viewTransitionManager.isSupported() && !skipped && !skipTransition && !!targetAsset) {
const targetTransition = slideShowPlaying ? null : order;
hasNext = await startTransition(
slideShowPlaying ? ['slideshow'] : ['viewer-nav'],
targetTransition,
targetAsset,
navigate,
);
} else {
hasNext = await navigate();
}
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (slideShowPlaying) {
if (hasNext) {
$restartSlideshowProgress = true;
} else {
@@ -286,10 +441,11 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
if (!document.fullscreenElement) {
return;
}
document.body.style.cursor = '';
await document.exitFullscreen();
} catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
@@ -307,7 +463,7 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await handleGetAllAlbums();
eventManager.emit('AlbumAddAssets');
break;
}
case AssetAction.DELETE:
@@ -317,9 +473,10 @@
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
if (!stack) {
break;
}
cursor.current = stack.assets[0];
break;
}
case AssetAction.STACK:
@@ -367,21 +524,44 @@
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
if (sharedLink) {
return;
}
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
});
let lastCursor = $state<AssetCursor>();
$effect(() => {
if (cursor.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
// After navigation completes, reconcile preloads with full state information
updatePreloadsAfterNavigation(lastCursor, cursor);
lastCursor = cursor;
return;
}
// "first time" load, start preloads
if (cursor.nextAsset) {
nextPreloader = startPreloader(cursor.nextAsset, 'next');
}
if (cursor.previousAsset) {
previousPreloader = startPreloader(cursor.previousAsset, 'previous');
}
lastCursor = cursor;
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
@@ -399,26 +579,36 @@
}
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
eventManager.emit('AssetViewerFree');
}
});
const isEquirectangular = (asset: AssetResponseDto) => {
return (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
);
};
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer';
if (asset.type === AssetTypeEnum.Image) {
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
} else if (isEquirectangular(asset)) {
return 'ImagePanaramaViewer';
} else if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
return 'CropArea';
}
return 'PhotoViewer';
}
if (assetViewerManager.isPlayingMotionPhoto && asset.livePhotoVideoId) {
return 'LiveVideoViewer';
}
if (
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
) {
return 'ImagePanaramaViewer';
}
if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
return 'CropArea';
}
return 'PhotoViewer';
return 'VideoViewer';
});
const showActivityStatus = $derived(
@@ -443,12 +633,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}
@@ -483,31 +677,35 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<div
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
style:view-transition-name="exclude-leftbutton"
>
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full items-center flex">
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{transitionName}
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)}
onReady={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
{transitionName}
cursor={{ ...cursor, current: previewStackedAsset! }}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
@@ -515,37 +713,41 @@
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{transitionName}
{cursor}
assetId={asset.livePhotoVideoId!}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{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
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)}
onReady={() => eventManager.emit('AssetViewerFree')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{transitionName}
{cursor}
assetId={asset.id}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
@@ -573,19 +775,23 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<div
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
style:view-transition-name="exclude-rightbutton"
>
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !isShowEditor}
<div
transition:fly={{ duration: 150 }}
transition:slide={{ axis: 'x', duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
style:view-transition-name={detailPanelTransitionName}
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
<DetailPanel {asset} currentAlbum={album} />
</div>
{/if}

View File

@@ -7,6 +7,7 @@
import { timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { Route } from '$lib/route';
@@ -17,9 +18,16 @@
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import {
mdiCalendar,
@@ -34,6 +42,7 @@
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
@@ -43,11 +52,10 @@
interface Props {
asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null;
}
let { asset, albums = [], currentAlbum = null }: Props = $props();
let { asset, currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -74,14 +82,43 @@
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
let albums = $state<AlbumResponseDto[]>([]);
const refreshAlbums = async () => {
if (authManager.isSharedLink) {
return;
}
try {
albums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
handleError(error, 'Error getting asset album membership');
}
};
eventManager.on('AlbumAddAssets', () => void refreshAlbums());
onDestroy(() => {
eventManager.off('AlbumAddAssets', () => void refreshAlbums());
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => void refreshAlbums());
});
$effect(() => {
if (!previousId) {
previousId = asset.id;
return;
}
if (asset.id !== previousId) {
showEditFaces = false;
previousId = asset.id;
if (asset.id === previousId) {
return;
}
showEditFaces = false;
previousId = asset.id;
});
const getMegapixel = (width: number, height: number): number | undefined => {

View File

@@ -11,7 +11,7 @@
import { t } from 'svelte-i18n';
interface Props {
htmlElement: HTMLImageElement | HTMLVideoElement;
htmlElement: HTMLImageElement | HTMLVideoElement | undefined | null;
containerWidth: number;
containerHeight: number;
assetId: string;
@@ -78,6 +78,9 @@
});
$effect(() => {
if (!htmlElement) {
return;
}
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,

View File

@@ -8,11 +8,12 @@
import { fade } from 'svelte/transition';
type Props = {
transitionName?: string;
asset: AssetResponseDto;
zoomToggle?: (() => void) | null;
};
let { asset, zoomToggle = $bindable() }: Props = $props();
let { transitionName, asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@@ -20,11 +21,12 @@
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div transition:fade={{ duration: 150 }} class="flex h-dvh w-dvw select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
{transitionName}
bind:zoomToggle
panorama={data}
originalPanorama={isWebCompatibleImage(asset)

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
interface Props {
transitionName?: string | null | undefined;
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
hasThumbhash: boolean;
scaledDimensions: {
width: number;
height: number;
};
container: {
width: number;
height: number;
};
}
let { transitionName, slideshowState, slideshowLook, hasThumbhash, scaledDimensions, container }: Props = $props();
const blurredSlideshow = $derived(
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && hasThumbhash,
);
const shouldShowLetterboxes = $derived(!!transitionName && transitionName !== 'hero' && !blurredSlideshow);
const transitionLetterboxLeft = $derived(shouldShowLetterboxes ? 'letterbox-left' : null);
const transitionLetterboxRight = $derived(shouldShowLetterboxes ? 'letterbox-right' : null);
const transitionLetterboxTop = $derived(shouldShowLetterboxes ? 'letterbox-top' : null);
const transitionLetterboxBottom = $derived(shouldShowLetterboxes ? 'letterbox-bottom' : null);
// Letterbox regions (the empty space around the main box)
const letterboxLeft = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (container.width - width) / 2;
return {
width: leftOffset + 'px',
height: container.height + 'px',
left: '0px',
top: '0px',
};
});
const letterboxRight = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (container.width - width) / 2;
const rightOffset = leftOffset;
return {
width: rightOffset + 'px',
height: container.height + 'px',
left: container.width - rightOffset + 'px',
top: '0px',
};
});
const letterboxTop = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (container.height - height) / 2;
const leftOffset = (container.width - width) / 2;
return {
width: width + 'px',
height: topOffset + 'px',
left: leftOffset + 'px',
top: '0px',
};
});
const letterboxBottom = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (container.height - height) / 2;
const bottomOffset = topOffset;
const leftOffset = (container.width - width) / 2;
return {
width: width + 'px',
height: bottomOffset + 'px',
left: leftOffset + 'px',
top: container.height - bottomOffset + 'px',
};
});
</script>
<!-- Letterbox regions (empty space around image) -->
<div
class="absolute"
style:view-transition-name={transitionLetterboxLeft}
style:left={letterboxLeft.left}
style:top={letterboxLeft.top}
style:width={letterboxLeft.width}
style:height={letterboxLeft.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxRight}
style:left={letterboxRight.left}
style:top={letterboxRight.top}
style:width={letterboxRight.width}
style:height={letterboxRight.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxTop}
style:left={letterboxTop.left}
style:top={letterboxTop.top}
style:width={letterboxTop.width}
style:height={letterboxTop.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxBottom}
style:left={letterboxBottom.left}
style:top={letterboxBottom.top}
style:width={letterboxBottom.width}
style:height={letterboxBottom.height}
></div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
@@ -27,6 +28,7 @@
};
type Props = {
transitionName?: string;
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>

View File

@@ -1,51 +1,45 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.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 BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
cursor: AssetCursor;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
transitionName?: string;
onReady?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
zoomToggle?: () => void;
}
let {
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
sharedLink,
transitionName,
onReady,
onSwipe,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
@@ -53,20 +47,6 @@
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
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,
});
onDestroy(() => {
$boundingBoxesArray = [];
});
@@ -115,29 +95,11 @@
handlePromiseError(copyImage());
};
const onSwipe = (event: SwipeCustomEvent) => {
if ($photoZoomState.currentZoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
};
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
let currentPreviewUrl = $state<string>();
$effect(() => {
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
if (currentPreviewUrl) {
void cast(currentPreviewUrl);
}
});
@@ -155,35 +117,20 @@
}
};
const onload = () => {
imageLoaded = true;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
onDestroy(() => preloadManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
);
let containerWidth = $state(0);
let containerHeight = $state(0);
let lastUrl: string | undefined;
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let imgContainerElement = $state<HTMLElement | undefined>();
let swipeFeedbackReset = $state<(() => void) | undefined>();
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
});
}
lastUrl = imageLoaderUrl;
// Reset swipe feedback when asset changes
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
untrack(() => swipeFeedbackReset?.());
});
</script>
@@ -193,49 +140,33 @@
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
/>
{#if imageError}
<div id="broken-asset" class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
<SwipeFeedback
bind:element
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
disabled={isOcrActive || $photoZoomState.currentZoom > 1}
{onSwipe}
bind:reset={swipeFeedbackReset}
>
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
<div
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img
bind:this={$photoViewerImgElement}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<AdaptiveImage
{asset}
{sharedLink}
{container}
zoomDisabled={isOcrActive}
imageClass={$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
onImageReady={() => onReady?.()}
onError={() => onReady?.()}
bind:imgElement={$photoViewerImgElement}
bind:imgContainerElement
{transitionName}
>
{#snippet overlays()}
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
@@ -247,23 +178,38 @@
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{/snippet}
</AdaptiveImage>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
{#snippet leftPreview()}
{#if cursor.previousAsset}
<AdaptiveImage
asset={cursor.previousAsset}
{sharedLink}
{container}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if cursor.nextAsset}
<AdaptiveImage
asset={cursor.nextAsset}
{sharedLink}
{container}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>

View File

@@ -0,0 +1,370 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { onDestroy } from 'svelte';
interface Props {
disabled?: boolean;
onSwipeEnd?: (offsetX: number) => void;
onSwipeMove?: (offsetX: number) => void;
onSwipe?: (direction: 'left' | 'right') => void;
swipeThreshold?: number;
class?: string;
transitionName?: string;
element?: HTMLDivElement;
clientWidth?: number;
clientHeight?: number;
reset?: () => void;
children: Snippet;
leftPreview?: Snippet;
rightPreview?: Snippet;
}
let {
disabled = false,
onSwipeEnd,
onSwipeMove,
onSwipe,
swipeThreshold = 45,
class: className = '',
transitionName,
element = $bindable(),
clientWidth = $bindable(),
clientHeight = $bindable(),
reset = $bindable(),
children,
leftPreview,
rightPreview,
}: Props = $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;
// 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 ? '' : isSwipeInProgress ? 'wait' : 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;
}
currentOffsetX = event.clientX - startX;
const direction = currentOffsetX < 0 ? 'left' : 'right';
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) < swipeThreshold ||
(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 resetSwipeFeedback = () => {
resetPreviewContainers();
isSwipeInProgress = false;
};
reset = 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
bind:this={element}
bind:clientWidth
bind:clientHeight
class={className}
style:cursor={cursorStyle}
style:view-transition-name={transitionName}
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>

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.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 SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import {
autoPlayVideo,
@@ -10,54 +14,87 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { onDestroy, onMount, untrack } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
transitionName?: string;
cursor: AssetCursor;
assetId: string;
sharedLink?: SharedLinkResponseDto;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onClose?: () => void;
}
let {
transitionName,
cursor,
assetId,
sharedLink,
loopVideo,
cacheKey,
playOriginalVideo,
onPreviousAsset = () => {},
onNextAsset = () => {},
onSwipe,
onVideoEnded = () => {},
onVideoStarted = () => {},
onClose = () => {},
}: Props = $props();
const asset = $derived(cursor.current);
const previousAsset = $derived(cursor.previousAsset);
const nextAsset = $derived(cursor.nextAsset);
const { slideshowState, slideshowLook } = slideshowStore;
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let swipeFeedbackReset: (() => void) | undefined = $state();
let assetFileUrl = $derived(
playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let previousAssetFileUrl = $state<string | undefined>();
let isScrubbing = $state(false);
let showVideo = $state(false);
let containerWidth = $state(document.documentElement.clientWidth);
let containerHeight = $state(document.documentElement.clientHeight);
const exifDimensions = $derived(
asset?.exifInfo?.exifImageHeight && asset?.exifInfo.exifImageHeight
? (getDimensions(asset.exifInfo) as { width: number; height: number })
: null,
);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
const scaledDimensions = $derived(scaleToFit(dimensions, container));
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
});
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
videoPlayer?.load();
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) {
previousAssetFileUrl = assetFileUrl;
untrack(() => {
isLoading = true;
videoPlayer?.load();
swipeFeedbackReset?.();
});
}
});
@@ -67,6 +104,14 @@
}
});
const handleLoadedMetadata = () => {
dimensions = {
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
eventManager.emit('AssetViewerFree');
};
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
@@ -98,76 +143,120 @@
}
};
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(() => {
if (isFaceEditMode.value) {
videoPlayer?.pause();
}
});
const calculateSize = () => {
const { width, height } = scaledDimensions;
const size = {
width: width + 'px',
height: height + 'px',
};
return size;
};
const box = $derived(calculateSize());
</script>
{#if showVideo}
<div
transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full select-none place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
<SwipeFeedback
class="flex select-none h-full w-full place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
bind:reset={swipeFeedbackReset}
{onSwipe}
>
{#if showVideo}
<div
in:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full w-full place-content-center place-items-center"
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<div class="relative">
<video
style:view-transition-name={transitionName}
style:height={box.height}
style:width={box.width}
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
onloadedmetadata={() => handleLoadedMetadata()}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
{#if isLoading}
<div class="absolute inset-0 flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
</div>
{/if}
{#snippet leftPreview()}
{#if previousAsset}
<AdaptiveImage
asset={previousAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
</div>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if nextAsset}
<AdaptiveImage
asset={nextAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>
<style>
video:focus {
outline: none;
}
</style>

View File

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

View File

@@ -1,45 +1,53 @@
<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 VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { SharedLinkResponseDto } from '@immich/sdk';
interface Props {
transitionName?: string;
cursor: AssetCursor;
assetId: string;
sharedLink?: SharedLinkResponseDto;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
playOriginalVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
}
let {
transitionName,
cursor,
assetId,
sharedLink,
projectionType,
cacheKey,
loopVideo,
playOriginalVideo,
onPreviousAsset,
onSwipe,
onClose,
onNextAsset,
onVideoEnded,
onVideoStarted,
}: Props = $props();
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} />
<VideoPanoramaViewer {assetId} {transitionName} />
{:else}
<VideoNativeViewer
{transitionName}
{loopVideo}
{cacheKey}
{cursor}
{assetId}
{sharedLink}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}
{onSwipe}
{onVideoEnded}
{onVideoStarted}
{onClose}

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { imageLoader } from '$lib/actions/image-loader.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
import type { ClassValue } from 'svelte/elements';
interface Props {
@@ -54,16 +53,6 @@
onComplete?.(true);
};
function mount(elem: HTMLImageElement): ActionReturn {
if (elem.complete) {
loaded = true;
onComplete?.(false);
}
return {
destroy: () => preloadManager.cancelPreloadUrl(url),
};
}
let optionalClasses = $derived(
[
curve && 'rounded-xl',
@@ -76,26 +65,28 @@
.filter(Boolean)
.join(' '),
);
let style = $derived(
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
);
</script>
{#if errored}
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
{:else}
<img
use:mount
onload={setLoaded}
onerror={setErrored}
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={loaded || errored ? altText : ''}
{title}
class={['object-cover', optionalClasses, imageClass]}
draggable="false"
loading={preload ? 'eager' : 'lazy'}
/>
<div
use:imageLoader={{
src: url,
onLoad: setLoaded,
onError: setErrored,
imgClass: ['object-cover', optionalClasses, imageClass],
style,
alt: loaded || errored ? altText : '',
draggable: false,
title,
loading: preload ? 'eager' : 'lazy',
}}
></div>
{/if}
{#if hidden}

View File

@@ -1,10 +1,18 @@
<script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import { ProjectionType } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mediaQueryManager } from '$lib/stores/media-query-manager.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;
@@ -78,7 +76,7 @@
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
let element: HTMLElement | undefined = $state();
let mouseOver = $state(false);
let loaded = $state(false);

View File

@@ -6,10 +6,11 @@
import { useActions, type ActionArray } from '$lib/actions/use-actions';
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 { appManager } from '$lib/managers/app-manager.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import type { Snippet } from 'svelte';
import { type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -44,12 +45,17 @@
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
let isAssetViewer = $derived(appManager.isAssetViewer);
</script>
<header>
{#if !hideNavbar}
{#if !hideNavbar && !isAssetViewer}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
</header>
<div
tabindex="-1"
@@ -58,13 +64,15 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>
{#if sidebar}
{#if isAssetViewer}
<div></div>
{:else if sidebar}
{@render sidebar()}
{:else}
<UserSidebar />
{/if}
<main class="relative">
<main class="relative w-full">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()}
</div>

View File

@@ -10,7 +10,7 @@
import { Route } from '$lib/route';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
@@ -110,7 +110,7 @@
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
</a>
{/snippet}

View File

@@ -13,7 +13,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { user } from '$lib/stores/user.store';
@@ -79,7 +79,7 @@
class="sidebar:hidden"
/>
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
<Logo variant={mobileDevice.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
</a>
</div>
<div class="flex justify-between gap-4 lg:gap-8 pe-6">

View File

@@ -5,14 +5,14 @@ import { vi } from 'vitest';
const mocks = vi.hoisted(() => {
return {
mobileDevice: {
mediaQueryManager: {
isFullSidebar: false,
},
};
});
vi.mock('$lib/stores/mobile-device.svelte', () => ({
mobileDevice: mocks.mobileDevice,
vi.mock('$lib/stores/media-query-manager.svelte', () => ({
mediaQueryManager: mocks.mediaQueryManager,
}));
vi.mock('$lib/stores/sidebar.svelte', () => ({
@@ -25,7 +25,7 @@ vi.mock('$lib/stores/sidebar.svelte', () => ({
describe('Sidebar component', () => {
beforeEach(() => {
vi.resetAllMocks();
mocks.mobileDevice.isFullSidebar = false;
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = false;
});
@@ -39,7 +39,7 @@ describe('Sidebar component', () => {
'inert is $expectedInert when isFullSidebar=$isFullSidebar and isSidebarOpen=$isSidebarOpen',
({ isFullSidebar, isSidebarOpen, expectedInert }) => {
// setup
mocks.mobileDevice.isFullSidebar = isFullSidebar;
mocks.mediaQueryManager.isFullSidebar = isFullSidebar;
sidebarStore.isOpen = isSidebarOpen;
// when
@@ -53,7 +53,7 @@ describe('Sidebar component', () => {
it('should set width when sidebar is expanded', () => {
// setup
mocks.mobileDevice.isFullSidebar = false;
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = true;
// when
@@ -68,7 +68,7 @@ describe('Sidebar component', () => {
it('should close the sidebar if it is open on initial render', () => {
// setup
mocks.mobileDevice.isFullSidebar = false;
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = true;
// when

View File

@@ -2,7 +2,7 @@
import { clickOutside } from '$lib/actions/click-outside';
import { focusTrap } from '$lib/actions/focus-trap';
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { onMount, type Snippet } from 'svelte';
@@ -13,8 +13,8 @@
let { ariaLabel, children }: Props = $props();
const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const isHidden = $derived(!sidebarStore.isOpen && !mediaQueryManager.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mediaQueryManager.isFullSidebar);
onMount(() => {
closeSidebar();

View File

@@ -1,20 +1,14 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
type Props = {
animationTargetAssetId?: string | null;
viewerAssets: ViewerAsset[];
width: number;
height: number;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
@@ -26,10 +20,7 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const { animationTargetAssetId, viewerAssets, width, height, thumbnail, customThumbnailLayout }: Props = $props();
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
@@ -41,18 +32,20 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
data-transition-name={transitionName}
style:view-transition-name={transitionName}
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<!-- animate:flip={{ duration: transitionDuration }} -->
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>

View File

@@ -1,34 +1,36 @@
<script lang="ts">
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
import { onDestroy, tick, type Snippet } from 'svelte';
type Props = {
toAssetViewerTransitionId?: string | null;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
};
let {
toAssetViewerTransitionId,
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
}: Props = $props();
@@ -51,6 +53,34 @@
});
return getDateLocaleString(date);
};
let toTimelineTransitionAssetId = $state<string | null>(null);
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
const transitionToTimelineCallback = ({ id }: { id: string }) => {
const asset = monthGroup.findAssetById({ id });
if (!asset) {
return;
}
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('TimelineLoaded', ({ id }) => {
animationTargetAssetId = id;
void tick().then(resolve);
});
}),
['timeline'],
() => {
animationTargetAssetId = null;
},
);
};
if (viewTransitionManager.isSupported()) {
eventManager.on('TransitionToTimeline', transitionToTimelineCallback);
onDestroy(() => {
eventManager.off('TransitionToTimeline', transitionToTimelineCallback);
});
}
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
@@ -95,7 +125,7 @@
</div>
<AssetLayout
{manager}
{animationTargetAssetId}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { ScrubberMonth, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
@@ -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,
@@ -65,7 +67,7 @@
const toScrollY = (percent: number) => percent * (height - (PADDING_TOP + PADDING_BOTTOM));
const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
const MOBILE_WIDTH = 20;
const DESKTOP_WIDTH = 60;
@@ -437,7 +439,7 @@
next = forward
? (focusable[(index + 1) % focusable.length] as HTMLElement)
: (focusable[(index - 1) % focusable.length] as HTMLElement);
next.focus();
next?.focus();
}
}
}
@@ -508,6 +510,7 @@
aria-valuemin={toScrollY(0)}
data-id="scrubber"
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
class:invisible
style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width

View File

@@ -12,6 +12,8 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -21,14 +23,13 @@
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
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, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
isSelectionMode?: boolean;
singleSelect?: boolean;
@@ -104,10 +105,11 @@
// 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);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const maxMd = $derived(mediaQueryManager.maxMd);
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
$effect(() => {
const layoutOptions = maxMd
@@ -211,7 +213,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = getScrollTarget();
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -223,7 +225,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = false;
invisible = isAssetViewerRoute(page) ? true : false;
};
// note: only modified once in afterNavigate()
@@ -241,10 +243,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
@@ -253,8 +258,13 @@
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
});
});
@@ -264,7 +274,7 @@
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting) {
if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false;
}
});
@@ -561,19 +571,6 @@
isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length);
};
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -604,6 +601,7 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -683,11 +681,11 @@
style:width="100%"
>
<Month
{toAssetViewerTransitionId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -701,12 +699,56 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
onClick={async (asset) => {
const onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const dispatchClick = () => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, onClick);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
};
const hasThumbnailClick = typeof onThumbnailClick === 'function';
const selectingAssets = isSelectionMode || assetInteraction.selectionActive;
if (!viewTransitionManager.isSupported() || hasThumbnailClick || selectingAssets) {
dispatchClick();
return;
}
// tag target on the 'old' snapshot
toAssetViewerTransitionId = asset.id;
await tick();
eventManager.once('StartViewTransition', () => {
toAssetViewerTransitionId = null;
dispatchClick();
});
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('AssetViewerFree', () => {
void tick().then(() => {
eventManager.emit('TransitionToAssetViewer');
resolve();
});
});
}),
['viewer'],
);
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {

View File

@@ -4,6 +4,7 @@
import { AssetAction } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -97,6 +98,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 };

View File

@@ -49,6 +49,7 @@
$locale = newLocale;
}
};
let editedLocale = $derived(findLocale($locale).code);
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
let selectedOption = $derived({

View File

@@ -0,0 +1,44 @@
import { getAssetUrlForKind, ImageKinds, type ImageKind } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { type AssetResponseDto } from '@immich/sdk';
type CancelImageKind = ImageKind | 'all';
class ImageManager {
preload(asset: AssetResponseDto | undefined, kind: ImageKind = 'preview') {
if (!asset) {
return;
}
this.preloadImageUrl(getAssetUrlForKind(asset, kind));
}
preloadImageUrl(src: string | undefined) {
if (!src) {
return;
}
const img = new Image();
img.src = src;
}
cancel(asset: AssetResponseDto | undefined, kind: CancelImageKind = 'preview') {
if (!asset) {
return;
}
const kinds = kind === 'all' ? (Object.keys(ImageKinds) as ImageKind[]) : [kind];
for (const kind of kinds) {
const url = getAssetUrlForKind(asset, kind);
if (url) {
cancelImageUrl(url);
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();

View File

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

View File

@@ -0,0 +1,127 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function traceTransitionEvents(msg: string, error?: unknown) {
// console.log(msg, error);
}
class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null);
#finishedCallbacks: (() => void)[] = [];
#splitViewerNavTransitionNames = true;
constructor() {
const root = document.documentElement;
const value = getComputedStyle(root).getPropertyValue('--immich-split-viewer-nav').trim();
this.#splitViewerNavTransitionNames = value === 'enabled';
}
getTransitionName = (kind: 'old' | 'new', name: string | null | undefined) => {
if (name === 'previous' || name === 'next') {
return this.#splitViewerNavTransitionNames ? name + '-' + kind : name;
} else if (name) {
return name;
}
return undefined;
};
get activeViewTransition() {
return this.#activeViewTransition;
}
isSupported() {
return 'startViewTransition' in document;
}
skipTransitions() {
const skippedTransitions = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#notifyFinished();
return skippedTransitions;
}
startTransition(domUpdateComplete: Promise<unknown>, types?: string[], finishedCallback?: () => unknown) {
if (!this.isSupported()) {
throw new Error('View transition API not available');
}
if (this.#activeViewTransition) {
traceTransitionEvents('Can not start transition - one already active');
return;
}
// good time to add view-transition-name styles (if needed)
traceTransitionEvents('emit BeforeStartViewTransition');
eventManager.emit('BeforeStartViewTransition');
// next call will create the 'old' view snapshot
let transition: ViewTransition;
try {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition({
update: async () => {
// Good time to remove any view-transition-name styles created during
// BeforeStartViewTransition, then trigger the actual view transition.
traceTransitionEvents('emit StartViewTransition');
eventManager.emit('StartViewTransition');
await domUpdateComplete;
traceTransitionEvents('awaited domUpdateComplete');
},
types,
});
} catch {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition(async () => {
// Good time to remove any view-transition-name styles created during
// BeforeStartViewTransition, then trigger the actual view transition.
traceTransitionEvents('emit StartViewTransition');
eventManager.emit('StartViewTransition');
await domUpdateComplete;
traceTransitionEvents('awaited domUpdateComplete');
});
}
this.#activeViewTransition = transition;
this.#finishedCallbacks.push(() => {
this.#activeViewTransition = null;
});
if (finishedCallback) {
this.#finishedCallbacks.push(finishedCallback);
}
// UpdateCallbackDone is a good time to add any view-transition-name styles
// to the new DOM state, before the 'new' view snapshot is creatd
// eslint-disable-next-line tscompat/tscompat
transition.updateCallbackDone
.then(() => {
traceTransitionEvents('emit UpdateCallbackDone');
eventManager.emit('UpdateCallbackDone');
})
.catch((error: unknown) => traceTransitionEvents('error in UpdateCallbackDone', error));
// Both old/new snapshots are taken - pseudo elements are created, transition is
// about to start
// eslint-disable-next-line tscompat/tscompat
transition.ready
.then(() => eventManager.emit('Ready'))
.catch((error: unknown) => {
this.#notifyFinished();
traceTransitionEvents('error in Ready', error);
});
// Transition is complete
// eslint-disable-next-line tscompat/tscompat
transition.finished
.then(() => {
traceTransitionEvents('emit Finished');
eventManager.emit('Finished');
})
.catch((error: unknown) => traceTransitionEvents('error in Finished', error));
// eslint-disable-next-line tscompat/tscompat
void transition.finished.then(() => this.#notifyFinished());
}
#notifyFinished() {
for (const callback of this.#finishedCallbacks) {
callback();
}
this.#finishedCallbacks = [];
}
}
export const viewTransitionManager = new ViewTransitionManager();

View File

@@ -0,0 +1,13 @@
class AppManager {
#isAssetViewer = $state<boolean>(false);
set isAssetViewer(value: boolean) {
this.#isAssetViewer = value;
}
get isAssetViewer() {
return this.#isAssetViewer;
}
}
export const appManager = new AppManager();

View File

@@ -72,6 +72,19 @@ export type Events = {
SessionLocked: [];
TransitionToTimeline: [{ id: string }];
TimelineLoaded: [{ id: string | null }];
TransitionToAssetViewer: [];
AssetViewerLoaded: [];
AssetViewerFree: [];
BeforeStartViewTransition: [];
Finished: [];
Ready: [];
UpdateCallbackDone: [];
StartViewTransition: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];
@@ -95,11 +108,11 @@ class EventManager<EventMap extends Record<string, unknown[]>> {
}[];
} = {};
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
return this.addListener(key, listener, false);
}
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
return this.addListener(key, listener, true);
}

View File

@@ -5,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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
class SidebarStore {
isOpen = $derived.by(() => mobileDevice.isFullSidebar);
isOpen = $derived.by(() => mediaQueryManager.isFullSidebar);
/**
* Reset the sidebar visibility to the default, based on the current screen width.
*/
reset() {
this.isOpen = mobileDevice.isFullSidebar;
this.isOpen = mediaQueryManager.isFullSidebar;
}
/**
* Toggles the sidebar visibility, if available at the current screen width.
*/
toggle() {
this.isOpen = mobileDevice.isFullSidebar ? true : !this.isOpen;
this.isOpen = mediaQueryManager.isFullSidebar ? true : !this.isOpen;
}
}

View File

@@ -31,6 +31,15 @@ import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, md
import { init, register, t, type MessageFormatter } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
export const ImageKinds = {
thumbnail: true,
preview: true,
fullsize: true,
original: true,
} as const;
export type ImageKind = keyof typeof ImageKinds;
interface DownloadRequestOptions<T = unknown> {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
url: string;
@@ -195,18 +204,32 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean };
export const getAssetUrlForKind = (asset: AssetResponseDto, kind: ImageKind) => {
switch (kind) {
case 'preview': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey: asset.thumbhash });
}
case 'thumbnail': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash });
}
case 'fullsize': {
return getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash });
}
case 'original': {
return getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
}
}
};
export const getAssetUrl = ({
asset,
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto | undefined;
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
if (!asset) {
return;
}
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {

View File

@@ -0,0 +1,207 @@
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetUrl, getAssetUrlForKind } from '$lib/utils';
import { type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
/**
* Quality levels for progressive image loading
*/
type ImageQuality =
| 'basic'
| 'loading-thumbnail'
| 'thumbnail'
| 'loading-preview'
| 'preview'
| 'loading-original'
| 'original';
export interface ImageLoaderState {
previewUrl?: string;
thumbnailUrl?: string;
originalUrl?: string;
quality: ImageQuality;
hasError: boolean;
thumbnailImage: ImageStatus;
previewImage: ImageStatus;
originalImage: ImageStatus;
}
enum ImageStatus {
Unloaded = 'Unloaded',
Success = 'Success',
Error = 'Error',
}
/**
* Coordinates adaptive loading of a single asset image:
* thumbhash → thumbnail → preview → original (on zoom)
*
*/
export class AdaptiveImageLoader {
private state = $state<ImageLoaderState>({
quality: 'basic',
hasError: false,
thumbnailImage: ImageStatus.Unloaded,
previewImage: ImageStatus.Unloaded,
originalImage: ImageStatus.Unloaded,
});
private readonly currentZoomFn?: () => number;
private readonly onImageReady?: () => void;
private readonly onError?: () => void;
private readonly onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
private readonly imageLoader?: LoadImageFunction;
private readonly destroyFunctions: (() => void)[] = [];
readonly thumbnailUrl: string;
readonly previewUrl: string;
readonly originalUrl: string;
asset: AssetResponseDto;
constructor(
asset: AssetResponseDto,
sharedLink: SharedLinkResponseDto | undefined,
callbacks?: {
currentZoomFn: () => number;
onImageReady?: () => void;
onError?: () => void;
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
},
imageLoader?: LoadImageFunction,
) {
this.asset = asset;
this.currentZoomFn = callbacks?.currentZoomFn;
this.onImageReady = callbacks?.onImageReady;
this.onError = callbacks?.onError;
this.onQualityUpgrade = callbacks?.onQualityUpgrade;
this.imageLoader = imageLoader;
this.thumbnailUrl = getAssetUrlForKind(asset, 'thumbnail');
this.previewUrl = getAssetUrl({ asset, sharedLink });
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true });
this.state.thumbnailUrl = this.thumbnailUrl;
}
start() {
if (!this.imageLoader) {
throw new Error('Start requires imageLoader to be specified');
}
this.destroyFunctions.push(
this.imageLoader(
this.thumbnailUrl,
{},
() => this.onThumbnailLoad(),
() => this.onThumbnailError(),
() => this.onThumbnailStart(),
),
);
}
get adaptiveLoaderState(): ImageLoaderState {
return this.state;
}
onThumbnailStart() {
this.state.quality = 'loading-thumbnail';
}
onThumbnailLoad() {
this.state.quality = 'thumbnail';
this.state.thumbnailImage = ImageStatus.Success;
this.onImageReady?.();
this.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail');
this.triggerMainImage();
}
onThumbnailError() {
this.state.thumbnailUrl = undefined;
this.state.thumbnailImage = ImageStatus.Error;
this.triggerMainImage();
}
triggerMainImage() {
const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1;
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
}
triggerPreview() {
if (!this.previewUrl) {
// no preview, try original?
this.triggerOriginal();
return false;
}
this.state.previewUrl = this.previewUrl;
if (this.imageLoader) {
this.destroyFunctions.push(
this.imageLoader(
this.previewUrl,
{},
() => this.onPreviewLoad(),
() => this.onPreviewError(),
() => this.onPreviewStart(),
),
);
}
}
onPreviewStart() {
this.state.quality = 'loading-preview';
}
onPreviewLoad() {
this.state.quality = 'preview';
this.state.previewImage = ImageStatus.Success;
this.onImageReady?.();
this.onQualityUpgrade?.(this.previewUrl, 'preview');
}
onPreviewError() {
this.state.previewImage = ImageStatus.Error;
this.state.previewUrl = undefined;
// TODO: maybe try original, but only if preview's error isnt due to cancelation
}
triggerOriginal() {
if (!this.originalUrl) {
this.onError?.();
return false;
}
this.state.originalUrl = this.originalUrl;
if (this.imageLoader) {
this.destroyFunctions.push(
this.imageLoader(
this.originalUrl,
{},
() => this.onOriginalLoad(),
() => this.onOriginalError(),
() => this.onOriginalStart(),
),
);
}
}
onOriginalStart() {
this.state.quality = 'loading-original';
}
onOriginalLoad() {
this.state.quality = 'original';
this.state.originalImage = ImageStatus.Success;
this.onImageReady?.();
}
onOriginalError() {
this.state.originalImage = ImageStatus.Error;
this.state.originalUrl = undefined;
}
destroy(): void {
if (this.imageLoader) {
for (const destroy of this.destroyFunctions) {
destroy();
}
return;
}
imageManager.cancelPreloadUrl(this.thumbnailUrl);
imageManager.cancelPreloadUrl(this.previewUrl);
imageManager.cancelPreloadUrl(this.originalUrl);
}
}

View File

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

View File

@@ -1,14 +1,24 @@
const broadcast = new BroadcastChannel('immich');
import { ServiceWorkerMessenger } from './sw-messenger';
const messenger = new ServiceWorkerMessenger();
let isServiceWorkerEnabled = true;
messenger.onAckTimeout(() => {
if (!isServiceWorkerEnabled) {
return;
}
console.error('[ServiceWorker] No communication detected. Auto-disabled service worker.');
isServiceWorkerEnabled = false;
});
const isValidSwContext = (url: string | undefined | null): url is string => {
return globalThis.isSecureContext && isServiceWorkerEnabled && !!url;
};
export function cancelImageUrl(url: string | undefined | null) {
if (!url) {
if (!isValidSwContext(url)) {
return;
}
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url });
void messenger.send('cancel', { url });
}

View File

@@ -0,0 +1,157 @@
/**
* Low-level protocol for communicating with the service worker via postMessage.
*
* Protocol:
* 1. Main thread sends request: { type: string, requestId: string, ...data }
* 2. SW sends ack: { type: 'ack', requestId: string }
* 3. SW sends response (optional): { type: 'response', requestId: string, result?: any, error?: string }
*/
interface PendingRequest {
resolveAck: () => void;
resolveResponse?: (result: unknown) => void;
rejectResponse?: (error: Error) => void;
ackTimeout: ReturnType<typeof setTimeout>;
ackReceived: boolean;
}
export class ServiceWorkerMessenger {
readonly #pendingRequests = new Map<string, PendingRequest>();
readonly #ackTimeoutMs: number;
#requestCounter = 0;
#onTimeout?: (type: string, data: Record<string, unknown>) => void;
#messageHandler?: (event: MessageEvent) => void;
constructor(ackTimeoutMs = 5000) {
this.#ackTimeoutMs = ackTimeoutMs;
// Listen for messages from the service worker
if ('serviceWorker' in navigator) {
this.#messageHandler = (event) => {
this.#handleMessage(event.data);
};
navigator.serviceWorker.addEventListener('message', this.#messageHandler);
}
}
#handleMessage(data: unknown) {
if (typeof data !== 'object' || data === null) {
return;
}
const message = data as { requestId?: string; type?: string; error?: string; result?: unknown };
const requestId = message.requestId;
if (!requestId) {
return;
}
const pending = this.#pendingRequests.get(requestId);
if (!pending) {
return;
}
if (message.type === 'ack') {
pending.ackReceived = true;
clearTimeout(pending.ackTimeout);
pending.resolveAck();
return;
}
if (message.type === 'response') {
clearTimeout(pending.ackTimeout);
this.#pendingRequests.delete(requestId);
if (message.error) {
pending.rejectResponse?.(new Error(message.error));
return;
}
pending.resolveResponse?.(message.result);
}
}
/**
* Set a callback to be invoked when an ack timeout occurs.
* This can be used to detect and disable faulty service workers.
*/
onAckTimeout(callback: (type: string, data: Record<string, unknown>) => void): void {
this.#onTimeout = callback;
}
/**
* Send a message to the service worker.
* - send(): waits for ack, resolves when acknowledged
* - request(): waits for response, throws on error/timeout
*/
#sendInternal<T>(type: string, data: Record<string, unknown>, waitForResponse: boolean): Promise<T> {
const requestId = `${type}-${++this.#requestCounter}-${Date.now()}`;
const promise = new Promise<T>((resolve, reject) => {
const ackTimeout = setTimeout(() => {
const pending = this.#pendingRequests.get(requestId);
if (pending && !pending.ackReceived) {
this.#pendingRequests.delete(requestId);
console.warn(`[ServiceWorker] ${type} request not acknowledged:`, data);
this.#onTimeout?.(type, data);
// Only reject if we're waiting for a response
if (waitForResponse) {
reject(new Error(`Service worker did not acknowledge ${type} request`));
} else {
resolve(undefined as T);
}
}
}, this.#ackTimeoutMs);
this.#pendingRequests.set(requestId, {
resolveAck: waitForResponse ? () => {} : () => resolve(undefined as T),
resolveResponse: waitForResponse ? (result: unknown) => resolve(result as T) : undefined,
rejectResponse: waitForResponse ? reject : undefined,
ackTimeout,
ackReceived: false,
});
// Send message to the active service worker
// Feature detection is done in constructor and at call sites (sw-messaging.ts:isValidSwContext)
// eslint-disable-next-line compat/compat
navigator.serviceWorker.controller?.postMessage({
type,
requestId,
...data,
});
});
return promise;
}
/**
* Send a one-way message to the service worker.
* Returns a promise that resolves after the service worker acknowledges receipt.
* Resolves even if no ack is received within the timeout period.
*/
send(type: string, data: Record<string, unknown>): Promise<void> {
return this.#sendInternal<void>(type, data, false);
}
/**
* Send a request and wait for ack + response.
* Returns a promise that resolves with the response data or rejects on error/timeout.
*/
request<T = void>(type: string, data: Record<string, unknown>): Promise<T> {
return this.#sendInternal<T>(type, data, true);
}
/**
* Clean up pending requests and remove event listener
*/
close(): void {
for (const pending of this.#pendingRequests.values()) {
clearTimeout(pending.ackTimeout);
}
this.#pendingRequests.clear();
if (this.#messageHandler && 'serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', this.#messageHandler);
this.#messageHandler = undefined;
}
}
}

View File

@@ -24,7 +24,7 @@
});
</script>
<div class:display-none={$showAssetViewer}>
<div>
{@render children?.()}
</div>
<UploadCover />
@@ -33,7 +33,4 @@
:root {
overscroll-behavior: none;
}
.display-none {
display: none;
}
</style>

View File

@@ -1,5 +1,5 @@
<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';
@@ -8,6 +8,7 @@
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import { appManager } from '$lib/managers/app-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -58,6 +59,8 @@
let showNavigationLoadingBar = $state(false);
appManager.isAssetViewer = isAssetViewerRoute(page);
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -83,8 +86,14 @@
showNavigationLoadingBar = true;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
onNavigate(({ to }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
});
afterNavigate(({ to, complete }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
void complete.finally(() => {
showNavigationLoadingBar = false;
});
});
const { serverRestarting } = websocketStore;

View File

@@ -1,25 +0,0 @@
import { handleCancel, handlePreload } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
return;
}
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
}
};
};

View File

@@ -1,42 +0,0 @@
import { version } from '$service-worker';
const CACHE = `cache-${version}`;
let _cache: Cache | undefined;
const getCache = async () => {
if (_cache) {
return _cache;
}
_cache = await caches.open(CACHE);
return _cache;
};
export const get = async (key: string) => {
const cache = await getCache();
if (!cache) {
return;
}
return cache.match(key);
};
export const put = async (key: string, response: Response) => {
if (response.status !== 200) {
return;
}
const cache = await getCache();
if (!cache) {
return;
}
cache.put(key, response.clone());
};
export const prune = async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
};

View File

@@ -2,9 +2,9 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installBroadcastChannelListener } from './broadcast-channel';
import { prune } from './cache';
import { handleRequest } from './request';
import { installMessageListener } from './messaging';
import { handleFetch as handleAssetFetch } from './request';
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
@@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope;
const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
event.waitUntil(prune());
};
const handleInstall = (event: ExtendableEvent) => {
event.waitUntil(sw.skipWaiting());
// do not preload app resources
};
const handleFetch = (event: FetchEvent): void => {
@@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => {
// Cache requests for thumbnails
const url = new URL(event.request.url);
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
event.respondWith(handleRequest(event.request));
event.respondWith(handleAssetFetch(event.request));
return;
}
};
@@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => {
sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installBroadcastChannelListener();
installMessageListener();

View File

@@ -0,0 +1,53 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { handleCancel } from './request';
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
/**
* Send acknowledgment for a request
*/
function sendAck(client: Client, requestId: string) {
client.postMessage({
type: 'ack',
requestId,
});
}
/**
* Handle 'cancel' request: cancel a pending request
*/
const handleCancelRequest = (client: Client, url: URL, requestId: string) => {
sendAck(client, requestId);
handleCancel(url);
};
export const installMessageListener = () => {
sw.addEventListener('message', (event) => {
if (!event.data?.requestId || !event.data?.type) {
return;
}
const requestId = event.data.requestId;
switch (event.data.type) {
case 'cancel': {
const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined;
if (!url) {
return;
}
const client = event.source;
if (!client) {
return;
}
handleCancelRequest(client, url, requestId);
break;
}
}
});
};

View File

@@ -1,73 +1,68 @@
import { get, put } from './cache';
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
const pendingRequests = new Map<string, AbortController>();
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
const assertResponse = (response: Response) => {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
type PendingRequest = {
controller: AbortController;
promise: Promise<Response>;
cleanupTimeout?: ReturnType<typeof setTimeout>;
};
const getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
const pendingRequests = new Map<string, PendingRequest>();
const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url);
const CANCELATION_MESSAGE = 'Request canceled by application';
const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export const handleFetch = (request: URL | Request): Promise<Response> => {
const requestKey = getRequestKey(request);
const existing = pendingRequests.get(requestKey);
if (existing) {
// Clone the response since response bodies can only be read once
// Each caller gets an independent clone they can consume
return existing.promise.then((response) => response.clone());
}
if (isRequest(request)) {
return request.url;
}
const pendingRequest: PendingRequest = {
controller: new AbortController(),
promise: undefined as unknown as Promise<Response>,
};
pendingRequests.set(requestKey, pendingRequest);
throw new Error(`Invalid request: ${request}`);
};
// NOTE: fetch returns after headers received, not the body
pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal })
.catch((error: unknown) => {
const standardError = error instanceof Error ? error : new Error(String(error));
if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
throw standardError;
})
.finally(() => {
// Schedule cleanup after timeout to allow response body streaming to complete
const cleanupTimeout = setTimeout(() => {
pendingRequests.delete(requestKey);
}, CLEANUP_TIMEOUT_MS);
pendingRequest.cleanupTimeout = cleanupTimeout;
});
export const handlePreload = async (request: URL | Request) => {
try {
return await handleRequest(request);
} catch (error) {
console.error(`Preload failed: ${error}`);
}
};
export const handleRequest = async (request: URL | Request) => {
const cacheKey = getCacheKey(request);
const cachedResponse = await get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
try {
const cancelToken = new AbortController();
pendingRequests.set(cacheKey, cancelToken);
const response = await fetch(request, { signal: cancelToken.signal });
assertResponse(response);
put(cacheKey, response);
return response;
} catch (error) {
if (error.name === 'AbortError') {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
console.log('Not an abort error', error);
throw error;
} finally {
pendingRequests.delete(cacheKey);
}
// Clone for the first caller to keep the original response unconsumed for future callers
return pendingRequest.promise.then((response) => response.clone());
};
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
const requestKey = getRequestKey(url);
pendingRequest.abort();
pendingRequests.delete(cacheKey);
const pendingRequest = pendingRequests.get(requestKey);
if (pendingRequest) {
pendingRequest.controller.abort(CANCELATION_MESSAGE);
if (pendingRequest.cleanupTimeout) {
clearTimeout(pendingRequest.cleanupTimeout);
}
pendingRequests.delete(requestKey);
}
};