immich-thumbnail.svelte 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. <script lang="ts">
  2. import { createEventDispatcher, onDestroy } from 'svelte';
  3. import { fade, fly } from 'svelte/transition';
  4. import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
  5. import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
  6. import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
  7. import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
  8. import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
  9. import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
  10. import LoadingSpinner from './loading-spinner.svelte';
  11. import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
  12. const dispatch = createEventDispatcher();
  13. export let asset: AssetResponseDto;
  14. export let groupIndex = 0;
  15. export let thumbnailSize: number | undefined = undefined;
  16. export let format: ThumbnailFormat = ThumbnailFormat.Webp;
  17. export let selected = false;
  18. export let disabled = false;
  19. let imageData: string;
  20. let mouseOver = false;
  21. let playMotionVideo = false;
  22. $: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
  23. let mouseOverIcon = false;
  24. let videoPlayerNode: HTMLVideoElement;
  25. let isThumbnailVideoPlaying = false;
  26. let calculateVideoDurationIntervalHandler: NodeJS.Timer;
  27. let videoProgress = '00:00';
  28. let videoUrl: string;
  29. const loadVideoData = async (isLivePhoto: boolean) => {
  30. isThumbnailVideoPlaying = false;
  31. if (isLivePhoto && asset.livePhotoVideoId) {
  32. console.log('get file url');
  33. videoUrl = getFileUrl(asset.livePhotoVideoId, false, true);
  34. } else {
  35. videoUrl = getFileUrl(asset.id, false, true);
  36. }
  37. };
  38. const getVideoDurationInString = (currentTime: number) => {
  39. const minute = Math.floor(currentTime / 60);
  40. const second = currentTime % 60;
  41. const minuteText = minute >= 10 ? `${minute}` : `0${minute}`;
  42. const secondText = second >= 10 ? `${second}` : `0${second}`;
  43. return minuteText + ':' + secondText;
  44. };
  45. const parseVideoDuration = (duration: string) => {
  46. const timePart = duration.split(':');
  47. const hours = timePart[0];
  48. const minutes = timePart[1];
  49. const seconds = timePart[2];
  50. if (hours != '0') {
  51. return `${hours}:${minutes}`;
  52. } else {
  53. return `${minutes}:${seconds.split('.')[0]}`;
  54. }
  55. };
  56. onDestroy(() => {
  57. URL.revokeObjectURL(imageData);
  58. });
  59. const getSize = () => {
  60. if (thumbnailSize) {
  61. return `w-[${thumbnailSize}px] h-[${thumbnailSize}px]`;
  62. }
  63. if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
  64. return 'w-[176px] h-[235px]';
  65. } else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
  66. return 'w-[313px] h-[235px]';
  67. } else {
  68. return 'w-[235px] h-[235px]';
  69. }
  70. };
  71. const handleMouseOverThumbnail = () => {
  72. mouseOver = true;
  73. };
  74. const handleMouseLeaveThumbnail = () => {
  75. mouseOver = false;
  76. videoUrl = '';
  77. clearInterval(calculateVideoDurationIntervalHandler);
  78. isThumbnailVideoPlaying = false;
  79. videoProgress = '00:00';
  80. if (videoPlayerNode) {
  81. videoPlayerNode.pause();
  82. }
  83. };
  84. const handleCanPlay = (ev: Event) => {
  85. const playerNode = ev.target as HTMLVideoElement;
  86. playerNode.muted = true;
  87. playerNode.play();
  88. isThumbnailVideoPlaying = true;
  89. calculateVideoDurationIntervalHandler = setInterval(() => {
  90. videoProgress = getVideoDurationInString(Math.round(playerNode.currentTime));
  91. }, 1000);
  92. };
  93. $: getThumbnailBorderStyle = () => {
  94. if (selected) {
  95. return 'border-[20px] border-immich-primary/20';
  96. } else if (disabled) {
  97. return 'border-[20px] border-gray-300';
  98. } else {
  99. return '';
  100. }
  101. };
  102. $: getOverlaySelectorIconStyle = () => {
  103. if (selected || disabled) {
  104. return '';
  105. } else {
  106. return 'bg-gradient-to-b from-gray-800/50';
  107. }
  108. };
  109. const thumbnailClickedHandler = () => {
  110. if (!disabled) {
  111. dispatch('click', { asset });
  112. }
  113. };
  114. const onIconClickedHandler = (e: MouseEvent) => {
  115. e.stopPropagation();
  116. if (!disabled) {
  117. dispatch('select', { asset });
  118. }
  119. };
  120. </script>
  121. <IntersectionObserver once={false} let:intersecting>
  122. <div
  123. style:width={`${thumbnailSize}px`}
  124. style:height={`${thumbnailSize}px`}
  125. class={`bg-gray-100 dark:bg-immich-dark-gray relative select-none ${getSize()} ${
  126. disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
  127. }`}
  128. on:mouseenter={handleMouseOverThumbnail}
  129. on:mouseleave={handleMouseLeaveThumbnail}
  130. on:click={thumbnailClickedHandler}
  131. on:keydown={thumbnailClickedHandler}
  132. >
  133. {#if mouseOver || selected || disabled}
  134. <div
  135. in:fade={{ duration: 200 }}
  136. class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
  137. >
  138. <button
  139. on:click={onIconClickedHandler}
  140. on:mouseenter={() => (mouseOverIcon = true)}
  141. on:mouseleave={() => (mouseOverIcon = false)}
  142. class="inline-block"
  143. >
  144. {#if selected}
  145. <CheckCircle size="24" color="#4250af" />
  146. {:else if disabled}
  147. <CheckCircle size="24" color="#252525" />
  148. {:else}
  149. <CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
  150. {/if}
  151. </button>
  152. </div>
  153. {/if}
  154. <!-- Playback and info -->
  155. {#if asset.type === AssetTypeEnum.Video}
  156. <div
  157. class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
  158. >
  159. {#if isThumbnailVideoPlaying}
  160. <span in:fly={{ x: -25, duration: 500 }}>
  161. {videoProgress}
  162. </span>
  163. {:else}
  164. <span in:fade={{ duration: 500 }}>
  165. {parseVideoDuration(asset.duration)}
  166. </span>
  167. {/if}
  168. {#if mouseOver}
  169. {#if isThumbnailVideoPlaying}
  170. <span in:fly={{ x: 25, duration: 500 }}>
  171. <PauseCircleOutline size="24" />
  172. </span>
  173. {:else}
  174. <span in:fade={{ duration: 250 }}>
  175. <LoadingSpinner />
  176. </span>
  177. {/if}
  178. {:else}
  179. <span in:fade={{ duration: 500 }}>
  180. <PlayCircleOutline size="24" />
  181. </span>
  182. {/if}
  183. </div>
  184. {/if}
  185. {#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
  186. <div
  187. class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
  188. >
  189. <span
  190. in:fade={{ duration: 500 }}
  191. on:mouseenter={() => {
  192. playMotionVideo = true;
  193. loadVideoData(true);
  194. }}
  195. on:mouseleave={() => (playMotionVideo = false)}
  196. >
  197. {#if playMotionVideo}
  198. <span in:fade={{ duration: 500 }}>
  199. <MotionPauseOutline size="24" />
  200. </span>
  201. {:else}
  202. <span in:fade={{ duration: 500 }}>
  203. <MotionPlayOutline size="24" />
  204. </span>
  205. {/if}
  206. </span>
  207. <!-- {/if} -->
  208. </div>
  209. {/if}
  210. <!-- Thumbnail -->
  211. {#if intersecting}
  212. <img
  213. id={asset.id}
  214. style:width={`${thumbnailSize}px`}
  215. style:height={`${thumbnailSize}px`}
  216. in:fade={{ duration: 150 }}
  217. src={`/api/asset/thumbnail/${asset.id}?format=${format}`}
  218. alt={asset.id}
  219. class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
  220. loading="lazy"
  221. />
  222. {/if}
  223. {#if mouseOver && asset.type === AssetTypeEnum.Video}
  224. <div class="absolute w-full h-full top-0" on:mouseenter={() => loadVideoData(false)}>
  225. {#if videoUrl}
  226. <video
  227. muted
  228. autoplay
  229. preload="none"
  230. class="h-full object-cover"
  231. width="250px"
  232. style:width={`${thumbnailSize}px`}
  233. on:canplay={handleCanPlay}
  234. bind:this={videoPlayerNode}
  235. >
  236. <source src={videoUrl} type="video/mp4" />
  237. <track kind="captions" />
  238. </video>
  239. {/if}
  240. </div>
  241. {/if}
  242. {#if playMotionVideo && asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
  243. <div class="absolute w-full h-full top-0">
  244. {#if videoUrl}
  245. <video
  246. muted
  247. autoplay
  248. preload="none"
  249. class="h-full object-cover"
  250. width="250px"
  251. style:width={`${thumbnailSize}px`}
  252. on:canplay={handleCanPlay}
  253. bind:this={videoPlayerNode}
  254. >
  255. <source src={videoUrl} type="video/mp4" />
  256. <track kind="captions" />
  257. </video>
  258. {/if}
  259. </div>
  260. {/if}
  261. </div>
  262. </IntersectionObserver>