mirror of
https://github.com/immich-app/immich.git
synced 2025-01-04 02:46:47 +01: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:
parent
cae37657e9
commit
4e526dfaae
11 changed files with 321 additions and 330 deletions
|
@ -12,8 +12,11 @@ import {
|
||||||
ServerInfoApi,
|
ServerInfoApi,
|
||||||
ShareApi,
|
ShareApi,
|
||||||
SystemConfigApi,
|
SystemConfigApi,
|
||||||
|
ThumbnailFormat,
|
||||||
UserApi
|
UserApi
|
||||||
} from './open-api';
|
} from './open-api';
|
||||||
|
import { BASE_PATH } from './open-api/base';
|
||||||
|
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
|
||||||
|
|
||||||
export class ImmichApi {
|
export class ImmichApi {
|
||||||
public userApi: UserApi;
|
public userApi: UserApi;
|
||||||
|
@ -48,6 +51,21 @@ export class ImmichApi {
|
||||||
this.shareApi = new ShareApi(this.config);
|
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) {
|
public setAccessToken(accessToken: string) {
|
||||||
this.config.accessToken = accessToken;
|
this.config.accessToken = accessToken;
|
||||||
}
|
}
|
||||||
|
@ -59,6 +77,16 @@ export class ImmichApi {
|
||||||
public setBaseUrl(baseUrl: string) {
|
public setBaseUrl(baseUrl: string) {
|
||||||
this.config.basePath = baseUrl;
|
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' });
|
export const api = new ImmichApi({ basePath: '/api' });
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { quintOut } from 'svelte/easing';
|
import { quintOut } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div class="flex flex-wrap gap-[2px]">
|
<div class="flex flex-wrap gap-[2px]">
|
||||||
{#each album.assets as asset}
|
{#each album.assets as asset}
|
||||||
<ImmichThumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
on:click={() => (selectedThumbnail = asset)}
|
on:click={() => (selectedThumbnail = asset)}
|
||||||
selected={isSelected(asset.id)}
|
selected={isSelected(asset.id)}
|
||||||
|
|
|
@ -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)}
|
||||||
|
/>
|
140
web/src/lib/components/assets/thumbnail/thumbnail.svelte
Normal file
140
web/src/lib/components/assets/thumbnail/thumbnail.svelte
Normal 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>
|
|
@ -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}
|
|
@ -5,7 +5,6 @@
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { AssetResponseDto } from '@api';
|
import { AssetResponseDto } from '@api';
|
||||||
import lodash from 'lodash-es';
|
import lodash from 'lodash-es';
|
||||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
|
||||||
import {
|
import {
|
||||||
assetInteractionStore,
|
assetInteractionStore,
|
||||||
assetsInAlbumStoreState,
|
assetsInAlbumStoreState,
|
||||||
|
@ -14,6 +13,7 @@
|
||||||
selectedGroup
|
selectedGroup
|
||||||
} from '$lib/stores/asset-interaction.store';
|
} from '$lib/stores/asset-interaction.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
export let bucketDate: string;
|
export let bucketDate: string;
|
||||||
|
@ -156,7 +156,7 @@
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div class="flex flex-wrap gap-[2px]">
|
<div class="flex flex-wrap gap-[2px]">
|
||||||
{#each assetsInDateGroup as asset (asset.id)}
|
{#each assetsInDateGroup as asset (asset.id)}
|
||||||
<ImmichThumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
on:click={() => assetClickHandler(asset, assetsInDateGroup, dateGroupTitle)}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
|
import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
|
||||||
|
|
||||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||||
import ImmichThumbnail from '../../shared-components/immich-thumbnail.svelte';
|
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||||
|
@ -93,7 +93,7 @@
|
||||||
{#if assets.length > 0}
|
{#if assets.length > 0}
|
||||||
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
|
||||||
{#each assets as asset (asset.id)}
|
{#each assets as asset (asset.id)}
|
||||||
<ImmichThumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
{thumbnailSize}
|
{thumbnailSize}
|
||||||
publicSharedKey={sharedLink?.key}
|
publicSharedKey={sharedLink?.key}
|
||||||
|
|
|
@ -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>
|
|
24
web/src/lib/utils/time-to-seconds.spec.ts
Normal file
24
web/src/lib/utils/time-to-seconds.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
13
web/src/lib/utils/time-to-seconds.ts
Normal file
13
web/src/lib/utils/time-to-seconds.ts
Normal 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');
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.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 { AppRoute } from '$lib/constants';
|
||||||
import { AssetTypeEnum, SearchExploreItem } from '@api';
|
import { AssetTypeEnum, SearchExploreItem } from '@api';
|
||||||
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
|
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
|
||||||
|
@ -49,12 +49,7 @@
|
||||||
{#each places as item}
|
{#each places as item}
|
||||||
<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
|
<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
|
||||||
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
||||||
<ImmichThumbnail
|
<Thumbnail thumbnailSize={156} asset={item.data} readonly />
|
||||||
isRoundedCorner={true}
|
|
||||||
thumbnailSize={156}
|
|
||||||
asset={item.data}
|
|
||||||
readonly={true}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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]"
|
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}
|
{#each things as item}
|
||||||
<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
|
<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
|
||||||
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
||||||
<ImmichThumbnail
|
<Thumbnail thumbnailSize={156} asset={item.data} readonly />
|
||||||
isRoundedCorner={true}
|
|
||||||
thumbnailSize={156}
|
|
||||||
asset={item.data}
|
|
||||||
readonly={true}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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]"
|
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]"
|
||||||
|
|
Loading…
Reference in a new issue