Bläddra i källkod

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

* feat(web): improve and refactor thumbnails

* only play live photos on icon hover
Michel Heusschen 2 år sedan
förälder
incheckning
4e526dfaae

+ 28 - 0
web/src/api/api.ts

@@ -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' });

+ 2 - 2
web/src/lib/components/album-page/thumbnail-selection.svelte

@@ -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)}

+ 19 - 0
web/src/lib/components/assets/thumbnail/image-thumbnail.svelte

@@ -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 - 0
web/src/lib/components/assets/thumbnail/thumbnail.svelte

@@ -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>

+ 88 - 0
web/src/lib/components/assets/thumbnail/video-thumbnail.svelte

@@ -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}

+ 2 - 2
web/src/lib/components/photos-page/asset-date-group.svelte

@@ -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)}

+ 2 - 2
web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte

@@ -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}

+ 0 - 311
web/src/lib/components/shared-components/immich-thumbnail.svelte

@@ -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 - 0
web/src/lib/utils/time-to-seconds.spec.ts

@@ -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 - 0
web/src/lib/utils/time-to-seconds.ts

@@ -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');
+}

+ 3 - 13
web/src/routes/(user)/explore/+page.svelte

@@ -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]"