1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(web): improve and refactor thumbnails (#2087)

* feat(web): improve and refactor thumbnails

* only play live photos on icon hover
This commit is contained in:
Michel Heusschen 2023-03-27 05:53:35 +02:00 committed by GitHub
parent cae37657e9
commit 4e526dfaae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 321 additions and 330 deletions

View File

@ -12,8 +12,11 @@ import {
ServerInfoApi,
ShareApi,
SystemConfigApi,
ThumbnailFormat,
UserApi
} from './open-api';
import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
export class ImmichApi {
public userApi: UserApi;
@ -48,6 +51,21 @@ export class ImmichApi {
this.shareApi = new ShareApi(this.config);
}
private createUrl(path: string, params?: Record<string, unknown>) {
const searchParams = new URLSearchParams();
for (const key in params) {
const value = params[key];
if (value !== undefined && value !== null) {
searchParams.set(key, value.toString());
}
}
const url = new URL(path, DUMMY_BASE_URL);
url.search = searchParams.toString();
return (this.config.basePath || BASE_PATH) + toPathString(url);
}
public setAccessToken(accessToken: string) {
this.config.accessToken = accessToken;
}
@ -59,6 +77,16 @@ export class ImmichApi {
public setBaseUrl(baseUrl: string) {
this.config.basePath = baseUrl;
}
public getAssetFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean, key?: string) {
const path = `/asset/file/${assetId}`;
return this.createUrl(path, { isThumb, isWeb, key });
}
public getAssetThumbnailUrl(assetId: string, format?: ThumbnailFormat, key?: string) {
const path = `/asset/thumbnail/${assetId}`;
return this.createUrl(path, { format, key });
}
}
export const api = new ImmichApi({ basePath: '/api' });

View File

@ -3,8 +3,8 @@
import { createEventDispatcher } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
export let album: AlbumResponseDto;
@ -43,7 +43,7 @@
<!-- Image grid -->
<div class="flex flex-wrap gap-[2px]">
{#each album.assets as asset}
<ImmichThumbnail
<Thumbnail
{asset}
on:click={() => (selectedThumbnail = asset)}
selected={isSelected(asset.id)}

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let url: string;
export let altText: string;
export let heightStyle: string;
export let widthStyle: string;
let loading = true;
</script>
<img
style:width={widthStyle}
style:height={heightStyle}
src={url}
alt={altText}
class="object-cover transition-opacity duration-300"
class:opacity-0={loading}
draggable="false"
on:load|once={() => (loading = false)}
/>

View File

@ -0,0 +1,140 @@
<script lang="ts">
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { timeToSeconds } from '$lib/utils/time-to-seconds';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
import { createEventDispatcher } from 'svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import Star from 'svelte-material-icons/Star.svelte';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
const dispatch = createEventDispatcher();
export let asset: AssetResponseDto;
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false;
export let disabled = false;
export let readonly = false;
export let publicSharedKey: string | undefined = undefined;
let mouseOver = false;
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
$: [width, height] = (() => {
if (thumbnailSize) {
return [thumbnailSize, thumbnailSize];
}
if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
return [176, 235];
} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
return [313, 235];
} else {
return [235, 235];
}
})();
const thumbnailClickedHandler = () => {
if (!disabled) {
dispatch('click', { asset });
}
};
const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation();
if (!disabled) {
dispatch('select', { asset });
}
};
</script>
<IntersectionObserver once={false} let:intersecting>
<div
style:width="{width}px"
style:height="{height}px"
class="relative group {disabled ? 'bg-gray-300' : 'bg-immich-primary/20'}"
class:cursor-not-allowed={disabled}
class:hover:cursor-pointer={!disabled}
on:mouseenter={() => (mouseOver = true)}
on:mouseleave={() => (mouseOver = false)}
on:click={thumbnailClickedHandler}
on:keydown={thumbnailClickedHandler}
>
{#if intersecting}
<div class="absolute w-full h-full z-20">
<!-- Select asset button -->
{#if !readonly}
<button
on:click={onIconClickedHandler}
class="absolute p-2 group-hover:block"
class:group-hover:block={!disabled}
class:hidden={!selected}
class:cursor-not-allowed={disabled}
role="checkbox"
aria-checked={selected}
{disabled}
>
{#if disabled}
<CheckCircle size="24" class="text-zinc-800" />
{:else if selected}
<CheckCircle size="24" class="text-immich-primary" />
{:else}
<CheckCircle size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{/if}
</div>
<div
class="bg-gray-100 dark:bg-immich-dark-gray absolute select-none transition-transform"
class:scale-[0.85]={selected}
>
<!-- Gradient overlay on hover -->
<div
class="absolute w-full h-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 group-hover:opacity-100 transition-opacity z-10"
/>
<!-- Favorite asset star -->
{#if asset.isFavorite && !publicSharedKey}
<div class="absolute bottom-2 left-2 z-10">
<Star size="24" class="text-white" />
</div>
{/if}
<ImageThumbnail
url={api.getAssetThumbnailUrl(asset.id, format, publicSharedKey)}
altText={asset.exifInfo?.imageName ?? asset.id}
widthStyle="{width}px"
heightStyle="{height}px"
/>
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute w-full h-full top-0">
<VideoThumbnail
url={api.getAssetFileUrl(asset.id, false, true, publicSharedKey)}
enablePlayback={mouseOver}
durationInSeconds={timeToSeconds(asset.duration)}
/>
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute w-full h-full top-0">
<VideoThumbnail
url={api.getAssetFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey)}
pauseIcon={MotionPauseOutline}
playIcon={MotionPlayOutline}
showTime={false}
playbackOnIconHover
/>
</div>
{/if}
</div>
{/if}
</div>
</IntersectionObserver>

View File

@ -0,0 +1,88 @@
<script lang="ts">
import { Duration } from 'luxon';
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import AlertCircleOutline from 'svelte-material-icons/AlertCircleOutline.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
export let url: string;
export let durationInSeconds = 0;
export let enablePlayback = false;
export let playbackOnIconHover = false;
export let showTime = true;
export let playIcon = PlayCircleOutline;
export let pauseIcon = PauseCircleOutline;
let remainingSeconds = durationInSeconds;
let loading = true;
let error = false;
let player: HTMLVideoElement;
$: if (!enablePlayback) {
// Reset remaining time when playback is disabled.
remainingSeconds = durationInSeconds;
if (player) {
// Cancel video buffering.
player.src = '';
}
}
</script>
<div
class="absolute right-0 top-0 text-white text-xs font-medium flex gap-1 place-items-center z-20"
>
{#if showTime}
<span class="pt-2">
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')}
</span>
{/if}
<span
class="pt-2 pr-2"
on:mouseenter={() => {
if (playbackOnIconHover) {
enablePlayback = true;
}
}}
on:mouseleave={() => {
if (playbackOnIconHover) {
enablePlayback = false;
}
}}
>
{#if enablePlayback}
{#if loading}
<LoadingSpinner />
{:else if error}
<AlertCircleOutline size="24" class="text-red-600" />
{:else}
<svelte:component this={pauseIcon} size="24" />
{/if}
{:else}
<svelte:component this={playIcon} size="24" />
{/if}
</span>
</div>
{#if enablePlayback}
<video
bind:this={player}
class="w-full h-full object-cover"
muted
autoplay
src={url}
on:play={() => {
loading = false;
error = false;
}}
on:error={() => {
error = true;
loading = false;
}}
on:timeupdate={({ currentTarget }) => {
const remaining = currentTarget.duration - currentTarget.currentTime;
remainingSeconds = Math.min(Math.ceil(remaining), durationInSeconds);
}}
/>
{/if}

View File

@ -5,7 +5,6 @@
import { fly } from 'svelte/transition';
import { AssetResponseDto } from '@api';
import lodash from 'lodash-es';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import {
assetInteractionStore,
assetsInAlbumStoreState,
@ -14,6 +13,7 @@
selectedGroup
} from '$lib/stores/asset-interaction.store';
import { locale } from '$lib/stores/preferences.store';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
export let assets: AssetResponseDto[];
export let bucketDate: string;
@ -156,7 +156,7 @@
<!-- Image grid -->
<div class="flex flex-wrap gap-[2px]">
{#each assetsInDateGroup as asset (asset.id)}
<ImmichThumbnail
<Thumbnail
{asset}
{groupIndex}
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}

View File

@ -1,10 +1,10 @@
<script lang="ts">
import { page } from '$app/stores';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { handleError } from '$lib/utils/handle-error';
import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ImmichThumbnail from '../../shared-components/immich-thumbnail.svelte';
export let assets: AssetResponseDto[];
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@ -93,7 +93,7 @@
{#if assets.length > 0}
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
{#each assets as asset (asset.id)}
<ImmichThumbnail
<Thumbnail
{asset}
{thumbnailSize}
publicSharedKey={sharedLink?.key}

View File

@ -1,311 +0,0 @@
<script lang="ts">
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
import { createEventDispatcher } from 'svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import Star from 'svelte-material-icons/Star.svelte';
import { fade, fly } from 'svelte/transition';
import LoadingSpinner from './loading-spinner.svelte';
const dispatch = createEventDispatcher();
export let asset: AssetResponseDto;
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false;
export let disabled = false;
export let readonly = false;
export let publicSharedKey = '';
export let isRoundedCorner = false;
let mouseOver = false;
let playMotionVideo = false;
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
let mouseOverIcon = false;
let videoPlayerNode: HTMLVideoElement;
let isImageLoading = true;
let isThumbnailVideoPlaying = false;
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
let videoProgress = '00:00';
let videoUrl: string;
$: isPublicShared = publicSharedKey !== '';
const loadVideoData = async (isLivePhoto: boolean) => {
isThumbnailVideoPlaying = false;
if (isLivePhoto && asset.livePhotoVideoId) {
videoUrl = getFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey);
} else {
videoUrl = getFileUrl(asset.id, false, true, publicSharedKey);
}
};
const getVideoDurationInString = (currentTime: number) => {
const minute = Math.floor(currentTime / 60);
const second = currentTime % 60;
const minuteText = minute >= 10 ? `${minute}` : `0${minute}`;
const secondText = second >= 10 ? `${second}` : `0${second}`;
return minuteText + ':' + secondText;
};
const parseVideoDuration = (duration: string) => {
duration = duration || '0:00:00.00000';
const timePart = duration.split(':');
const hours = timePart[0];
const minutes = timePart[1];
const seconds = timePart[2];
if (hours != '0') {
return `${hours}:${minutes}`;
} else {
return `${minutes}:${seconds.split('.')[0]}`;
}
};
const getSize = () => {
if (thumbnailSize) {
return `w-[${thumbnailSize}px] h-[${thumbnailSize}px]`;
}
if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
return 'w-[176px] h-[235px]';
} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
return 'w-[313px] h-[235px]';
} else {
return 'w-[235px] h-[235px]';
}
};
const handleMouseOverThumbnail = () => {
mouseOver = true;
};
const handleMouseLeaveThumbnail = () => {
mouseOver = false;
videoUrl = '';
clearInterval(calculateVideoDurationIntervalHandler);
isThumbnailVideoPlaying = false;
videoProgress = '00:00';
if (videoPlayerNode) {
videoPlayerNode.pause();
}
};
const handleCanPlay = (ev: Event) => {
const playerNode = ev.target as HTMLVideoElement;
playerNode.muted = true;
playerNode.play();
isThumbnailVideoPlaying = true;
calculateVideoDurationIntervalHandler = setInterval(() => {
videoProgress = getVideoDurationInString(Math.round(playerNode.currentTime));
}, 1000);
};
$: getThumbnailBorderStyle = () => {
if (selected) {
return 'border-[20px] border-immich-primary/20';
} else if (disabled) {
return 'border-[20px] border-gray-300';
} else if (isRoundedCorner) {
return 'rounded-lg';
} else {
return '';
}
};
$: getOverlaySelectorIconStyle = () => {
if (selected || disabled) {
return '';
} else {
return 'bg-gradient-to-b from-gray-800/50';
}
};
const thumbnailClickedHandler = () => {
if (!disabled) {
dispatch('click', { asset });
}
};
const onIconClickedHandler = (e: MouseEvent) => {
e.stopPropagation();
if (!disabled) {
dispatch('select', { asset });
}
};
</script>
<IntersectionObserver once={false} let:intersecting>
<div
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
class={`bg-gray-100 dark:bg-immich-dark-gray relative select-none ${getSize()} ${
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
}`}
on:mouseenter={handleMouseOverThumbnail}
on:mouseleave={handleMouseLeaveThumbnail}
on:click={thumbnailClickedHandler}
on:keydown={thumbnailClickedHandler}
>
{#if (mouseOver || selected || disabled) && !readonly}
<div
in:fade={{ duration: 200 }}
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
>
<button
on:click={onIconClickedHandler}
on:mouseenter={() => (mouseOverIcon = true)}
on:mouseleave={() => (mouseOverIcon = false)}
class="inline-block"
>
{#if selected}
<CheckCircle size="24" color="#4250af" />
{:else if disabled}
<CheckCircle size="24" color="#252525" />
{:else}
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
{/if}
</button>
</div>
{/if}
{#if asset.isFavorite && !isPublicShared}
<div class="w-full absolute bottom-2 left-2 z-10">
<Star size="24" color={'white'} />
</div>
{/if}
<!-- Playback and info -->
{#if asset.type === AssetTypeEnum.Video}
<div
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
>
{#if isThumbnailVideoPlaying}
<span in:fly={{ x: -25, duration: 500 }}>
{videoProgress}
</span>
{:else}
<span in:fade={{ duration: 500 }}>
{parseVideoDuration(asset.duration)}
</span>
{/if}
{#if mouseOver}
{#if isThumbnailVideoPlaying}
<span in:fly={{ x: 25, duration: 500 }}>
<PauseCircleOutline size="24" />
</span>
{:else}
<span in:fade={{ duration: 250 }}>
<LoadingSpinner />
</span>
{/if}
{:else}
<span in:fade={{ duration: 500 }}>
<PlayCircleOutline size="24" />
</span>
{/if}
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
>
<span
in:fade={{ duration: 500 }}
on:mouseenter={() => {
playMotionVideo = true;
loadVideoData(true);
}}
on:mouseleave={() => (playMotionVideo = false)}
>
{#if playMotionVideo}
<span in:fade={{ duration: 500 }}>
<MotionPauseOutline size="24" />
</span>
{:else}
<span in:fade={{ duration: 500 }}>
<MotionPlayOutline size="24" />
</span>
{/if}
</span>
<!-- {/if} -->
</div>
{/if}
<!-- Thumbnail -->
{#if intersecting}
<img
id={asset.id}
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
src={`/api/asset/thumbnail/${asset.id}?format=${format}&key=${publicSharedKey}`}
alt={asset.id}
class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
class:opacity-0={isImageLoading}
loading="lazy"
draggable="false"
on:load|once={() => (isImageLoading = false)}
/>
{/if}
{#if mouseOver && asset.type === AssetTypeEnum.Video}
<div class="absolute w-full h-full top-0" on:mouseenter={() => loadVideoData(false)}>
{#if videoUrl}
<video
muted
autoplay
preload="none"
class="h-full object-cover"
width="250px"
style:width={`${thumbnailSize}px`}
on:canplay={handleCanPlay}
bind:this={videoPlayerNode}
>
<source src={videoUrl} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
</div>
{/if}
{#if playMotionVideo && asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute w-full h-full top-0">
{#if videoUrl}
<video
muted
autoplay
preload="none"
class="h-full object-cover"
width="250px"
style:width={`${thumbnailSize}px`}
on:canplay={handleCanPlay}
bind:this={videoPlayerNode}
>
<source src={videoUrl} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
</div>
{/if}
</div>
</IntersectionObserver>
<style>
img {
transition: 0.2s ease all;
}
</style>

View File

@ -0,0 +1,24 @@
import { describe, it, expect } from '@jest/globals';
import { timeToSeconds } from './time-to-seconds';
describe('converting time to seconds', () => {
it('parses hh:mm:ss correctly', () => {
expect(timeToSeconds('01:02:03')).toBeCloseTo(3723);
});
it('parses hh:mm:ss.SSS correctly', () => {
expect(timeToSeconds('01:02:03.456')).toBeCloseTo(3723.456);
});
it('parses h:m:s.S correctly', () => {
expect(timeToSeconds('1:2:3.4')).toBeCloseTo(3723.4);
});
it('parses hhh:mm:ss.SSS correctly', () => {
expect(timeToSeconds('100:02:03.456')).toBeCloseTo(360123.456);
});
it('ignores ignores double milliseconds hh:mm:ss.SSS.SSSSSS', () => {
expect(timeToSeconds('01:02:03.456.123456')).toBeCloseTo(3723.456);
});
});

View File

@ -0,0 +1,13 @@
import { Duration } from 'luxon';
/**
* Convert time like `01:02:03.456` to seconds.
*/
export function timeToSeconds(time: string) {
const parts = time.split(':');
parts[2] = parts[2].split('.').slice(0, 2).join('.');
const [hours, minutes, seconds] = parts.map(Number);
return Duration.fromObject({ hours, minutes, seconds }).as('seconds');
}

View File

@ -1,6 +1,6 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
import { AppRoute } from '$lib/constants';
import { AssetTypeEnum, SearchExploreItem } from '@api';
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
@ -49,12 +49,7 @@
{#each places as item}
<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
<div class="filter brightness-75 rounded-xl overflow-hidden">
<ImmichThumbnail
isRoundedCorner={true}
thumbnailSize={156}
asset={item.data}
readonly={true}
/>
<Thumbnail thumbnailSize={156} asset={item.data} readonly />
</div>
<span
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
@ -76,12 +71,7 @@
{#each things as item}
<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
<div class="filter brightness-75 rounded-xl overflow-hidden">
<ImmichThumbnail
isRoundedCorner={true}
thumbnailSize={156}
asset={item.data}
readonly={true}
/>
<Thumbnail thumbnailSize={156} asset={item.data} readonly />
</div>
<span
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"