asset-viewer.svelte 11 KB

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