mirror of
https://github.com/immich-app/immich.git
synced 2026-01-29 00:04:52 -08:00
Compare commits
4 Commits
refactor/a
...
fix-memory
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29919e8aa | ||
|
|
4e0e1b2c5c | ||
|
|
84c3980844 | ||
|
|
e50579eefc |
@@ -1,7 +1,10 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Page, 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;
|
||||
@@ -23,32 +26,31 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await page.getByTestId('thumbnail').boundingBox();
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).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(page.getByTestId('original')).toBeInViewport();
|
||||
await expect(page.getByTestId('original')).toHaveAttribute('src', /original/);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await page.getByTestId('thumbnail').boundingBox();
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).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(page.getByTestId('original')).toHaveAttribute('src', /fullsize/);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect(page.getByTestId('thumbnail')).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await page.getByTestId('thumbnail').getAttribute('src');
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect(page.getByTestId('preview')).not.toHaveAttribute('src', initialSrc!);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
@@ -103,8 +103,11 @@ export const thumbnailUtils = {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
await expect(
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
|
||||
page.locator(
|
||||
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
|
||||
@@ -92,7 +92,9 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
// Hide controls by default for videos and motion photos
|
||||
}
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
}
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -771,8 +771,11 @@ importers:
|
||||
specifier: ^7946.0.16
|
||||
version: 7946.0.16
|
||||
'@zoom-image/core':
|
||||
specifier: ^0.42.0
|
||||
version: 0.42.0
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.4
|
||||
'@zoom-image/svelte':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.8(svelte@5.48.0)
|
||||
dom-to-image:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@@ -5678,8 +5681,13 @@ packages:
|
||||
'@xtuc/long@4.2.2':
|
||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
|
||||
|
||||
'@zoom-image/core@0.42.0':
|
||||
resolution: {integrity: sha512-aF7siQqxqmOVlBd65deaCM7L/6V80Rp7HazZJpxtErh8zAn5itXXKBv1KA1NufSPfRZsXl1QtysxkjB3gVIzxw==}
|
||||
'@zoom-image/core@0.41.4':
|
||||
resolution: {integrity: sha512-zUJNHWQzx8rmfNOlp2Rr0+n8I7QK9hLNThnusdtvz20/HN+J//RcDJmCuRDj6jUW/qJGh9FWR5sROMFBuPLPfQ==}
|
||||
|
||||
'@zoom-image/svelte@0.3.8':
|
||||
resolution: {integrity: sha512-rkXS+JS4qkBccmRK9+I5j+Pe4rp78GWK/7y0EduBJNtt38q+AwmKhhQs8oTMKTU6lOzLgxjXy1TI802mtvcAmw==}
|
||||
peerDependencies:
|
||||
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||
|
||||
abab@2.0.6:
|
||||
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
|
||||
@@ -18667,10 +18675,15 @@ snapshots:
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
||||
'@zoom-image/core@0.42.0':
|
||||
'@zoom-image/core@0.41.4':
|
||||
dependencies:
|
||||
'@namnode/store': 0.1.0
|
||||
|
||||
'@zoom-image/svelte@0.3.8(svelte@5.48.0)':
|
||||
dependencies:
|
||||
'@zoom-image/core': 0.41.4
|
||||
svelte: 5.48.0
|
||||
|
||||
abab@2.0.6:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -248,6 +248,9 @@ export class MediaService extends BaseService {
|
||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: now, thumbnailAt: now });
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.14.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@zoom-image/core": "^0.42.0",
|
||||
"@zoom-image/core": "^0.41.0",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"fabric": "^6.5.4",
|
||||
"geo-coordinates-parser": "^1.7.4",
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
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 destroyImageElement = (
|
||||
imgElement: HTMLImageElement,
|
||||
currentSrc: string | undefined,
|
||||
handleLoad: () => void,
|
||||
handleError: () => void,
|
||||
) => {
|
||||
imgElement.removeEventListener('load', handleLoad);
|
||||
imgElement.removeEventListener('error', handleError);
|
||||
cancelImageUrl(currentSrc);
|
||||
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,
|
||||
) {
|
||||
let destroyed = false;
|
||||
const wrapper = (fn: (() => void) | undefined) => () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
fn?.();
|
||||
};
|
||||
const wrappedOnLoad = wrapper(onLoad);
|
||||
const wrappedOnError = wrapper(onError);
|
||||
const wrappedOnStart = wrapper(onStart);
|
||||
const img = createImageElement(src, properties, wrappedOnLoad, wrappedOnError, wrappedOnStart);
|
||||
if (!img) {
|
||||
return () => void 0;
|
||||
}
|
||||
return () => {
|
||||
destroyed = true;
|
||||
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
currentSrc = newParams.src;
|
||||
currentCallbacks = newParams;
|
||||
|
||||
createImage();
|
||||
return;
|
||||
}
|
||||
|
||||
currentCallbacks = newParams;
|
||||
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
updateImageAttributes(imgElement, newParams);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: assetViewerManager.zoomState,
|
||||
zoomTarget: options?.zoomTarget ?? null,
|
||||
});
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
||||
|
||||
const unsubscribes = [
|
||||
assetViewerManager.on('ZoomChange', (state) => zoomInstance.setState(state)),
|
||||
@@ -24,9 +20,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
|
||||
node.style.overflow = 'visible';
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
options = newOptions;
|
||||
zoomInstance.setState({ zoomTarget: newOptions?.zoomTarget ?? null });
|
||||
},
|
||||
destroy() {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
|
||||
@@ -14,7 +14,7 @@ type ActionMap = {
|
||||
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
|
||||
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | undefined; asset: AssetResponseDto };
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
|
||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
<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 { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.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;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
imgElement?: HTMLImageElement;
|
||||
overlays?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
imgElement = $bindable<HTMLImageElement | undefined>(),
|
||||
asset,
|
||||
sharedLink,
|
||||
zoomDisabled = false,
|
||||
imageClass = '',
|
||||
container,
|
||||
slideshowState,
|
||||
slideshowLook,
|
||||
onImageReady,
|
||||
onError,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
|
||||
let previousLoader = $state<AdaptiveImageLoader>();
|
||||
let previousAssetId: string | undefined;
|
||||
let previousSharedLinkId: string | undefined;
|
||||
|
||||
const resetZoomState = () => {
|
||||
assetViewerManager.zoomState = {
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
};
|
||||
};
|
||||
|
||||
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: () => assetViewerManager.zoom,
|
||||
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);
|
||||
|
||||
// Effect: Upgrade to original when user zooms in
|
||||
$effect(() => {
|
||||
if (assetViewerManager.zoom > 1 && loadState.quality === 'preview') {
|
||||
void adaptiveImageLoader.triggerOriginal();
|
||||
}
|
||||
});
|
||||
let thumbnailElement = $state<HTMLImageElement>();
|
||||
let previewElement = $state<HTMLImageElement>();
|
||||
let originalElement = $state<HTMLImageElement>();
|
||||
let mainImageBox = $state<HTMLElement>();
|
||||
|
||||
// Effect: Synchronize highest quality element as main imgElement
|
||||
$effect(() => {
|
||||
imgElement = originalElement ?? previewElement ?? thumbnailElement;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full" use:zoomImageAction={{ disabled: zoomDisabled, zoomTarget: mainImageBox }}>
|
||||
<!-- 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}
|
||||
|
||||
<!-- Main image box with transition -->
|
||||
<div
|
||||
bind:this={mainImageBox}
|
||||
class="absolute"
|
||||
style:left={renderDimensions.left}
|
||||
style:top={renderDimensions.top}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
>
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
|
||||
{:else if showSpinner}
|
||||
<div id="spinner" class="absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: thumbnailUrl,
|
||||
onStart: () => adaptiveImageLoader.onThumbnailStart(),
|
||||
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
|
||||
onError: () => adaptiveImageLoader.onThumbnailError(),
|
||||
onElementCreated: (element) => (thumbnailElement = element),
|
||||
imgClass: ['absolute h-full', 'w-full'],
|
||||
alt: '',
|
||||
role: 'presentation',
|
||||
dataAttributes: {
|
||||
'data-testid': 'thumbnail',
|
||||
},
|
||||
}}
|
||||
></div>
|
||||
|
||||
{#if showBrokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{:else}
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: previewUrl,
|
||||
onStart: () => adaptiveImageLoader.onPreviewStart(),
|
||||
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
|
||||
onError: () => adaptiveImageLoader.onPreviewError(),
|
||||
onElementCreated: (element) => (previewElement = element),
|
||||
imgClass: ['h-full', 'w-full', imageClass],
|
||||
alt: imageAltText,
|
||||
draggable: false,
|
||||
dataAttributes: {
|
||||
'data-testid': 'preview',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: originalUrl,
|
||||
onStart: () => adaptiveImageLoader.onOriginalStart(),
|
||||
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
|
||||
onError: () => adaptiveImageLoader.onOriginalError(),
|
||||
onElementCreated: (element) => (originalElement = element),
|
||||
imgClass: ['h-full', 'w-full', imageClass],
|
||||
alt: imageAltText,
|
||||
draggable: false,
|
||||
dataAttributes: {
|
||||
'data-testid': 'original',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<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';
|
||||
@@ -13,6 +12,7 @@
|
||||
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 { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
@@ -21,15 +21,14 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
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 { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
type AlbumResponseDto,
|
||||
@@ -94,21 +93,21 @@
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
slideshowRepeat,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
let stack: StackResponseDto | undefined = $state();
|
||||
let selectedStackAsset = $derived(stack?.assets.find(({ id }) => id === stack?.primaryAssetId));
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
||||
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 fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
@@ -118,132 +117,95 @@
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cursor.current.stack) {
|
||||
stack = undefined;
|
||||
return;
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
}
|
||||
|
||||
stack = await getStack({ id: cursor.current.stack.id });
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
imageManager.preload(stack?.assets[1]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!album || !album.isActivityEnabled) {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
unsubscribes.push(
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
}),
|
||||
slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
});
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
console.error('Error getting album that asset belong to', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
activityManager.reset();
|
||||
|
||||
destroyNextPreloader();
|
||||
destroyPreviousPreloader();
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
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);
|
||||
}
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
|
||||
let nextPreloader: AdaptiveImageLoader | undefined;
|
||||
let previousPreloader: AdaptiveImageLoader | undefined;
|
||||
|
||||
const startPreloader = (asset: AssetResponseDto | undefined) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage);
|
||||
loader.start();
|
||||
return loader;
|
||||
};
|
||||
|
||||
const destroyPreviousPreloader = () => {
|
||||
previousPreloader?.destroy();
|
||||
previousPreloader = undefined;
|
||||
};
|
||||
|
||||
const destroyNextPreloader = () => {
|
||||
nextPreloader?.destroy();
|
||||
nextPreloader = undefined;
|
||||
};
|
||||
|
||||
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
|
||||
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 (shouldDestroyPrevious) {
|
||||
destroyPreviousPreloader();
|
||||
}
|
||||
|
||||
if (shouldDestroyNext) {
|
||||
destroyNextPreloader();
|
||||
}
|
||||
|
||||
if (movedForward) {
|
||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||
} else if (movedBackward) {
|
||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||
} else {
|
||||
// Non-adjacent navigation (e.g., slideshow random)
|
||||
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||
}
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
@@ -252,8 +214,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
cancelPreloadsBeforeNavigation(order);
|
||||
|
||||
e?.stopPropagation();
|
||||
imageManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
@@ -275,18 +237,16 @@
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
// Loop back to starting asset
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
// Loop back to starting asset
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
}
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
@@ -331,24 +291,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
if (isMouseOver) {
|
||||
previewStackedAsset = stackedAsset;
|
||||
}
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
};
|
||||
|
||||
const handleStackedAssetsMouseLeave = () => {
|
||||
previewStackedAsset = undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.ADD_TO_ALBUM: {
|
||||
eventManager.emit('AlbumAddAssets');
|
||||
await handleGetAllAlbums();
|
||||
break;
|
||||
}
|
||||
case AssetAction.DELETE:
|
||||
@@ -408,44 +360,21 @@
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
await handleGetAllAlbums();
|
||||
ocrManager.clear();
|
||||
if (sharedLink) {
|
||||
return;
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
}
|
||||
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
};
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
cursor.current;
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
});
|
||||
|
||||
let lastCursor = $state<AssetCursor>();
|
||||
|
||||
$effect(() => {
|
||||
if (cursor.current.id === lastCursor?.current.id) {
|
||||
return;
|
||||
}
|
||||
if (lastCursor) {
|
||||
selectedStackAsset = undefined;
|
||||
previewStackedAsset = undefined;
|
||||
// After navigation completes, reconcile preloads with full state information
|
||||
updatePreloadsAfterNavigation(lastCursor, cursor);
|
||||
}
|
||||
if (!lastCursor && cursor) {
|
||||
// "first time" load, start preloads
|
||||
if (cursor.nextAsset) {
|
||||
nextPreloader = startPreloader(cursor.nextAsset);
|
||||
}
|
||||
if (cursor.previousAsset) {
|
||||
previousPreloader = startPreloader(cursor.previousAsset);
|
||||
}
|
||||
}
|
||||
lastCursor = cursor;
|
||||
imageManager.preload(cursor.nextAsset);
|
||||
imageManager.preload(cursor.previousAsset);
|
||||
});
|
||||
|
||||
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
|
||||
@@ -465,7 +394,7 @@
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||
}
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return 'VideoViewer';
|
||||
@@ -552,7 +481,15 @@
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
@@ -582,7 +519,13 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} />
|
||||
<PhotoViewer
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
@@ -630,7 +573,7 @@
|
||||
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"
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -648,14 +591,10 @@
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div
|
||||
role="presentation"
|
||||
class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
||||
onmouseleave={handleStackedAssetsMouseLeave}
|
||||
>
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2']}
|
||||
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
@@ -664,7 +603,7 @@
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
selectedStackAsset = stackedAsset;
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
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';
|
||||
@@ -18,16 +17,9 @@
|
||||
import { getAssetMediaUrl, 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,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
@@ -42,7 +34,6 @@
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
@@ -52,10 +43,11 @@
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
}
|
||||
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
let { asset, albums = [], currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
@@ -82,40 +74,14 @@
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => eventManager.on('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) {
|
||||
return;
|
||||
if (asset.id !== previousId) {
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
}
|
||||
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
});
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
|
||||
@@ -1,39 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { onDestroy, 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;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
}
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError }: Props = $props();
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
}: Props = $props();
|
||||
|
||||
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>();
|
||||
|
||||
assetViewerManager.zoomState = {
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
@@ -81,11 +110,29 @@
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
let currentPreviewUrl = $state<string>();
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 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 || assetViewerManager.zoom > 1));
|
||||
|
||||
$effect(() => {
|
||||
if (currentPreviewUrl) {
|
||||
void cast(currentPreviewUrl);
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,11 +150,35 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -121,29 +192,46 @@
|
||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, 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}
|
||||
class="relative h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={isOcrActive}
|
||||
imageClass={$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
onImageReady={() => onReady?.()}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgElement={assetViewerManager.imgRef}
|
||||
>
|
||||
{#snippet overlays()}
|
||||
{#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={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
|
||||
<div
|
||||
@@ -155,10 +243,23 @@
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
</div>
|
||||
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,34 +2,24 @@
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiImageBrokenVariant } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
class?: string;
|
||||
hideMessage?: boolean;
|
||||
width?: string | undefined;
|
||||
height?: string | undefined;
|
||||
}
|
||||
|
||||
let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
|
||||
|
||||
let clientWidth = $state(0);
|
||||
let textClass = $derived(clientWidth < 100 ? 'text-xs' : clientWidth < 150 ? 'text-sm' : 'text-base');
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:clientWidth
|
||||
class={[
|
||||
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
|
||||
className,
|
||||
]}
|
||||
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
|
||||
style:width
|
||||
style:height
|
||||
>
|
||||
{#if clientWidth >= 75}
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
|
||||
{/if}
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
|
||||
{#if !hideMessage}
|
||||
<span class="text-center {textClass}">{$t('error_loading_image')}</span>
|
||||
<span class="text-center">{$t('error_loading_image')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { imageLoader } from '$lib/actions/image-loader.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.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 {
|
||||
@@ -48,46 +49,53 @@
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
};
|
||||
|
||||
const setErrored = () => {
|
||||
errored = true;
|
||||
onComplete?.(true);
|
||||
};
|
||||
|
||||
let optionalClasses = $derived([
|
||||
function mount(elem: HTMLImageElement): ActionReturn {
|
||||
if (elem.complete) {
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
}
|
||||
return {
|
||||
destroy: () => imageManager.cancelPreloadUrl(url),
|
||||
};
|
||||
}
|
||||
|
||||
let optionalClasses = $derived(
|
||||
[
|
||||
curve && 'rounded-xl',
|
||||
circle && 'rounded-full',
|
||||
shadow && 'shadow-lg',
|
||||
(circle || !heightStyle) && 'aspect-square',
|
||||
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
|
||||
brokenAssetClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
brokenAssetClass,
|
||||
]);
|
||||
|
||||
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}
|
||||
<div
|
||||
use:imageLoader={{
|
||||
src: url,
|
||||
onLoad: setLoaded,
|
||||
onError: setErrored,
|
||||
imgClass: ['object-cover', imageClass],
|
||||
style,
|
||||
alt: loaded || errored ? altText : '',
|
||||
draggable: false,
|
||||
title,
|
||||
loading: preload ? 'eager' : 'lazy',
|
||||
}}
|
||||
></div>
|
||||
<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'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
|
||||
@@ -200,9 +200,8 @@
|
||||
|
||||
<div
|
||||
class={[
|
||||
'group focus-visible:outline-none focus-visible:rounded-lg flex overflow-hidden',
|
||||
'focus-visible:outline-none flex overflow-hidden',
|
||||
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
@@ -224,10 +223,18 @@
|
||||
bind:this={element}
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
data-selected={selected ? true : undefined}
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
|
||||
<div
|
||||
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="inherit"
|
||||
@@ -240,81 +247,13 @@
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
>
|
||||
<ImageThumbnail
|
||||
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
|
||||
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
curve={selected}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- icon overlay -->
|
||||
<div>
|
||||
<!-- Gradient overlay on hover -->
|
||||
{#if !usingMobileDevice && !disabled}
|
||||
<div
|
||||
class={[
|
||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:rounded-lg',
|
||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
></div>
|
||||
@@ -322,10 +261,7 @@
|
||||
|
||||
<!-- Dimmed support -->
|
||||
{#if dimmed && !mouseOver}
|
||||
<div
|
||||
id="a"
|
||||
class={['absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
|
||||
></div>
|
||||
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
{/if}
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
@@ -393,6 +329,72 @@
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
curve={selected}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectionCandidate}
|
||||
@@ -425,14 +427,11 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-immich-primary dark:outline-immich-dark-primary group-focus-visible:rounded-lg group-focus-visible:outline-2 group-focus-visible:-outline-offset-2',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { Duration } from 'luxon';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
@@ -13,7 +12,6 @@
|
||||
curve?: boolean;
|
||||
playIcon?: string;
|
||||
pauseIcon?: string;
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,7 +23,6 @@
|
||||
curve = false,
|
||||
playIcon = mdiPlayCircleOutline,
|
||||
pauseIcon = mdiPauseCircleOutline,
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
let remainingSeconds = $state(durationInSeconds);
|
||||
@@ -60,7 +57,7 @@
|
||||
{#if enablePlayback}
|
||||
<video
|
||||
bind:this={player}
|
||||
class={['h-full w-full object-cover', className]}
|
||||
class="h-full w-full object-cover"
|
||||
class:rounded-xl={curve}
|
||||
muted
|
||||
autoplay
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
invisible = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
|
||||
@@ -141,43 +141,41 @@
|
||||
}
|
||||
};
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||
return [];
|
||||
}
|
||||
const shortcutList = $derived.by(() => {
|
||||
if (searchStore.isSearchEnabled || $showAssetViewer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shortcuts: ShortcutOptions[] = [
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
|
||||
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
|
||||
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
|
||||
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
|
||||
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
|
||||
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
|
||||
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
|
||||
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
|
||||
];
|
||||
if (onEscape) {
|
||||
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
|
||||
}
|
||||
const shortcuts: ShortcutOptions[] = [
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
|
||||
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
|
||||
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
|
||||
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
|
||||
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
|
||||
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
|
||||
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
|
||||
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
|
||||
];
|
||||
if (onEscape) {
|
||||
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
|
||||
}
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
shortcuts.push(
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||
);
|
||||
}
|
||||
if (assetInteraction.selectionActive) {
|
||||
shortcuts.push(
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||
);
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
})(),
|
||||
);
|
||||
return shortcuts;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
|
||||
@@ -4,38 +4,7 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||
|
||||
type AssetLoadState = 'loading' | 'cancelled';
|
||||
|
||||
class ImageManager {
|
||||
// track recently canceled assets, so know if an load "error" is due to
|
||||
// cancelation
|
||||
private assetStates = new Map<string, AssetLoadState>();
|
||||
private readonly MAX_TRACKED_ASSETS = 10;
|
||||
|
||||
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
|
||||
// Remove if exists to reset insertion order
|
||||
this.assetStates.delete(asset.id);
|
||||
this.assetStates.set(asset.id, action);
|
||||
|
||||
// Only keep recent assets (Map maintains insertion order)
|
||||
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
|
||||
const firstKey = this.assetStates.keys().next().value!;
|
||||
this.assetStates.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
isCanceled(asset: AssetResponseDto) {
|
||||
return 'cancelled' === this.assetStates.get(asset.id);
|
||||
}
|
||||
|
||||
trackLoad(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'loading');
|
||||
}
|
||||
|
||||
trackCancelled(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'cancelled');
|
||||
}
|
||||
|
||||
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
@@ -46,8 +15,6 @@ class ImageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackLoad(asset);
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
@@ -57,8 +24,6 @@ class ImageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackCancelled(asset);
|
||||
|
||||
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||
for (const size of sizes) {
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
@@ -67,6 +32,12 @@ class ImageManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string | undefined) {
|
||||
if (url) {
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageManager = new ImageManager();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { AlbumPageViewMode } from '$lib/constants';
|
||||
import {
|
||||
getAlbumActions,
|
||||
handleRemoveUserFromAlbum,
|
||||
@@ -55,7 +56,7 @@
|
||||
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
|
||||
};
|
||||
|
||||
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
|
||||
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
|
||||
|
||||
let sharedLinks: SharedLinkResponseDto[] = $state([]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AlbumPageViewMode } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { type MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
@@ -39,7 +40,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
|
||||
return { Create };
|
||||
};
|
||||
|
||||
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
|
||||
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
|
||||
const isOwned = get(user).id === album.ownerId;
|
||||
|
||||
const Share: ActionItem = {
|
||||
@@ -66,7 +67,16 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
|
||||
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
|
||||
};
|
||||
|
||||
return { Share, AddUsers, CreateSharedLink };
|
||||
const Close: ActionItem = {
|
||||
title: $t('go_back'),
|
||||
type: $t('command'),
|
||||
icon: mdiArrowLeft,
|
||||
onAction: () => goto(Route.albums()),
|
||||
$if: () => viewMode === AlbumPageViewMode.VIEW,
|
||||
shortcuts: { key: 'Escape' },
|
||||
};
|
||||
|
||||
return { Share, AddUsers, CreateSharedLink, Close };
|
||||
};
|
||||
|
||||
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoViewerImgElement = writable<HTMLImageElement>();
|
||||
export const isSelectingAllAssets = writable(false);
|
||||
|
||||
@@ -200,10 +200,13 @@ export const getAssetUrl = ({
|
||||
sharedLink,
|
||||
forceOriginal = false,
|
||||
}: {
|
||||
asset: AssetResponseDto;
|
||||
asset: AssetResponseDto | undefined;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
forceOriginal?: boolean;
|
||||
}) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const id = asset.id;
|
||||
const cacheKey = asset.thumbhash;
|
||||
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetMediaUrl, getAssetUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, 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 imageLoader?: LoadImageFunction;
|
||||
private readonly destroyFunctions: (() => void)[] = [];
|
||||
readonly thumbnailUrl: string;
|
||||
readonly previewUrl: string;
|
||||
readonly originalUrl: string;
|
||||
readonly asset: AssetResponseDto;
|
||||
readonly callbacks?: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
};
|
||||
destroyed = false;
|
||||
|
||||
constructor(
|
||||
asset: AssetResponseDto,
|
||||
sharedLink: SharedLinkResponseDto | undefined,
|
||||
callbacks?: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
imageLoader?: LoadImageFunction,
|
||||
) {
|
||||
imageManager.trackLoad(asset);
|
||||
this.asset = asset;
|
||||
this.callbacks = callbacks;
|
||||
this.imageLoader = imageLoader;
|
||||
this.thumbnailUrl = getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.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() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-thumbnail';
|
||||
}
|
||||
|
||||
onThumbnailLoad() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'thumbnail';
|
||||
this.state.thumbnailImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
onThumbnailError() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.hasError = true;
|
||||
this.state.thumbnailUrl = undefined;
|
||||
this.state.thumbnailImage = ImageStatus.Error;
|
||||
this.callbacks?.onError?.();
|
||||
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.hasError = false;
|
||||
this.state.previewUrl = this.previewUrl;
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.previewUrl,
|
||||
{},
|
||||
() => this.onPreviewLoad(),
|
||||
() => this.onPreviewError(),
|
||||
() => this.onPreviewStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onPreviewStart() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-preview';
|
||||
}
|
||||
|
||||
onPreviewLoad() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'preview';
|
||||
this.state.previewImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
}
|
||||
|
||||
onPreviewError() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.hasError = true;
|
||||
this.state.previewImage = ImageStatus.Error;
|
||||
this.state.previewUrl = undefined;
|
||||
this.callbacks?.onError?.();
|
||||
this.triggerOriginal();
|
||||
}
|
||||
|
||||
triggerOriginal() {
|
||||
if (!this.originalUrl) {
|
||||
return false;
|
||||
}
|
||||
this.state.hasError = false;
|
||||
|
||||
this.state.originalUrl = this.originalUrl;
|
||||
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.originalUrl,
|
||||
{},
|
||||
() => this.onOriginalLoad(),
|
||||
() => this.onOriginalError(),
|
||||
() => this.onOriginalStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onOriginalStart() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-original';
|
||||
}
|
||||
|
||||
onOriginalLoad() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'original';
|
||||
this.state.originalImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
}
|
||||
|
||||
onOriginalError() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.hasError = true;
|
||||
this.state.originalImage = ImageStatus.Error;
|
||||
this.state.originalUrl = undefined;
|
||||
this.callbacks?.onError?.();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
if (this.imageLoader) {
|
||||
for (const destroy of this.destroyFunctions) {
|
||||
destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
imageManager.cancel(this.asset);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getAlbumDateRange, timeToSeconds } from './date-time';
|
||||
import { getAlbumDateRange, getShortDateRange, timeToSeconds } from './date-time';
|
||||
|
||||
describe('converting time to seconds', () => {
|
||||
it('parses hh:mm:ss correctly', () => {
|
||||
@@ -49,6 +49,43 @@ describe('converting time to seconds', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShortDateRange', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TZ', 'UTC');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should correctly return month if start and end date are within the same month', () => {
|
||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
|
||||
});
|
||||
|
||||
it('should correctly return month range if start and end date are in separate months within the same year', () => {
|
||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
|
||||
});
|
||||
|
||||
it('should correctly return range if start and end date are in separate months and years', () => {
|
||||
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
|
||||
});
|
||||
|
||||
it('should correctly return month if start and end date are within the same month, ignoring local time zone', () => {
|
||||
vi.stubEnv('TZ', 'UTC+6');
|
||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-01-31T00:00:00.000Z')).toEqual('Jan 2022');
|
||||
});
|
||||
|
||||
it('should correctly return month range if start and end date are in separate months within the same year, ignoring local time zone', () => {
|
||||
vi.stubEnv('TZ', 'UTC+6');
|
||||
expect(getShortDateRange('2022-01-01T00:00:00.000Z', '2022-02-01T00:00:00.000Z')).toEqual('Jan - Feb 2022');
|
||||
});
|
||||
|
||||
it('should correctly return range if start and end date are in separate months and years, ignoring local time zone', () => {
|
||||
vi.stubEnv('TZ', 'UTC+6');
|
||||
expect(getShortDateRange('2021-12-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z')).toEqual('Dec 2021 - Jan 2022');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlbumDate', () => {
|
||||
beforeAll(() => {
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
@@ -19,28 +19,30 @@ export function parseUtcDate(date: string) {
|
||||
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
|
||||
}
|
||||
|
||||
export const getShortDateRange = (startDate: string | Date, endDate: string | Date) => {
|
||||
startDate = startDate instanceof Date ? startDate : new Date(startDate);
|
||||
endDate = endDate instanceof Date ? endDate : new Date(endDate);
|
||||
|
||||
export const getShortDateRange = (startTimestamp: string, endTimestamp: string) => {
|
||||
const userLocale = get(locale);
|
||||
const endDateLocalized = endDate.toLocaleString(userLocale, {
|
||||
let startDate = DateTime.fromISO(startTimestamp).setZone('UTC');
|
||||
let endDate = DateTime.fromISO(endTimestamp).setZone('UTC');
|
||||
|
||||
if (userLocale) {
|
||||
startDate = startDate.setLocale(userLocale);
|
||||
endDate = endDate.setLocale(userLocale);
|
||||
}
|
||||
|
||||
const endDateLocalized = endDate.toLocaleString({
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
// The API returns the date in UTC. If the earliest asset was taken on Jan 1st at 1am,
|
||||
// we expect the album to start in January, even if the local timezone is UTC-5 for instance.
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
if (startDate.getFullYear() === endDate.getFullYear()) {
|
||||
if (startDate.getMonth() === endDate.getMonth()) {
|
||||
if (startDate.year === endDate.year) {
|
||||
if (startDate.month === endDate.month) {
|
||||
// Same year and month.
|
||||
// e.g.: aug. 2024
|
||||
return endDateLocalized;
|
||||
} else {
|
||||
// Same year but different month.
|
||||
// e.g.: jul. - sept. 2024
|
||||
const startMonthLocalized = startDate.toLocaleString(userLocale, {
|
||||
const startMonthLocalized = startDate.toLocaleString({
|
||||
month: 'short',
|
||||
});
|
||||
return `${startMonthLocalized} - ${endDateLocalized}`;
|
||||
@@ -48,7 +50,7 @@ export const getShortDateRange = (startDate: string | Date, endDate: string | Da
|
||||
} else {
|
||||
// Different year.
|
||||
// e.g.: feb. 2021 - sept. 2024
|
||||
const startDateLocalized = startDate.toLocaleString(userLocale, {
|
||||
const startDateLocalized = startDate.toLocaleString({
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
@@ -129,19 +129,3 @@ 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { ServiceWorkerMessenger } from './sw-messenger';
|
||||
|
||||
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
|
||||
// eslint-disable-next-line compat/compat
|
||||
const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined;
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
|
||||
export function cancelImageUrl(url: string | undefined | null) {
|
||||
if (!url || !messenger) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
messenger.send('cancel', { url });
|
||||
broadcast.postMessage({ type: 'cancel', url });
|
||||
}
|
||||
export function preloadImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export class ServiceWorkerMessenger {
|
||||
readonly #serviceWorker: ServiceWorkerContainer;
|
||||
|
||||
constructor(serviceWorker: ServiceWorkerContainer) {
|
||||
this.#serviceWorker = serviceWorker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-way message to the service worker.
|
||||
*/
|
||||
send(type: string, data: Record<string, unknown>) {
|
||||
this.#serviceWorker.controller?.postMessage({
|
||||
type,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto, onNavigate } from '$app/navigation';
|
||||
import { goto, onNavigate } from '$app/navigation';
|
||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
|
||||
@@ -52,13 +52,7 @@
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
isAlbumsRoute,
|
||||
isPeopleRoute,
|
||||
isSearchRoute,
|
||||
navigate,
|
||||
type AssetGridRouteSearchParams,
|
||||
} from '$lib/utils/navigation';
|
||||
import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||
import { AlbumUserRole, AssetVisibility, getAlbumInfo, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
@@ -91,7 +85,6 @@
|
||||
|
||||
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
|
||||
|
||||
let backUrl: string = $state(Route.albums());
|
||||
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
@@ -100,25 +93,6 @@
|
||||
const assetInteraction = new AssetInteraction();
|
||||
const timelineInteraction = new AssetInteraction();
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
let url: string | undefined = from?.url?.pathname;
|
||||
|
||||
const route = from?.route?.id;
|
||||
if (isSearchRoute(route)) {
|
||||
url = from?.url.href;
|
||||
}
|
||||
|
||||
if (isAlbumsRoute(route) || isPeopleRoute(route)) {
|
||||
url = Route.albums();
|
||||
}
|
||||
|
||||
backUrl = url || Route.albums();
|
||||
|
||||
if (backUrl === Route.sharedLinks()) {
|
||||
backUrl = history.state?.backUrl || Route.albums();
|
||||
}
|
||||
});
|
||||
|
||||
const handleFavorite = async () => {
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
@@ -158,7 +132,6 @@
|
||||
cancelMultiselect(assetInteraction);
|
||||
return;
|
||||
}
|
||||
await goto(backUrl);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -305,7 +278,7 @@
|
||||
|
||||
const onAlbumDelete = async ({ id }: AlbumResponseDto) => {
|
||||
if (id === album.id) {
|
||||
await goto(backUrl);
|
||||
await goto(Route.albums());
|
||||
viewMode = AlbumPageViewMode.VIEW;
|
||||
}
|
||||
};
|
||||
@@ -332,7 +305,7 @@
|
||||
};
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
const { Share } = $derived(getAlbumActions($t, album));
|
||||
const { Share, Close } = $derived(getAlbumActions($t, album, viewMode));
|
||||
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
|
||||
</script>
|
||||
|
||||
@@ -346,7 +319,7 @@
|
||||
onAlbumUserDelete={refreshAlbum}
|
||||
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
|
||||
/>
|
||||
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload]} />
|
||||
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload, Close]} />
|
||||
|
||||
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: Route.albums() }}>
|
||||
<div class="relative w-full shrink">
|
||||
@@ -512,7 +485,7 @@
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
{#if viewMode === AlbumPageViewMode.VIEW}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(backUrl)}>
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
||||
{#snippet trailing()}
|
||||
<ActionButton action={Cast} />
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { getPersonActions } from '$lib/services/person.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
@@ -61,7 +60,6 @@
|
||||
let { data }: Props = $props();
|
||||
|
||||
let numberOfAssets = $derived(data.statistics.assets);
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
const options = $derived({ visibility: AssetVisibility.Timeline, personId: data.person.id });
|
||||
@@ -106,16 +104,13 @@
|
||||
});
|
||||
|
||||
const handleEscape = async () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
}
|
||||
if (assetInteraction.selectionActive) {
|
||||
assetInteraction.clearMultiselect();
|
||||
return;
|
||||
} else {
|
||||
await goto(previousRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(previousRoute);
|
||||
return;
|
||||
};
|
||||
|
||||
const updateAssetCount = async () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { memoryStore } from '$lib/stores/memory.store.svelte';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
const $t = await getFormatter();
|
||||
const [$t] = await Promise.all([getFormatter(), memoryStore.ready()]);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
|
||||
25
web/src/service-worker/broadcast-channel.ts
Normal file
25
web/src/service-worker/broadcast-channel.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
42
web/src/service-worker/cache.ts
Normal file
42
web/src/service-worker/cache.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,9 +2,9 @@
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { installMessageListener } from './messaging';
|
||||
import { handleFetch as handleAssetFetch } from './request';
|
||||
import { installBroadcastChannelListener } from './broadcast-channel';
|
||||
import { prune } from './cache';
|
||||
import { handleRequest } from './request';
|
||||
|
||||
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
|
||||
|
||||
@@ -12,10 +12,12 @@ 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 => {
|
||||
@@ -26,7 +28,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(handleAssetFetch(event.request));
|
||||
event.respondWith(handleRequest(event.request));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -34,4 +36,4 @@ const handleFetch = (event: FetchEvent): void => {
|
||||
sw.addEventListener('install', handleInstall, { passive: true });
|
||||
sw.addEventListener('activate', handleActivate, { passive: true });
|
||||
sw.addEventListener('fetch', handleFetch, { passive: true });
|
||||
installMessageListener();
|
||||
installBroadcastChannelListener();
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/// <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;
|
||||
|
||||
export const installMessageListener = () => {
|
||||
sw.addEventListener('message', (event) => {
|
||||
if (!event.data?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
handleCancel(url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,69 +1,73 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
import { get, put } from './cache';
|
||||
|
||||
type PendingRequest = {
|
||||
controller: AbortController;
|
||||
promise: Promise<Response>;
|
||||
cleanupTimeout?: ReturnType<typeof setTimeout>;
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
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());
|
||||
const getCacheKey = (request: URL | Request) => {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
}
|
||||
|
||||
const pendingRequest: PendingRequest = {
|
||||
controller: new AbortController(),
|
||||
promise: undefined as unknown as Promise<Response>,
|
||||
cleanupTimeout: undefined,
|
||||
};
|
||||
pendingRequests.set(requestKey, pendingRequest);
|
||||
if (isRequest(request)) {
|
||||
return request.url;
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
throw new Error(`Invalid request: ${request}`);
|
||||
};
|
||||
|
||||
// Clone for the first caller to keep the original response unconsumed for future callers
|
||||
return pendingRequest.promise.then((response) => response.clone());
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCancel = (url: URL) => {
|
||||
const requestKey = getRequestKey(url);
|
||||
|
||||
const pendingRequest = pendingRequests.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
pendingRequest.controller.abort(CANCELATION_MESSAGE);
|
||||
if (pendingRequest.cleanupTimeout) {
|
||||
clearTimeout(pendingRequest.cleanupTimeout);
|
||||
}
|
||||
pendingRequests.delete(requestKey);
|
||||
const cacheKey = getCacheKey(url);
|
||||
const pendingRequest = pendingRequests.get(cacheKey);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequest.abort();
|
||||
pendingRequests.delete(cacheKey);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user