Skip to content

Commit 49791dd

Browse files
committed
bugfixes in thumbnail
1 parent 7ddcfe4 commit 49791dd

5 files changed

Lines changed: 110 additions & 98 deletions

File tree

‎e2e/src/web/specs/timeline/utils.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
6565
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
6666
},
6767
selectedAsset(page: Page) {
68-
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
68+
return page.locator('[data-thumbnail-focus-container][data-selected]');
6969
},
7070
async clickAssetId(page: Page, assetId: string) {
7171
await thumbnailUtils.withAssetId(page, assetId).click();
@@ -103,11 +103,8 @@ export const thumbnailUtils = {
103103
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
104104
},
105105
async expectSelectedReadonly(page: Page, assetId: string) {
106-
// todo - need a data attribute for selected
107106
await expect(
108-
page.locator(
109-
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
110-
),
107+
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
111108
).toBeVisible();
112109
},
113110
async expectTimelineHasOnScreenAssets(page: Page) {

‎web/src/lib/components/assets/broken-asset.svelte‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,34 @@
22
import { Icon } from '@immich/ui';
33
import { mdiImageBrokenVariant } from '@mdi/js';
44
import { t } from 'svelte-i18n';
5+
import type { ClassValue } from 'svelte/elements';
56
67
interface Props {
7-
class?: string;
8+
class?: ClassValue;
89
hideMessage?: boolean;
910
width?: string | undefined;
1011
height?: string | undefined;
1112
}
1213
1314
let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
15+
16+
let clientWidth = $state(0);
17+
let textClass = $derived(clientWidth < 100 ? 'text-xs' : clientWidth < 150 ? 'text-sm' : 'text-base');
1418
</script>
1519

1620
<div
17-
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}"
21+
bind:clientWidth
22+
class={[
23+
'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',
24+
className,
25+
]}
1826
style:width
1927
style:height
2028
>
21-
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
29+
{#if clientWidth >= 75}
30+
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
31+
{/if}
2232
{#if !hideMessage}
23-
<span class="text-center">{$t('error_loading_image')}</span>
33+
<span class="text-center {textClass}">{$t('error_loading_image')}</span>
2434
{/if}
2535
</div>

‎web/src/lib/components/assets/thumbnail/image-thumbnail.svelte‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,24 @@
4848
loaded = true;
4949
onComplete?.(false);
5050
};
51+
5152
const setErrored = () => {
5253
errored = true;
5354
onComplete?.(true);
5455
};
5556
56-
let optionalClasses = $derived(
57+
let optionalClasses = $derived([
5758
[
5859
curve && 'rounded-xl',
5960
circle && 'rounded-full',
6061
shadow && 'shadow-lg',
6162
(circle || !heightStyle) && 'aspect-square',
6263
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
63-
brokenAssetClass,
6464
]
6565
.filter(Boolean)
6666
.join(' '),
67-
);
67+
brokenAssetClass,
68+
]);
6869
6970
let style = $derived(
7071
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
@@ -79,7 +80,7 @@
7980
src: url,
8081
onLoad: setLoaded,
8182
onError: setErrored,
82-
imgClass: ['object-cover', optionalClasses, imageClass],
83+
imgClass: ['object-cover', imageClass],
8384
style,
8485
alt: loaded || errored ? altText : '',
8586
draggable: false,

‎web/src/lib/components/assets/thumbnail/thumbnail.svelte‎

Lines changed: 85 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,9 @@
200200

201201
<div
202202
class={[
203-
'focus-visible:outline-none flex overflow-hidden',
203+
'group focus-visible:outline-none focus-visible:rounded-lg flex overflow-hidden',
204204
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
205+
{ 'rounded-xl': selected },
205206
]}
206207
style:width="{width}px"
207208
style:height="{height}px"
@@ -223,18 +224,10 @@
223224
bind:this={element}
224225
data-asset={asset.id}
225226
data-thumbnail-focus-container
227+
data-selected={selected ? true : undefined}
226228
tabindex={0}
227229
role="link"
228230
>
229-
<!-- Outline on focus -->
230-
<div
231-
class={[
232-
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
233-
{ 'rounded-xl': selected },
234-
]}
235-
data-outline
236-
></div>
237-
238231
<div
239232
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
240233
style:width="inherit"
@@ -247,21 +240,92 @@
247240
{ 'rounded-xl': selected },
248241
]}
249242
>
243+
<ImageThumbnail
244+
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
245+
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
246+
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
247+
altText={$getAltText(asset)}
248+
widthStyle="{width}px"
249+
heightStyle="{height}px"
250+
curve={selected}
251+
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
252+
/>
253+
{#if asset.isVideo}
254+
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
255+
<VideoThumbnail
256+
class="group-focus-visible:rounded-lg"
257+
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
258+
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
259+
curve={selected}
260+
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
261+
playbackOnIconHover={!$playVideoThumbnailOnHover}
262+
/>
263+
</div>
264+
{:else if asset.isImage && asset.livePhotoVideoId}
265+
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
266+
<VideoThumbnail
267+
class="group-focus-visible:rounded-lg"
268+
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
269+
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
270+
pauseIcon={mdiMotionPauseOutline}
271+
playIcon={mdiMotionPlayOutline}
272+
showTime={false}
273+
curve={selected}
274+
playbackOnIconHover={!$playVideoThumbnailOnHover}
275+
/>
276+
</div>
277+
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
278+
<!-- GIF -->
279+
<div class="absolute top-0 h-full w-full pointer-events-none">
280+
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
281+
<ImageThumbnail
282+
class={imageClass}
283+
{brokenAssetClass}
284+
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
285+
altText={$getAltText(asset)}
286+
widthStyle="{width}px"
287+
heightStyle="{height}px"
288+
curve={selected}
289+
/>
290+
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
291+
<span class="pe-2 pt-2">
292+
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
293+
</span>
294+
</div>
295+
</div>
296+
{/if}
297+
298+
{#if (!loaded || thumbError) && asset.thumbhash}
299+
<canvas
300+
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
301+
data-testid="thumbhash"
302+
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
303+
style:width="{width}px"
304+
style:height="{height}px"
305+
class:rounded-xl={selected}
306+
draggable="false"
307+
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
308+
></canvas>
309+
{/if}
310+
250311
<!-- icon overlay -->
251312
<div>
252313
<!-- Gradient overlay on hover -->
253314
{#if !usingMobileDevice && !disabled}
254315
<div
255316
class={[
256-
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
317+
'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',
257318
{ 'rounded-xl': selected },
258319
]}
259320
></div>
260321
{/if}
261322

262323
<!-- Dimmed support -->
263324
{#if dimmed && !mouseOver}
264-
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
325+
<div
326+
id="a"
327+
class={['absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
328+
></div>
265329
{/if}
266330

267331
<!-- Favorite asset star -->
@@ -329,72 +393,6 @@
329393
>
330394
</a>
331395
{/if}
332-
333-
<ImageThumbnail
334-
class={imageClass}
335-
{brokenAssetClass}
336-
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
337-
altText={$getAltText(asset)}
338-
widthStyle="{width}px"
339-
heightStyle="{height}px"
340-
curve={selected}
341-
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
342-
/>
343-
{#if asset.isVideo}
344-
<div class="absolute top-0 h-full w-full pointer-events-none">
345-
<VideoThumbnail
346-
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
347-
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
348-
curve={selected}
349-
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
350-
playbackOnIconHover={!$playVideoThumbnailOnHover}
351-
/>
352-
</div>
353-
{:else if asset.isImage && asset.livePhotoVideoId}
354-
<div class="absolute top-0 h-full w-full pointer-events-none">
355-
<VideoThumbnail
356-
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
357-
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
358-
pauseIcon={mdiMotionPauseOutline}
359-
playIcon={mdiMotionPlayOutline}
360-
showTime={false}
361-
curve={selected}
362-
playbackOnIconHover={!$playVideoThumbnailOnHover}
363-
/>
364-
</div>
365-
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
366-
<!-- GIF -->
367-
<div class="absolute top-0 h-full w-full pointer-events-none">
368-
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
369-
<ImageThumbnail
370-
class={imageClass}
371-
{brokenAssetClass}
372-
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
373-
altText={$getAltText(asset)}
374-
widthStyle="{width}px"
375-
heightStyle="{height}px"
376-
curve={selected}
377-
/>
378-
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
379-
<span class="pe-2 pt-2">
380-
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
381-
</span>
382-
</div>
383-
</div>
384-
{/if}
385-
386-
{#if (!loaded || thumbError) && asset.thumbhash}
387-
<canvas
388-
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
389-
data-testid="thumbhash"
390-
class="absolute top-0 object-cover"
391-
style:width="{width}px"
392-
style:height="{height}px"
393-
class:rounded-xl={selected}
394-
draggable="false"
395-
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
396-
></canvas>
397-
{/if}
398396
</div>
399397

400398
{#if selectionCandidate}
@@ -427,11 +425,14 @@
427425
{/if}
428426
</button>
429427
{/if}
428+
429+
<!-- Outline on focus -->
430+
<div
431+
class={[
432+
'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',
433+
{ 'rounded-xl': selected },
434+
]}
435+
data-outline
436+
></div>
430437
</div>
431438
</div>
432-
433-
<style>
434-
[data-asset]:focus-visible > [data-outline] {
435-
outline-style: solid;
436-
}
437-
</style>

‎web/src/lib/components/assets/thumbnail/video-thumbnail.svelte‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { Icon, LoadingSpinner } from '@immich/ui';
33
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
44
import { Duration } from 'luxon';
5+
import type { ClassValue } from 'svelte/elements';
56
67
interface Props {
78
url: string;
@@ -12,6 +13,7 @@
1213
curve?: boolean;
1314
playIcon?: string;
1415
pauseIcon?: string;
16+
class?: ClassValue;
1517
}
1618
1719
let {
@@ -23,6 +25,7 @@
2325
curve = false,
2426
playIcon = mdiPlayCircleOutline,
2527
pauseIcon = mdiPauseCircleOutline,
28+
class: className,
2629
}: Props = $props();
2730
2831
let remainingSeconds = $state(durationInSeconds);
@@ -57,7 +60,7 @@
5760
{#if enablePlayback}
5861
<video
5962
bind:this={player}
60-
class="h-full w-full object-cover"
63+
class={['h-full w-full object-cover', className]}
6164
class:rounded-xl={curve}
6265
muted
6366
autoplay

0 commit comments

Comments
 (0)