bugfixes in thumbnail

This commit is contained in:
midzelis 2026-01-24 23:43:18 +00:00
parent 5dc3a4810a
commit 70953a5ba9
5 changed files with 110 additions and 98 deletions

View File

@ -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]:has(button[aria-checked])');
return page.locator('[data-thumbnail-focus-container][data-selected]');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
@ -103,11 +103,8 @@ 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}"] > .group.cursor-not-allowed > .rounded-xl`,
),
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {

View File

@ -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');
</script>
<div
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}"
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,
]}
style:width
style:height
>
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
{#if clientWidth >= 75}
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
{/if}
{#if !hideMessage}
<span class="text-center">{$t('error_loading_image')}</span>
<span class="text-center {textClass}">{$t('error_loading_image')}</span>
{/if}
</div>

View File

@ -48,23 +48,24 @@
loaded = true;
onComplete?.(false);
};
const setErrored = () => {
errored = true;
onComplete?.(true);
};
let optionalClasses = $derived(
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'};`,
@ -79,7 +80,7 @@
src: url,
onLoad: setLoaded,
onError: setErrored,
imgClass: ['object-cover', optionalClasses, imageClass],
imgClass: ['object-cover', imageClass],
style,
alt: loaded || errored ? altText : '',
draggable: false,

View File

@ -200,8 +200,9 @@
<div
class={[
'focus-visible:outline-none flex overflow-hidden',
'group focus-visible:outline-none focus-visible:rounded-lg 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"
@ -223,18 +224,10 @@
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"
@ -247,13 +240,81 @@
{ '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',
'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',
{ 'rounded-xl': selected },
]}
></div>
@ -261,7 +322,10 @@
<!-- Dimmed support -->
{#if dimmed && !mouseOver}
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
<div
id="a"
class={['absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
></div>
{/if}
<!-- Favorite asset star -->
@ -329,72 +393,6 @@
>
</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}
@ -427,11 +425,14 @@
{/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-visible > [data-outline] {
outline-style: solid;
}
</style>

View File

@ -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}
<video
bind:this={player}
class="h-full w-full object-cover"
class={['h-full w-full object-cover', className]}
class:rounded-xl={curve}
muted
autoplay