asset-viewer.svelte 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. <script lang="ts">
  2. import { goto } from '$app/navigation';
  3. import { downloadAssets } from '$lib/stores/download';
  4. import {
  5. AlbumResponseDto,
  6. api,
  7. AssetResponseDto,
  8. AssetTypeEnum,
  9. SharedLinkResponseDto
  10. } from '@api';
  11. import { createEventDispatcher, onDestroy, onMount } from 'svelte';
  12. import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
  13. import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
  14. import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
  15. import { fly } from 'svelte/transition';
  16. import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
  17. import {
  18. notificationController,
  19. NotificationType
  20. } from '../shared-components/notification/notification';
  21. import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
  22. import DetailPanel from './detail-panel.svelte';
  23. import PhotoViewer from './photo-viewer.svelte';
  24. import VideoViewer from './video-viewer.svelte';
  25. import { assetStore } from '$lib/stores/assets.store';
  26. import { isShowDetail } from '$lib/stores/preferences.store';
  27. import { addAssetsToAlbum, getFilenameExtension } from '$lib/utils/asset-utils';
  28. import { browser } from '$app/environment';
  29. export let asset: AssetResponseDto;
  30. export let publicSharedKey = '';
  31. export let showNavigation = true;
  32. export let sharedLink: SharedLinkResponseDto | undefined = undefined;
  33. const dispatch = createEventDispatcher();
  34. let halfLeftHover = false;
  35. let halfRightHover = false;
  36. let appearsInAlbums: AlbumResponseDto[] = [];
  37. let isShowAlbumPicker = false;
  38. let addToSharedAlbum = true;
  39. let shouldPlayMotionPhoto = false;
  40. let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
  41. let canCopyImagesToClipboard: boolean;
  42. const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
  43. onMount(async () => {
  44. document.addEventListener('keydown', onKeyboardPress);
  45. getAllAlbums();
  46. // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
  47. // TODO: Move to regular import once the package correctly supports ESM.
  48. const module = await import('copy-image-clipboard');
  49. canCopyImagesToClipboard = module.canCopyImagesToClipboard();
  50. });
  51. onDestroy(() => {
  52. if (browser) {
  53. document.removeEventListener('keydown', onKeyboardPress);
  54. }
  55. });
  56. $: asset.id && getAllAlbums(); // Update the album information when the asset ID changes
  57. const getAllAlbums = async () => {
  58. try {
  59. const { data } = await api.albumApi.getAllAlbums({ assetId: asset.id });
  60. appearsInAlbums = data;
  61. } catch (e) {
  62. console.error('Error getting album that asset belong to', e);
  63. }
  64. };
  65. const handleKeyboardPress = (key: string) => {
  66. switch (key) {
  67. case 'Escape':
  68. closeViewer();
  69. return;
  70. case 'Delete':
  71. deleteAsset();
  72. return;
  73. case 'i':
  74. $isShowDetail = !$isShowDetail;
  75. return;
  76. case 'ArrowLeft':
  77. navigateAssetBackward();
  78. return;
  79. case 'ArrowRight':
  80. navigateAssetForward();
  81. return;
  82. }
  83. };
  84. const handleCloseViewer = () => {
  85. $isShowDetail = false;
  86. closeViewer();
  87. };
  88. const closeViewer = () => {
  89. dispatch('close');
  90. };
  91. const navigateAssetForward = (e?: Event) => {
  92. e?.stopPropagation();
  93. dispatch('navigate-next');
  94. };
  95. const navigateAssetBackward = (e?: Event) => {
  96. e?.stopPropagation();
  97. dispatch('navigate-previous');
  98. };
  99. const showDetailInfoHandler = () => {
  100. $isShowDetail = !$isShowDetail;
  101. };
  102. const handleDownload = () => {
  103. if (asset.livePhotoVideoId) {
  104. downloadFile(asset.livePhotoVideoId, true, publicSharedKey);
  105. downloadFile(asset.id, false, publicSharedKey);
  106. return;
  107. }
  108. downloadFile(asset.id, false, publicSharedKey);
  109. };
  110. const downloadFile = async (assetId: string, isLivePhoto: boolean, key: string) => {
  111. try {
  112. const imageExtension = isLivePhoto ? 'mov' : getFilenameExtension(asset.originalPath);
  113. const imageFileName = asset.originalFileName + '.' + imageExtension;
  114. // If assets is already download -> return;
  115. if ($downloadAssets[imageFileName]) {
  116. return;
  117. }
  118. $downloadAssets[imageFileName] = 0;
  119. const { data, status } = await api.assetApi.downloadFile(
  120. { id: assetId, key },
  121. {
  122. responseType: 'blob',
  123. onDownloadProgress: (progressEvent) => {
  124. if (progressEvent.lengthComputable) {
  125. const total = progressEvent.total;
  126. const current = progressEvent.loaded;
  127. $downloadAssets[imageFileName] = Math.floor((current / total) * 100);
  128. }
  129. }
  130. }
  131. );
  132. if (!(data instanceof Blob)) {
  133. return;
  134. }
  135. if (status === 200) {
  136. const fileUrl = URL.createObjectURL(data);
  137. const anchor = document.createElement('a');
  138. anchor.href = fileUrl;
  139. anchor.download = imageFileName;
  140. document.body.appendChild(anchor);
  141. anchor.click();
  142. document.body.removeChild(anchor);
  143. URL.revokeObjectURL(fileUrl);
  144. // Remove item from download list
  145. setTimeout(() => {
  146. const copy = $downloadAssets;
  147. delete copy[imageFileName];
  148. $downloadAssets = copy;
  149. }, 2000);
  150. }
  151. } catch (e) {
  152. $downloadAssets = {};
  153. console.error('Error downloading file ', e);
  154. notificationController.show({
  155. type: NotificationType.Error,
  156. message: 'Error downloading file, check console for more details.'
  157. });
  158. }
  159. };
  160. const deleteAsset = async () => {
  161. try {
  162. if (
  163. window.confirm(
  164. `Caution! Are you sure you want to delete this asset? This step also deletes this asset in the album(s) to which it belongs. You can not undo this action!`
  165. )
  166. ) {
  167. const { data: deletedAssets } = await api.assetApi.deleteAsset({
  168. deleteAssetDto: {
  169. ids: [asset.id]
  170. }
  171. });
  172. navigateAssetForward();
  173. for (const asset of deletedAssets) {
  174. if (asset.status == 'SUCCESS') {
  175. assetStore.removeAsset(asset.id);
  176. }
  177. }
  178. }
  179. } catch (e) {
  180. notificationController.show({
  181. type: NotificationType.Error,
  182. message: 'Error deleting this asset, check console for more details'
  183. });
  184. console.error('Error deleteSelectedAssetHandler', e);
  185. }
  186. };
  187. const toggleFavorite = async () => {
  188. const { data } = await api.assetApi.updateAsset({
  189. id: asset.id,
  190. updateAssetDto: {
  191. isFavorite: !asset.isFavorite
  192. }
  193. });
  194. asset.isFavorite = data.isFavorite;
  195. assetStore.updateAsset(asset.id, data.isFavorite);
  196. };
  197. const openAlbumPicker = (shared: boolean) => {
  198. isShowAlbumPicker = true;
  199. addToSharedAlbum = shared;
  200. };
  201. const handleAddToNewAlbum = (event: CustomEvent) => {
  202. isShowAlbumPicker = false;
  203. const { albumName }: { albumName: string } = event.detail;
  204. api.albumApi
  205. .createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } })
  206. .then((response) => {
  207. const album = response.data;
  208. goto('/albums/' + album.id);
  209. });
  210. };
  211. const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
  212. isShowAlbumPicker = false;
  213. const album = event.detail.album;
  214. addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
  215. if (dto.successfullyAdded === 1 && dto.album) {
  216. appearsInAlbums = [...appearsInAlbums, dto.album];
  217. }
  218. });
  219. };
  220. const disableKeyDownEvent = () => {
  221. if (browser) {
  222. document.removeEventListener('keydown', onKeyboardPress);
  223. }
  224. };
  225. const enableKeyDownEvent = () => {
  226. if (browser) {
  227. document.addEventListener('keydown', onKeyboardPress);
  228. }
  229. };
  230. const toggleArchive = async () => {
  231. try {
  232. const { data } = await api.assetApi.updateAsset({
  233. id: asset.id,
  234. updateAssetDto: {
  235. isArchived: !asset.isArchived
  236. }
  237. });
  238. asset.isArchived = data.isArchived;
  239. if (data.isArchived) {
  240. dispatch('archived', data);
  241. } else {
  242. dispatch('unarchived', data);
  243. }
  244. notificationController.show({
  245. type: NotificationType.Info,
  246. message: asset.isArchived ? `Added to archive` : `Removed from archive`
  247. });
  248. } catch (error) {
  249. console.error(error);
  250. notificationController.show({
  251. type: NotificationType.Error,
  252. message: `Error ${
  253. asset.isArchived ? 'archiving' : 'unarchiving'
  254. } asset, check console for more details`
  255. });
  256. }
  257. };
  258. </script>
  259. <section
  260. id="immich-asset-viewer"
  261. class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4"
  262. >
  263. <div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
  264. <AssetViewerNavBar
  265. {asset}
  266. isMotionPhotoPlaying={shouldPlayMotionPhoto}
  267. showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
  268. showZoomButton={asset.type === AssetTypeEnum.Image}
  269. showMotionPlayButton={!!asset.livePhotoVideoId}
  270. showDownloadButton={shouldShowDownloadButton}
  271. on:goBack={closeViewer}
  272. on:showDetail={showDetailInfoHandler}
  273. on:download={handleDownload}
  274. on:delete={deleteAsset}
  275. on:favorite={toggleFavorite}
  276. on:addToAlbum={() => openAlbumPicker(false)}
  277. on:addToSharedAlbum={() => openAlbumPicker(true)}
  278. on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
  279. on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
  280. on:toggleArchive={toggleArchive}
  281. />
  282. </div>
  283. {#if showNavigation}
  284. <div
  285. class={`row-start-2 row-span-end col-start-1 flex place-items-center hover:cursor-pointer w-1/4 mb-[60px] ${
  286. asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
  287. }`}
  288. on:mouseenter={() => {
  289. halfLeftHover = true;
  290. halfRightHover = false;
  291. }}
  292. on:mouseleave={() => {
  293. halfLeftHover = false;
  294. }}
  295. on:click={navigateAssetBackward}
  296. on:keydown={navigateAssetBackward}
  297. >
  298. <button
  299. class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
  300. class:navigation-button-hover={halfLeftHover}
  301. on:click={navigateAssetBackward}
  302. >
  303. <ChevronLeft size="36" />
  304. </button>
  305. </div>
  306. {/if}
  307. <div class="row-start-1 row-span-full col-start-1 col-span-4">
  308. {#key asset.id}
  309. {#if !asset.resized}
  310. <div class="h-full w-full flex justify-center">
  311. <div
  312. class="h-full bg-gray-100 dark:bg-immich-dark-gray flex items-center justify-center aspect-square px-auto"
  313. >
  314. <ImageBrokenVariant size="25%" />
  315. </div>
  316. </div>
  317. {:else if asset.type === AssetTypeEnum.Image}
  318. {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
  319. <VideoViewer
  320. {publicSharedKey}
  321. assetId={asset.livePhotoVideoId}
  322. on:close={closeViewer}
  323. on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
  324. />
  325. {:else}
  326. <PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />
  327. {/if}
  328. {:else}
  329. <VideoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
  330. {/if}
  331. {/key}
  332. </div>
  333. {#if showNavigation}
  334. <div
  335. class={`row-start-2 row-span-full col-start-4 flex justify-end place-items-center hover:cursor-pointer w-1/4 justify-self-end mb-[60px] ${
  336. asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
  337. }`}
  338. on:click={navigateAssetForward}
  339. on:keydown={navigateAssetForward}
  340. on:mouseenter={() => {
  341. halfLeftHover = false;
  342. halfRightHover = true;
  343. }}
  344. on:mouseleave={() => {
  345. halfRightHover = false;
  346. }}
  347. >
  348. <button
  349. class="rounded-full p-3 hover:bg-gray-500 hover:text-white text-gray-500 mx-4"
  350. class:navigation-button-hover={halfRightHover}
  351. on:click={navigateAssetForward}
  352. >
  353. <ChevronRight size="36" />
  354. </button>
  355. </div>
  356. {/if}
  357. {#if $isShowDetail}
  358. <div
  359. transition:fly={{ duration: 150 }}
  360. id="detail-panel"
  361. class="bg-immich-bg w-[360px] z-[1002] row-span-full transition-all overflow-y-auto dark:bg-immich-dark-bg dark:border-l dark:border-l-immich-dark-gray"
  362. translate="yes"
  363. >
  364. <DetailPanel
  365. {asset}
  366. albums={appearsInAlbums}
  367. on:close={() => ($isShowDetail = false)}
  368. on:close-viewer={handleCloseViewer}
  369. on:description-focus-in={disableKeyDownEvent}
  370. on:description-focus-out={enableKeyDownEvent}
  371. />
  372. </div>
  373. {/if}
  374. {#if isShowAlbumPicker}
  375. <AlbumSelectionModal
  376. shared={addToSharedAlbum}
  377. on:newAlbum={handleAddToNewAlbum}
  378. on:newSharedAlbum={handleAddToNewAlbum}
  379. on:album={handleAddToAlbum}
  380. on:close={() => (isShowAlbumPicker = false)}
  381. />
  382. {/if}
  383. </section>
  384. <style>
  385. #immich-asset-viewer {
  386. contain: layout;
  387. }
  388. .navigation-button-hover {
  389. background-color: rgb(107 114 128 / var(--tw-bg-opacity));
  390. color: rgb(255 255 255 / var(--tw-text-opacity));
  391. transition: all 150ms;
  392. }
  393. </style>