diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts new file mode 100644 index 0000000000..0918309596 --- /dev/null +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -0,0 +1,54 @@ +import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; +import { expect, test } from '@playwright/test'; +import { utils } from 'src/utils'; + +test.describe('Photo Viewer', () => { + let admin: LoginResponseDto; + let asset: AssetMediaResponseDto; + let rawAsset: AssetMediaResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + asset = await utils.createAsset(admin.accessToken); + rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); + }); + + test.beforeEach(async ({ context, page }) => { + // before each test, login as user + await utils.setAuthCookies(context, admin.accessToken); + await page.waitForLoadState('networkidle'); + }); + + 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(); + 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/); + }); + + 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(); + 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/); + }); + + 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 utils.replaceAsset(admin.accessToken, asset.id); + await expect(page.getByTestId('preview')).not.toHaveAttribute('src', initialSrc!); + }); +}); diff --git a/e2e/src/web/specs/timeline/utils.ts b/e2e/src/web/specs/timeline/utils.ts new file mode 100644 index 0000000000..6cd44cd784 --- /dev/null +++ b/e2e/src/web/specs/timeline/utils.ts @@ -0,0 +1,225 @@ +import { BrowserContext, expect, Page } from '@playwright/test'; +import { DateTime } from 'luxon'; +import { TimelineAssetConfig } from 'src/generators/timeline'; + +export const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + +export const padYearMonth = (yearMonth: string) => { + const [year, month] = yearMonth.split('-'); + return `${year}-${month.padStart(2, '0')}`; +}; + +export async function throttlePage(context: BrowserContext, page: Page) { + const session = await context.newCDPSession(page); + await session.send('Network.emulateNetworkConditions', { + offline: false, + downloadThroughput: (1.5 * 1024 * 1024) / 8, + uploadThroughput: (750 * 1024) / 8, + latency: 40, + connectionType: 'cellular3g', + }); + await session.send('Emulation.setCPUThrottlingRate', { rate: 10 }); +} + +export const poll = async ( + page: Page, + query: () => Promise, + callback?: (result: Awaited | undefined) => boolean, +) => { + let result; + const timeout = Date.now() + 10_000; + + const terminate = callback || ((result: Awaited | undefined) => !!result); + while (!terminate(result) && Date.now() < timeout) { + try { + result = await query(); + } catch { + // ignore + } + if (page.isClosed()) { + return; + } + try { + await page.waitForTimeout(50); + } catch { + return; + } + } + if (!result) { + // rerun to trigger error if any + result = await query(); + } + return result; +}; + +export const thumbnailUtils = { + locator(page: Page) { + return page.locator('[data-thumbnail-focus-container]'); + }, + withAssetId(page: Page, assetId: string) { + return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`); + }, + selectButton(page: Page, assetId: string) { + return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`); + }, + selectedAsset(page: Page) { + return page.locator('[data-thumbnail-focus-container][data-selected]'); + }, + async clickAssetId(page: Page, assetId: string) { + await thumbnailUtils.withAssetId(page, assetId).click(); + }, + async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) { + const assetIds: string[] = []; + for (const thumb of await this.locator(page).all()) { + const box = await thumb.boundingBox(); + if (box) { + const assetId = await thumb.evaluate((e) => e.dataset.asset); + if (collector?.(assetId!)) { + return [assetId!]; + } + assetIds.push(assetId!); + } + } + return assetIds; + }, + async getFirstInViewport(page: Page) { + return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true)); + }, + async getAllInViewport(page: Page, collector: (assetId: string) => boolean) { + return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector)); + }, + async expectThumbnailIsFavorite(page: Page, assetId: string) { + await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1); + }, + async expectThumbnailIsNotFavorite(page: Page, assetId: string) { + await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0); + }, + async expectThumbnailIsArchive(page: Page, assetId: string) { + await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1); + }, + async expectThumbnailIsNotArchive(page: Page, assetId: string) { + await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0); + }, + async expectSelectedReadonly(page: Page, assetId: string) { + await expect( + page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`), + ).toBeVisible(); + }, + async expectTimelineHasOnScreenAssets(page: Page) { + const first = await thumbnailUtils.getFirstInViewport(page); + if (page.isClosed()) { + return; + } + expect(first).toBeTruthy(); + }, + async expectInViewport(page: Page, assetId: string) { + const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox()); + if (page.isClosed()) { + return; + } + expect(box).toBeTruthy(); + }, + async expectBottomIsTimelineBottom(page: Page, assetId: string) { + const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox(); + const gridBox = await timelineUtils.locator(page).boundingBox(); + if (page.isClosed()) { + return; + } + expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0); + }, + async expectTopIsTimelineTop(page: Page, assetId: string) { + const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox(); + const gridBox = await timelineUtils.locator(page).boundingBox(); + if (page.isClosed()) { + return; + } + expect(box!.y).toBeCloseTo(gridBox!.y, 0); + }, +}; +export const timelineUtils = { + locator(page: Page) { + return page.locator('#asset-grid'); + }, + async waitForTimelineLoad(page: Page) { + await expect(timelineUtils.locator(page)).toBeInViewport(); + await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0); + }, + async getScrollTop(page: Page) { + const queryTop = () => + page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return document.querySelector('#asset-grid').scrollTop; + }); + await expect.poll(queryTop).toBeGreaterThan(0); + return await queryTop(); + }, +}; + +export const assetViewerUtils = { + locator(page: Page) { + return page.locator('#immich-asset-viewer'); + }, + async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { + await page + .locator( + `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`, + ) + .or( + page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`), + ) + .waitFor(); + }, + async expectActiveAssetToBe(page: Page, assetId: string) { + const activeElement = () => + page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return document.activeElement?.dataset?.asset; + }); + await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId); + }, +}; +export const pageUtils = { + async deepLinkPhotosPage(page: Page, assetId: string) { + await page.goto(`/photos?at=${assetId}`); + await timelineUtils.waitForTimelineLoad(page); + }, + async openPhotosPage(page: Page) { + await page.goto(`/photos`); + await timelineUtils.waitForTimelineLoad(page); + }, + async openFavorites(page: Page) { + await page.goto(`/favorites`); + await timelineUtils.waitForTimelineLoad(page); + }, + async openAlbumPage(page: Page, albumId: string) { + await page.goto(`/albums/${albumId}`); + await timelineUtils.waitForTimelineLoad(page); + }, + async openArchivePage(page: Page) { + await page.goto(`/archive`); + await timelineUtils.waitForTimelineLoad(page); + }, + async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) { + await page.goto(`/albums/${albumId}?at=${assetId}`); + await timelineUtils.waitForTimelineLoad(page); + }, + async goToAsset(page: Page, assetDate: string) { + await timelineUtils.locator(page).hover(); + const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa'); + await page.keyboard.press('g'); + await page.locator('#datetime').pressSequentially(stringDate); + await page.getByText('Confirm').click(); + }, + async selectDay(page: Page, day: string) { + await page.getByTitle(day).hover(); + await page.locator('[data-group] .w-8').click(); + }, + async pauseTestDebug() { + console.log('NOTE: pausing test indefinately for debug'); + await new Promise(() => void 0); + }, +}; diff --git a/web/src/lib/actions/image-loader.svelte.ts b/web/src/lib/actions/image-loader.svelte.ts new file mode 100644 index 0000000000..10b642065c --- /dev/null +++ b/web/src/lib/actions/image-loader.svelte.ts @@ -0,0 +1,207 @@ +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 type ImageLoaderProperties = { + imgClass?: ClassValue; + alt?: string; + draggable?: boolean; + role?: string; + style?: string; + title?: string | null; + loading?: 'lazy' | 'eager'; + dataAttributes?: Record; +}; +export type ImageSourceProperty = { + src: string | undefined; +}; +export type 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 () => {}; + } + return () => { + destroyed = true; + destroyImageElement(img, src, wrappedOnLoad, wrappedOnError); + }; +} + +export type LoadImageFunction = typeof loadImage; + +/** + * 1. Creates and appends an 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); + } + }, + }; +} diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 6288daa380..502565ef6c 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -1,8 +1,12 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { createZoomImageWheel } from '@zoom-image/core'; -export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState }); +export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => { + const zoomInstance = createZoomImageWheel(node, { + maxZoom: 10, + initialState: assetViewerManager.zoomState, + zoomTarget: options?.zoomTarget ?? null, + }); const unsubscribes = [ assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }), @@ -20,8 +24,9 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea node.style.overflow = 'visible'; return { - update(newOptions?: { disabled?: boolean }) { + update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) { options = newOptions; + zoomInstance.setState({ zoomTarget: newOptions?.zoomTarget ?? null }); }, destroy() { for (const unsubscribe of unsubscribes) { diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 19cc5afa8d..761887a465 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -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 | null; asset: AssetResponseDto }; + [AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | undefined; asset: AssetResponseDto }; [AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset }; [AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset }; [AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto }; diff --git a/web/src/lib/components/asset-viewer/adaptive-image.svelte b/web/src/lib/components/asset-viewer/adaptive-image.svelte new file mode 100644 index 0000000000..2fe919c7f8 --- /dev/null +++ b/web/src/lib/components/asset-viewer/adaptive-image.svelte @@ -0,0 +1,230 @@ + + +
+ + {#if blurredSlideshow} + + {/if} + + +
+ {#if asset.thumbhash} + + + {:else if showSpinner} +
+ +
+ {/if} + +
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', + }, + }} + >
+ + {#if showBrokenAsset} + + {:else} +
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?.()} +
+ +
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?.()} +
+ {/if} +
+
+ + diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 848870b654..eb46673d49 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,6 +1,7 @@ @@ -189,46 +121,29 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> -{#if imageError} -
- -
-{/if} - +
- {#if !imageLoaded} -
- -
- {:else if !imageError} -
- {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} - - {/if} - {$getAltText(toTimelineAsset(asset))} + onReady?.()} + onError={() => { + onError?.(); + onReady?.(); + }} + bind:imgElement={assetViewerManager.imgRef} + > + {#snippet overlays()} {#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
{/each} -
+ {/snippet} +
- {#if isFaceEditMode.value} - - {/if} + {#if isFaceEditMode.value && assetViewerManager.imgRef} + {/if}
- - diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index a15a787e64..16903112d3 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -2,24 +2,34 @@ import { Icon } from '@immich/ui'; import { mdiImageBrokenVariant } from '@mdi/js'; import { t } from 'svelte-i18n'; + import type { ClassValue } from 'svelte/elements'; interface Props { - class?: string; + class?: ClassValue; 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');
- + {#if clientWidth >= 75} + + {/if} {#if !hideMessage} - {$t('error_loading_image')} + {$t('error_loading_image')} {/if}
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index a1dd22f44f..00ba3addb7 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -1,9 +1,8 @@ {#if errored} {:else} - {loaded +
{/if} {#if hidden} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 8270646470..e750d4bd77 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -200,8 +200,9 @@
- -
-
+ ((loaded = true), (thumbError = errored))} + /> + {#if asset.isVideo} +
+ +
+ {:else if asset.isImage && asset.livePhotoVideoId} +
+ +
+ {:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver} + +
+
+ +
+ + + +
+
+ {/if} + + {#if (!loaded || thumbError) && asset.thumbhash} + + {/if} +
{#if !usingMobileDevice && !disabled}
@@ -261,7 +322,10 @@ {#if dimmed && !mouseOver} -
+
{/if} @@ -329,72 +393,6 @@ > {/if} - - ((loaded = true), (thumbError = errored))} - /> - {#if asset.isVideo} -
- -
- {:else if asset.isImage && asset.livePhotoVideoId} -
- -
- {:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver} - -
-
- -
- - - -
-
- {/if} - - {#if (!loaded || thumbError) && asset.thumbhash} - - {/if}
{#if selectionCandidate} @@ -427,11 +425,14 @@ {/if} {/if} + + +
- - diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index 222fa7a8ec..6a9df58a61 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -2,6 +2,7 @@ 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; @@ -12,6 +13,7 @@ curve?: boolean; playIcon?: string; pauseIcon?: string; + class?: ClassValue; } let { @@ -23,6 +25,7 @@ curve = false, playIcon = mdiPlayCircleOutline, pauseIcon = mdiPauseCircleOutline, + class: className, }: Props = $props(); let remainingSeconds = $state(durationInSeconds); @@ -57,7 +60,7 @@ {#if enablePlayback}