123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- <script lang="ts">
- import { createEventDispatcher, onDestroy } from 'svelte';
- import { fade, fly } from 'svelte/transition';
- import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
- import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
- import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
- import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
- import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
- import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
- import LoadingSpinner from './loading-spinner.svelte';
- import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
- 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;
- let imageData: string;
- let mouseOver = false;
- let playMotionVideo = false;
- $: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
- let mouseOverIcon = false;
- let videoPlayerNode: HTMLVideoElement;
- let isThumbnailVideoPlaying = false;
- let calculateVideoDurationIntervalHandler: NodeJS.Timer;
- let videoProgress = '00:00';
- let videoUrl: string;
- const loadVideoData = async (isLivePhoto: boolean) => {
- isThumbnailVideoPlaying = false;
- if (isLivePhoto && asset.livePhotoVideoId) {
- console.log('get file url');
- videoUrl = getFileUrl(asset.livePhotoVideoId, false, true);
- } else {
- videoUrl = getFileUrl(asset.id, false, true);
- }
- };
- 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) => {
- 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]}`;
- }
- };
- onDestroy(() => {
- URL.revokeObjectURL(imageData);
- });
- 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 {
- 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}
- <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}
- <!-- 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`}
- in:fade={{ duration: 150 }}
- src={`/api/asset/thumbnail/${asset.id}?format=${format}`}
- alt={asset.id}
- class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
- loading="lazy"
- />
- {/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>
|