immich/web/src/lib/components/shared-components/immich-thumbnail.svelte
Jason Rasmussen 2ca560ebf8
feat(web,server): explore (#1926)
* feat: explore

* chore: generate open api

* styling explore page

* styling no result page

* style overlay

* style: bluring text on thumbnail card for readability

* explore page tweaks

* fix(web): search urls

* feat(web): use objects for things

* feat(server): filter by motion, sort by createdAt

* More styling

* better navigation

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2023-03-05 14:44:31 -06:00

312 lines
8.4 KiB
Svelte

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