mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04: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
					
				| @ -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' }); | ||||
|  | ||||
| @ -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)} | ||||
|  | ||||
| @ -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 { 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)} | ||||
|  | ||||
| @ -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} | ||||
|  | ||||
| @ -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"> | ||||
| 	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]" | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user