mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00: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:
		| @@ -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]" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user