asset-viewer.svelte 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <script lang="ts">
  2. import { goto } from '$app/navigation';
  3. import { AlbumResponseDto, api, AssetJobName, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
  4. import { createEventDispatcher, onDestroy, onMount } from 'svelte';
  5. import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
  6. import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
  7. import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
  8. import { fly } from 'svelte/transition';
  9. import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
  10. import { notificationController, NotificationType } from '../shared-components/notification/notification';
  11. import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
  12. import DetailPanel from './detail-panel.svelte';
  13. import PhotoViewer from './photo-viewer.svelte';
  14. import VideoViewer from './video-viewer.svelte';
  15. import PanoramaViewer from './panorama-viewer.svelte';
  16. import { ProjectionType } from '$lib/constants';
  17. import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
  18. import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
  19. import Pause from 'svelte-material-icons/Pause.svelte';
  20. import Play from 'svelte-material-icons/Play.svelte';
  21. import { isShowDetail } from '$lib/stores/preferences.store';
  22. import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
  23. import NavigationArea from './navigation-area.svelte';
  24. import { browser } from '$app/environment';
  25. import { handleError } from '$lib/utils/handle-error';
  26. import type { AssetStore } from '$lib/stores/assets.store';
  27. import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
  28. import Close from 'svelte-material-icons/Close.svelte';
  29. import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
  30. import { disableShortcut } from '$lib/stores/shortcut.store';
  31. export let assetStore: AssetStore | null = null;
  32. export let asset: AssetResponseDto;
  33. export let showNavigation = true;
  34. export let sharedLink: SharedLinkResponseDto | undefined = undefined;
  35. const dispatch = createEventDispatcher<{
  36. archived: AssetResponseDto;
  37. unarchived: AssetResponseDto;
  38. favorite: AssetResponseDto;
  39. unfavorite: AssetResponseDto;
  40. close: void;
  41. next: void;
  42. previous: void;
  43. }>();
  44. let appearsInAlbums: AlbumResponseDto[] = [];
  45. let isShowAlbumPicker = false;
  46. let isShowDeleteConfirmation = false;
  47. let addToSharedAlbum = true;
  48. let shouldPlayMotionPhoto = false;
  49. let isShowProfileImageCrop = false;
  50. let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
  51. let canCopyImagesToClipboard: boolean;
  52. const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key, keyInfo.shiftKey);
  53. onMount(async () => {
  54. document.addEventListener('keydown', onKeyboardPress);
  55. if (!sharedLink) {
  56. await getAllAlbums();
  57. }
  58. // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
  59. // TODO: Move to regular import once the package correctly supports ESM.
  60. const module = await import('copy-image-clipboard');
  61. canCopyImagesToClipboard = module.canCopyImagesToClipboard();
  62. });
  63. onDestroy(() => {
  64. if (browser) {
  65. document.removeEventListener('keydown', onKeyboardPress);
  66. }
  67. });
  68. $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes
  69. const getAllAlbums = async () => {
  70. if (api.isSharedLink) {
  71. return;
  72. }
  73. try {
  74. const { data } = await api.albumApi.getAllAlbums({ assetId: asset.id });
  75. appearsInAlbums = data;
  76. } catch (e) {
  77. console.error('Error getting album that asset belong to', e);
  78. }
  79. };
  80. const handleKeyboardPress = (key: string, shiftKey: boolean) => {
  81. if ($disableShortcut) {
  82. return;
  83. }
  84. switch (key) {
  85. case 'a':
  86. case 'A':
  87. if (shiftKey) {
  88. toggleArchive();
  89. }
  90. return;
  91. case 'ArrowLeft':
  92. navigateAssetBackward();
  93. return;
  94. case 'ArrowRight':
  95. navigateAssetForward();
  96. return;
  97. case 'd':
  98. case 'D':
  99. if (shiftKey) {
  100. downloadFile(asset);
  101. }
  102. return;
  103. case 'Delete':
  104. isShowDeleteConfirmation = true;
  105. return;
  106. case 'Escape':
  107. closeViewer();
  108. return;
  109. case 'f':
  110. toggleFavorite();
  111. return;
  112. case 'i':
  113. $isShowDetail = !$isShowDetail;
  114. return;
  115. }
  116. };
  117. const handleCloseViewer = () => {
  118. $isShowDetail = false;
  119. closeViewer();
  120. };
  121. const closeViewer = () => dispatch('close');
  122. const navigateAssetForward = async (e?: Event) => {
  123. if (isSlideshowMode && assetStore && progressBar) {
  124. const hasNext = await assetStore.getNextAssetId(asset.id);
  125. if (hasNext) {
  126. progressBar.restart(true);
  127. } else {
  128. await handleStopSlideshow();
  129. }
  130. }
  131. e?.stopPropagation();
  132. dispatch('next');
  133. };
  134. const navigateAssetBackward = (e?: Event) => {
  135. if (isSlideshowMode && progressBar) {
  136. progressBar.restart(true);
  137. }
  138. e?.stopPropagation();
  139. dispatch('previous');
  140. };
  141. const showDetailInfoHandler = () => {
  142. $isShowDetail = !$isShowDetail;
  143. };
  144. const deleteAsset = async () => {
  145. try {
  146. const { data: deletedAssets } = await api.assetApi.deleteAsset({
  147. deleteAssetDto: {
  148. ids: [asset.id],
  149. },
  150. });
  151. await navigateAssetForward();
  152. for (const asset of deletedAssets) {
  153. if (asset.status == 'SUCCESS') {
  154. assetStore?.removeAsset(asset.id);
  155. }
  156. }
  157. } catch (e) {
  158. notificationController.show({
  159. type: NotificationType.Error,
  160. message: 'Error deleting this asset, check console for more details',
  161. });
  162. console.error('Error deleteAsset', e);
  163. } finally {
  164. isShowDeleteConfirmation = false;
  165. }
  166. };
  167. const toggleFavorite = async () => {
  168. try {
  169. const { data } = await api.assetApi.updateAsset({
  170. id: asset.id,
  171. updateAssetDto: {
  172. isFavorite: !asset.isFavorite,
  173. },
  174. });
  175. asset.isFavorite = data.isFavorite;
  176. assetStore?.updateAsset(data);
  177. dispatch(data.isFavorite ? 'favorite' : 'unfavorite', data);
  178. notificationController.show({
  179. type: NotificationType.Info,
  180. message: asset.isFavorite ? `Added to favorites` : `Removed from favorites`,
  181. });
  182. } catch (error) {
  183. await handleError(error, `Unable to ${asset.isFavorite ? `add asset to` : `remove asset from`} favorites`);
  184. }
  185. };
  186. const openAlbumPicker = (shared: boolean) => {
  187. isShowAlbumPicker = true;
  188. $disableShortcut = true;
  189. addToSharedAlbum = shared;
  190. };
  191. const handleAddToNewAlbum = (event: CustomEvent) => {
  192. isShowAlbumPicker = false;
  193. $disableShortcut = false;
  194. const { albumName }: { albumName: string } = event.detail;
  195. api.albumApi.createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } }).then((response) => {
  196. const album = response.data;
  197. goto('/albums/' + album.id);
  198. });
  199. };
  200. const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
  201. isShowAlbumPicker = false;
  202. $disableShortcut = false;
  203. const album = event.detail.album;
  204. await addAssetsToAlbum(album.id, [asset.id]);
  205. await getAllAlbums();
  206. };
  207. const disableKeyDownEvent = () => {
  208. if (browser) {
  209. document.removeEventListener('keydown', onKeyboardPress);
  210. }
  211. };
  212. const enableKeyDownEvent = () => {
  213. if (browser) {
  214. document.addEventListener('keydown', onKeyboardPress);
  215. }
  216. };
  217. const toggleArchive = async () => {
  218. try {
  219. const { data } = await api.assetApi.updateAsset({
  220. id: asset.id,
  221. updateAssetDto: {
  222. isArchived: !asset.isArchived,
  223. },
  224. });
  225. asset.isArchived = data.isArchived;
  226. assetStore?.updateAsset(data);
  227. dispatch(data.isArchived ? 'archived' : 'unarchived', data);
  228. notificationController.show({
  229. type: NotificationType.Info,
  230. message: asset.isArchived ? `Added to archive` : `Removed from archive`,
  231. });
  232. } catch (error) {
  233. await handleError(error, `Unable to ${asset.isArchived ? `add asset to` : `remove asset from`} archive`);
  234. }
  235. };
  236. const getAssetType = () => {
  237. switch (asset.type) {
  238. case 'IMAGE':
  239. return 'Photo';
  240. case 'VIDEO':
  241. return 'Video';
  242. default:
  243. return 'Asset';
  244. }
  245. };
  246. const handleRunJob = async (name: AssetJobName) => {
  247. try {
  248. await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
  249. notificationController.show({ type: NotificationType.Info, message: api.getAssetJobMessage(name) });
  250. } catch (error) {
  251. await handleError(error, `Unable to submit job`);
  252. }
  253. };
  254. /**
  255. * Slide show mode
  256. */
  257. let isSlideshowMode = false;
  258. let assetViewerHtmlElement: HTMLElement;
  259. let progressBar: ProgressBar;
  260. let progressBarStatus: ProgressBarStatus;
  261. const handleVideoStarted = () => {
  262. if (isSlideshowMode) {
  263. progressBar.restart(false);
  264. }
  265. };
  266. const handleVideoEnded = async () => {
  267. if (isSlideshowMode) {
  268. await navigateAssetForward();
  269. }
  270. };
  271. const handlePlaySlideshow = async () => {
  272. try {
  273. await assetViewerHtmlElement.requestFullscreen();
  274. } catch (error) {
  275. console.error('Error entering fullscreen', error);
  276. } finally {
  277. isSlideshowMode = true;
  278. }
  279. };
  280. const handleStopSlideshow = async () => {
  281. try {
  282. await document.exitFullscreen();
  283. } catch (error) {
  284. console.error('Error exiting fullscreen', error);
  285. } finally {
  286. isSlideshowMode = false;
  287. progressBar.restart(false);
  288. }
  289. };
  290. </script>
  291. <section
  292. id="immich-asset-viewer"
  293. class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
  294. bind:this={assetViewerHtmlElement}
  295. >
  296. <div class="z-[1000] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
  297. {#if isSlideshowMode}
  298. <!-- SlideShowController -->
  299. <div class="flex">
  300. <div class="m-4 flex gap-2">
  301. <CircleIconButton logo={Close} on:click={handleStopSlideshow} title="Exit Slideshow" />
  302. <CircleIconButton
  303. logo={progressBarStatus === ProgressBarStatus.Paused ? Play : Pause}
  304. on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
  305. title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
  306. />
  307. <CircleIconButton logo={ChevronLeft} on:click={navigateAssetBackward} title="Previous" />
  308. <CircleIconButton logo={ChevronRight} on:click={navigateAssetForward} title="Next" />
  309. </div>
  310. <ProgressBar
  311. autoplay
  312. bind:this={progressBar}
  313. bind:status={progressBarStatus}
  314. on:done={navigateAssetForward}
  315. duration={5000}
  316. />
  317. </div>
  318. {:else}
  319. <AssetViewerNavBar
  320. {asset}
  321. isMotionPhotoPlaying={shouldPlayMotionPhoto}
  322. showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
  323. showZoomButton={asset.type === AssetTypeEnum.Image}
  324. showMotionPlayButton={!!asset.livePhotoVideoId}
  325. showDownloadButton={shouldShowDownloadButton}
  326. showSlideshow={!!assetStore}
  327. on:goBack={closeViewer}
  328. on:showDetail={showDetailInfoHandler}
  329. on:download={() => downloadFile(asset)}
  330. on:delete={() => (isShowDeleteConfirmation = true)}
  331. on:favorite={toggleFavorite}
  332. on:addToAlbum={() => openAlbumPicker(false)}
  333. on:addToSharedAlbum={() => openAlbumPicker(true)}
  334. on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
  335. on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
  336. on:toggleArchive={toggleArchive}
  337. on:asProfileImage={() => (isShowProfileImageCrop = true)}
  338. on:runJob={({ detail: job }) => handleRunJob(job)}
  339. on:playSlideShow={handlePlaySlideshow}
  340. />
  341. {/if}
  342. </div>
  343. {#if !isSlideshowMode && showNavigation}
  344. <div class="column-span-1 z-[999] col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
  345. <NavigationArea on:click={navigateAssetBackward}><ChevronLeft size="36" /></NavigationArea>
  346. </div>
  347. {/if}
  348. <div class="col-span-4 col-start-1 row-span-full row-start-1">
  349. {#key asset.id}
  350. {#if !asset.resized}
  351. <div class="flex h-full w-full justify-center">
  352. <div
  353. class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray"
  354. >
  355. <ImageBrokenVariant size="25%" />
  356. </div>
  357. </div>
  358. {:else if asset.type === AssetTypeEnum.Image}
  359. {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
  360. <VideoViewer
  361. assetId={asset.livePhotoVideoId}
  362. on:close={closeViewer}
  363. on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
  364. />
  365. {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.originalPath
  366. .toLowerCase()
  367. .endsWith('.insp')}
  368. <PanoramaViewer {asset} />
  369. {:else}
  370. <PhotoViewer {asset} on:close={closeViewer} />
  371. {/if}
  372. {:else}
  373. <VideoViewer
  374. assetId={asset.id}
  375. on:close={closeViewer}
  376. on:onVideoEnded={handleVideoEnded}
  377. on:onVideoStarted={handleVideoStarted}
  378. />
  379. {/if}
  380. {/key}
  381. </div>
  382. {#if !isSlideshowMode && showNavigation}
  383. <div class="z-[999] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
  384. <NavigationArea on:click={navigateAssetForward}><ChevronRight size="36" /></NavigationArea>
  385. </div>
  386. {/if}
  387. {#if !isSlideshowMode && $isShowDetail}
  388. <div
  389. transition:fly={{ duration: 150 }}
  390. id="detail-panel"
  391. class="z-[1002] row-span-full w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
  392. translate="yes"
  393. >
  394. <DetailPanel
  395. {asset}
  396. albums={appearsInAlbums}
  397. on:close={() => ($isShowDetail = false)}
  398. on:close-viewer={handleCloseViewer}
  399. on:description-focus-in={disableKeyDownEvent}
  400. on:description-focus-out={enableKeyDownEvent}
  401. />
  402. </div>
  403. {/if}
  404. {#if isShowAlbumPicker}
  405. <AlbumSelectionModal
  406. shared={addToSharedAlbum}
  407. on:newAlbum={handleAddToNewAlbum}
  408. on:newSharedAlbum={handleAddToNewAlbum}
  409. on:album={handleAddToAlbum}
  410. on:close={() => {
  411. isShowAlbumPicker = false;
  412. $disableShortcut = false;
  413. }}
  414. />
  415. {/if}
  416. {#if isShowDeleteConfirmation}
  417. <ConfirmDialogue
  418. title="Delete {getAssetType()}"
  419. confirmText="Delete"
  420. on:confirm={deleteAsset}
  421. on:cancel={() => (isShowDeleteConfirmation = false)}
  422. >
  423. <svelte:fragment slot="prompt">
  424. <p>
  425. Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove it from its
  426. album(s).
  427. </p>
  428. <p><b>You cannot undo this action!</b></p>
  429. </svelte:fragment>
  430. </ConfirmDialogue>
  431. {/if}
  432. {#if isShowProfileImageCrop}
  433. <ProfileImageCropper
  434. {asset}
  435. on:close={() => (isShowProfileImageCrop = false)}
  436. on:close-viewer={handleCloseViewer}
  437. />
  438. {/if}
  439. </section>
  440. <style>
  441. #immich-asset-viewer {
  442. contain: layout;
  443. }
  444. </style>