asset-viewer.svelte 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833
  1. <script lang="ts">
  2. import { goto } from '$app/navigation';
  3. import {
  4. ActivityResponseDto,
  5. AlbumResponseDto,
  6. api,
  7. AssetJobName,
  8. AssetResponseDto,
  9. AssetTypeEnum,
  10. ReactionType,
  11. SharedLinkResponseDto,
  12. UserResponseDto,
  13. } from '@api';
  14. import { createEventDispatcher, onDestroy, onMount } from 'svelte';
  15. import { fly } from 'svelte/transition';
  16. import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
  17. import { notificationController, NotificationType } from '../shared-components/notification/notification';
  18. import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
  19. import DetailPanel from './detail-panel.svelte';
  20. import PhotoViewer from './photo-viewer.svelte';
  21. import VideoViewer from './video-viewer.svelte';
  22. import PanoramaViewer from './panorama-viewer.svelte';
  23. import { ProjectionType } from '$lib/constants';
  24. import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
  25. import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
  26. import { isShowDetail } from '$lib/stores/preferences.store';
  27. import { addAssetsToAlbum, downloadFile, getAssetType } from '$lib/utils/asset-utils';
  28. import NavigationArea from './navigation-area.svelte';
  29. import { browser } from '$app/environment';
  30. import { handleError } from '$lib/utils/handle-error';
  31. import type { AssetStore } from '$lib/stores/assets.store';
  32. import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
  33. import { assetViewingStore } from '$lib/stores/asset-viewing.store';
  34. import { SlideshowHistory } from '$lib/utils/slideshow-history';
  35. import { featureFlags } from '$lib/stores/server-config.store';
  36. import { mdiChevronLeft, mdiChevronRight, mdiImageBrokenVariant } from '@mdi/js';
  37. import Icon from '$lib/components/elements/icon.svelte';
  38. import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
  39. import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
  40. import ActivityViewer from './activity-viewer.svelte';
  41. import ActivityStatus from './activity-status.svelte';
  42. import { updateNumberOfComments } from '$lib/stores/activity.store';
  43. import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
  44. import SlideshowBar from './slideshow-bar.svelte';
  45. export let assetStore: AssetStore | null = null;
  46. export let asset: AssetResponseDto;
  47. export let showNavigation = true;
  48. export let sharedLink: SharedLinkResponseDto | undefined = undefined;
  49. $: isTrashEnabled = $featureFlags.trash;
  50. export let force = false;
  51. export let withStacked = false;
  52. export let isShared = false;
  53. export let user: UserResponseDto | null = null;
  54. export let album: AlbumResponseDto | null = null;
  55. let reactions: ActivityResponseDto[] = [];
  56. const { setAssetId } = assetViewingStore;
  57. const {
  58. restartProgress: restartSlideshowProgress,
  59. stopProgress: stopSlideshowProgress,
  60. slideshowShuffle,
  61. slideshowState,
  62. } = slideshowStore;
  63. const dispatch = createEventDispatcher<{
  64. archived: AssetResponseDto;
  65. unarchived: AssetResponseDto;
  66. favorite: AssetResponseDto;
  67. unfavorite: AssetResponseDto;
  68. close: void;
  69. next: void;
  70. previous: void;
  71. unstack: void;
  72. }>();
  73. let appearsInAlbums: AlbumResponseDto[] = [];
  74. let isShowAlbumPicker = false;
  75. let isShowDeleteConfirmation = false;
  76. let addToSharedAlbum = true;
  77. let shouldPlayMotionPhoto = false;
  78. let isShowProfileImageCrop = false;
  79. let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
  80. let shouldShowDetailButton = asset.hasMetadata;
  81. let canCopyImagesToClipboard: boolean;
  82. let slideshowStateUnsubscribe: () => void;
  83. let shuffleSlideshowUnsubscribe: () => void;
  84. let previewStackedAsset: AssetResponseDto | undefined;
  85. let isShowActivity = false;
  86. let isLiked: ActivityResponseDto | null = null;
  87. let numberOfComments: number;
  88. $: {
  89. if (asset.stackCount && asset.stack) {
  90. $stackAssetsStore = asset.stack;
  91. $stackAssetsStore = [...$stackAssetsStore, asset].sort(
  92. (a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(),
  93. );
  94. }
  95. if (!$stackAssetsStore.map((a) => a.id).includes(asset.id)) {
  96. $stackAssetsStore = [];
  97. }
  98. }
  99. $: {
  100. if (album && !album.isActivityEnabled && numberOfComments === 0) {
  101. isShowActivity = false;
  102. }
  103. }
  104. const handleAddComment = () => {
  105. numberOfComments++;
  106. updateNumberOfComments(1);
  107. };
  108. const handleRemoveComment = () => {
  109. numberOfComments--;
  110. updateNumberOfComments(-1);
  111. };
  112. const handleFavorite = async () => {
  113. if (album && album.isActivityEnabled) {
  114. try {
  115. if (isLiked) {
  116. const activityId = isLiked.id;
  117. await api.activityApi.deleteActivity({ id: activityId });
  118. reactions = reactions.filter((reaction) => reaction.id !== activityId);
  119. isLiked = null;
  120. } else {
  121. const { data } = await api.activityApi.createActivity({
  122. activityCreateDto: { albumId: album.id, assetId: asset.id, type: ReactionType.Like },
  123. });
  124. isLiked = data;
  125. reactions = [...reactions, isLiked];
  126. }
  127. } catch (error) {
  128. handleError(error, "Can't change favorite for asset");
  129. }
  130. }
  131. };
  132. const getFavorite = async () => {
  133. if (album && user) {
  134. try {
  135. const { data } = await api.activityApi.getActivities({
  136. userId: user.id,
  137. assetId: asset.id,
  138. albumId: album.id,
  139. type: ReactionType.Like,
  140. });
  141. if (data.length > 0) {
  142. isLiked = data[0];
  143. } else {
  144. isLiked = null;
  145. }
  146. } catch (error) {
  147. handleError(error, "Can't get Favorite");
  148. }
  149. }
  150. };
  151. const getNumberOfComments = async () => {
  152. if (album) {
  153. try {
  154. const { data } = await api.activityApi.getActivityStatistics({ assetId: asset.id, albumId: album.id });
  155. numberOfComments = data.comments;
  156. } catch (error) {
  157. handleError(error, "Can't get number of comments");
  158. }
  159. }
  160. };
  161. $: {
  162. if (isShared && asset.id) {
  163. getFavorite();
  164. getNumberOfComments();
  165. }
  166. }
  167. const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);
  168. onMount(async () => {
  169. document.addEventListener('keydown', onKeyboardPress);
  170. slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
  171. if (value === SlideshowState.PlaySlideshow) {
  172. slideshowHistory.reset();
  173. slideshowHistory.queue(asset.id);
  174. handlePlaySlideshow();
  175. } else if (value === SlideshowState.StopSlideshow) {
  176. handleStopSlideshow();
  177. }
  178. });
  179. shuffleSlideshowUnsubscribe = slideshowShuffle.subscribe((value) => {
  180. if (value) {
  181. slideshowHistory.reset();
  182. slideshowHistory.queue(asset.id);
  183. }
  184. });
  185. if (!sharedLink) {
  186. await getAllAlbums();
  187. }
  188. // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
  189. // TODO: Move to regular import once the package correctly supports ESM.
  190. const module = await import('copy-image-clipboard');
  191. canCopyImagesToClipboard = module.canCopyImagesToClipboard();
  192. if (asset.stackCount && asset.stack) {
  193. $stackAssetsStore = asset.stack;
  194. $stackAssetsStore = [...$stackAssetsStore, asset].sort(
  195. (a, b) => new Date(a.fileCreatedAt).getTime() - new Date(b.fileCreatedAt).getTime(),
  196. );
  197. } else {
  198. $stackAssetsStore = [];
  199. }
  200. });
  201. onDestroy(() => {
  202. if (browser) {
  203. document.removeEventListener('keydown', onKeyboardPress);
  204. }
  205. if (slideshowStateUnsubscribe) {
  206. slideshowStateUnsubscribe();
  207. }
  208. if (shuffleSlideshowUnsubscribe) {
  209. shuffleSlideshowUnsubscribe();
  210. }
  211. });
  212. $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes
  213. const getAllAlbums = async () => {
  214. if (api.isSharedLink) {
  215. return;
  216. }
  217. try {
  218. const { data } = await api.albumApi.getAllAlbums({ assetId: asset.id });
  219. appearsInAlbums = data;
  220. } catch (e) {
  221. console.error('Error getting album that asset belong to', e);
  222. }
  223. };
  224. const handleOpenActivity = () => {
  225. if ($isShowDetail) {
  226. $isShowDetail = false;
  227. }
  228. isShowActivity = !isShowActivity;
  229. };
  230. const handleKeyboardPress = (event: KeyboardEvent) => {
  231. if (shouldIgnoreShortcut(event)) {
  232. return;
  233. }
  234. const key = event.key;
  235. const shiftKey = event.shiftKey;
  236. switch (key) {
  237. case 'a':
  238. case 'A':
  239. if (shiftKey) {
  240. toggleArchive();
  241. }
  242. return;
  243. case 'ArrowLeft':
  244. navigateAssetBackward();
  245. return;
  246. case 'ArrowRight':
  247. navigateAssetForward();
  248. return;
  249. case 'd':
  250. case 'D':
  251. if (shiftKey) {
  252. downloadFile(asset);
  253. }
  254. return;
  255. case 'Delete':
  256. trashOrDelete();
  257. return;
  258. case 'Escape':
  259. if (isShowDeleteConfirmation) {
  260. isShowDeleteConfirmation = false;
  261. return;
  262. }
  263. closeViewer();
  264. return;
  265. case 'f':
  266. toggleFavorite();
  267. return;
  268. case 'i':
  269. isShowActivity = false;
  270. $isShowDetail = !$isShowDetail;
  271. return;
  272. }
  273. };
  274. const handleCloseViewer = () => {
  275. $isShowDetail = false;
  276. closeViewer();
  277. };
  278. const closeViewer = () => dispatch('close');
  279. const navigateAssetRandom = async () => {
  280. if (!assetStore) {
  281. return;
  282. }
  283. const asset = await assetStore.getRandomAsset();
  284. if (!asset) {
  285. return;
  286. }
  287. slideshowHistory.queue(asset.id);
  288. setAssetId(asset.id);
  289. $restartSlideshowProgress = true;
  290. };
  291. const navigateAssetForward = async (e?: Event) => {
  292. if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
  293. return slideshowHistory.next() || navigateAssetRandom();
  294. }
  295. if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) {
  296. const hasNext = await assetStore.getNextAssetId(asset.id);
  297. if (hasNext) {
  298. $restartSlideshowProgress = true;
  299. } else {
  300. await handleStopSlideshow();
  301. }
  302. }
  303. e?.stopPropagation();
  304. dispatch('next');
  305. };
  306. const navigateAssetBackward = (e?: Event) => {
  307. if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) {
  308. slideshowHistory.previous();
  309. return;
  310. }
  311. if ($slideshowState === SlideshowState.PlaySlideshow) {
  312. $restartSlideshowProgress = true;
  313. }
  314. e?.stopPropagation();
  315. dispatch('previous');
  316. };
  317. const showDetailInfoHandler = () => {
  318. if (isShowActivity) {
  319. isShowActivity = false;
  320. }
  321. $isShowDetail = !$isShowDetail;
  322. };
  323. $: trashOrDelete = !(force || !isTrashEnabled)
  324. ? trashAsset
  325. : () => {
  326. isShowDeleteConfirmation = true;
  327. };
  328. const trashAsset = async () => {
  329. try {
  330. await api.assetApi.deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
  331. await navigateAssetForward();
  332. assetStore?.removeAsset(asset.id);
  333. notificationController.show({
  334. message: 'Moved to trash',
  335. type: NotificationType.Info,
  336. });
  337. } catch (e) {
  338. handleError(e, 'Unable to trash asset');
  339. }
  340. };
  341. const deleteAsset = async () => {
  342. try {
  343. await api.assetApi.deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
  344. await navigateAssetForward();
  345. assetStore?.removeAsset(asset.id);
  346. notificationController.show({
  347. message: 'Permanently deleted asset',
  348. type: NotificationType.Info,
  349. });
  350. } catch (e) {
  351. handleError(e, 'Unable to delete asset');
  352. } finally {
  353. isShowDeleteConfirmation = false;
  354. }
  355. };
  356. const toggleFavorite = async () => {
  357. try {
  358. const { data } = await api.assetApi.updateAsset({
  359. id: asset.id,
  360. updateAssetDto: {
  361. isFavorite: !asset.isFavorite,
  362. },
  363. });
  364. asset.isFavorite = data.isFavorite;
  365. assetStore?.updateAsset(data);
  366. dispatch(data.isFavorite ? 'favorite' : 'unfavorite', data);
  367. notificationController.show({
  368. type: NotificationType.Info,
  369. message: asset.isFavorite ? `Added to favorites` : `Removed from favorites`,
  370. });
  371. } catch (error) {
  372. await handleError(error, `Unable to ${asset.isFavorite ? `add asset to` : `remove asset from`} favorites`);
  373. }
  374. };
  375. const openAlbumPicker = (shared: boolean) => {
  376. isShowAlbumPicker = true;
  377. addToSharedAlbum = shared;
  378. };
  379. const handleAddToNewAlbum = (event: CustomEvent) => {
  380. isShowAlbumPicker = false;
  381. const { albumName }: { albumName: string } = event.detail;
  382. api.albumApi.createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } }).then((response) => {
  383. const album = response.data;
  384. goto('/albums/' + album.id);
  385. });
  386. };
  387. const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
  388. isShowAlbumPicker = false;
  389. const album = event.detail.album;
  390. await addAssetsToAlbum(album.id, [asset.id]);
  391. await getAllAlbums();
  392. };
  393. const disableKeyDownEvent = () => {
  394. if (browser) {
  395. document.removeEventListener('keydown', onKeyboardPress);
  396. }
  397. };
  398. const enableKeyDownEvent = () => {
  399. if (browser) {
  400. document.addEventListener('keydown', onKeyboardPress);
  401. }
  402. };
  403. const toggleArchive = async () => {
  404. try {
  405. const { data } = await api.assetApi.updateAsset({
  406. id: asset.id,
  407. updateAssetDto: {
  408. isArchived: !asset.isArchived,
  409. },
  410. });
  411. asset.isArchived = data.isArchived;
  412. assetStore?.updateAsset(data);
  413. dispatch(data.isArchived ? 'archived' : 'unarchived', data);
  414. notificationController.show({
  415. type: NotificationType.Info,
  416. message: asset.isArchived ? `Added to archive` : `Removed from archive`,
  417. });
  418. } catch (error) {
  419. await handleError(error, `Unable to ${asset.isArchived ? `add asset to` : `remove asset from`} archive`);
  420. }
  421. };
  422. const handleRunJob = async (name: AssetJobName) => {
  423. try {
  424. await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
  425. notificationController.show({ type: NotificationType.Info, message: api.getAssetJobMessage(name) });
  426. } catch (error) {
  427. await handleError(error, `Unable to submit job`);
  428. }
  429. };
  430. /**
  431. * Slide show mode
  432. */
  433. let assetViewerHtmlElement: HTMLElement;
  434. const slideshowHistory = new SlideshowHistory((assetId: string) => {
  435. setAssetId(assetId);
  436. $restartSlideshowProgress = true;
  437. });
  438. const handleVideoStarted = () => {
  439. if ($slideshowState === SlideshowState.PlaySlideshow) {
  440. $stopSlideshowProgress = true;
  441. }
  442. };
  443. const handleVideoEnded = async () => {
  444. if ($slideshowState === SlideshowState.PlaySlideshow) {
  445. await navigateAssetForward();
  446. }
  447. };
  448. const handlePlaySlideshow = async () => {
  449. try {
  450. await assetViewerHtmlElement.requestFullscreen();
  451. } catch (error) {
  452. console.error('Error entering fullscreen', error);
  453. $slideshowState = SlideshowState.StopSlideshow;
  454. }
  455. };
  456. const handleStopSlideshow = async () => {
  457. try {
  458. if (document.fullscreenElement) {
  459. await document.exitFullscreen();
  460. }
  461. } catch (error) {
  462. console.error('Error exiting fullscreen', error);
  463. } finally {
  464. $stopSlideshowProgress = true;
  465. $slideshowState = SlideshowState.None;
  466. }
  467. };
  468. const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => {
  469. const { isMouseOver } = e.detail;
  470. if (isMouseOver) {
  471. previewStackedAsset = asset;
  472. } else {
  473. previewStackedAsset = undefined;
  474. }
  475. };
  476. const handleUnstack = async () => {
  477. try {
  478. const ids = $stackAssetsStore.map(({ id }) => id);
  479. await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, removeParent: true } });
  480. for (const child of $stackAssetsStore) {
  481. child.stackParentId = null;
  482. child.stackCount = 0;
  483. child.stack = [];
  484. assetStore?.addAsset(child);
  485. }
  486. dispatch('unstack');
  487. notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
  488. } catch (error) {
  489. await handleError(error, `Unable to unstack`);
  490. }
  491. };
  492. </script>
  493. <section
  494. id="immich-asset-viewer"
  495. 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"
  496. >
  497. <!-- Top navigation bar -->
  498. {#if $slideshowState === SlideshowState.None}
  499. <div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
  500. <AssetViewerNavBar
  501. {asset}
  502. isMotionPhotoPlaying={shouldPlayMotionPhoto}
  503. showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
  504. showZoomButton={asset.type === AssetTypeEnum.Image}
  505. showMotionPlayButton={!!asset.livePhotoVideoId}
  506. showDownloadButton={shouldShowDownloadButton}
  507. showDetailButton={shouldShowDetailButton}
  508. showSlideshow={!!assetStore}
  509. hasStackChildren={$stackAssetsStore.length > 0}
  510. on:goBack={closeViewer}
  511. on:showDetail={showDetailInfoHandler}
  512. on:download={() => downloadFile(asset)}
  513. on:delete={trashOrDelete}
  514. on:favorite={toggleFavorite}
  515. on:addToAlbum={() => openAlbumPicker(false)}
  516. on:addToSharedAlbum={() => openAlbumPicker(true)}
  517. on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
  518. on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
  519. on:toggleArchive={toggleArchive}
  520. on:asProfileImage={() => (isShowProfileImageCrop = true)}
  521. on:runJob={({ detail: job }) => handleRunJob(job)}
  522. on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
  523. on:unstack={handleUnstack}
  524. />
  525. </div>
  526. {/if}
  527. {#if $slideshowState === SlideshowState.None && showNavigation}
  528. <div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 mb-[60px] justify-self-start">
  529. <NavigationArea on:click={navigateAssetBackward}><Icon path={mdiChevronLeft} size="36" /></NavigationArea>
  530. </div>
  531. {/if}
  532. <!-- Asset Viewer -->
  533. <div class="z-[1000] relative col-start-1 col-span-4 row-start-1 row-span-full" bind:this={assetViewerHtmlElement}>
  534. {#if $slideshowState != SlideshowState.None}
  535. <div class="z-[1000] absolute w-full flex">
  536. <SlideshowBar
  537. on:prev={navigateAssetBackward}
  538. on:next={navigateAssetForward}
  539. on:close={() => ($slideshowState = SlideshowState.StopSlideshow)}
  540. />
  541. </div>
  542. {/if}
  543. {#if previewStackedAsset}
  544. {#key previewStackedAsset.id}
  545. {#if previewStackedAsset.type === AssetTypeEnum.Image}
  546. <PhotoViewer asset={previewStackedAsset} on:close={closeViewer} haveFadeTransition={false} />
  547. {:else}
  548. <VideoViewer
  549. assetId={previewStackedAsset.id}
  550. on:close={closeViewer}
  551. on:onVideoEnded={handleVideoEnded}
  552. on:onVideoStarted={handleVideoStarted}
  553. />
  554. {/if}
  555. {/key}
  556. {:else}
  557. {#key asset.id}
  558. {#if !asset.resized}
  559. <div class="flex h-full w-full justify-center">
  560. <div
  561. class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray"
  562. >
  563. <Icon path={mdiImageBrokenVariant} size="25%" />
  564. </div>
  565. </div>
  566. {:else if asset.type === AssetTypeEnum.Image}
  567. {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
  568. <VideoViewer
  569. assetId={asset.livePhotoVideoId}
  570. on:close={closeViewer}
  571. on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
  572. />
  573. {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
  574. .toLowerCase()
  575. .endsWith('.insp'))}
  576. <PanoramaViewer {asset} />
  577. {:else}
  578. <PhotoViewer {asset} on:close={closeViewer} />
  579. {/if}
  580. {:else}
  581. <VideoViewer
  582. assetId={asset.id}
  583. on:close={closeViewer}
  584. on:onVideoEnded={handleVideoEnded}
  585. on:onVideoStarted={handleVideoStarted}
  586. />
  587. {/if}
  588. {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
  589. <div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
  590. <ActivityStatus
  591. disabled={!album?.isActivityEnabled}
  592. {isLiked}
  593. {numberOfComments}
  594. {isShowActivity}
  595. on:favorite={handleFavorite}
  596. on:openActivityTab={handleOpenActivity}
  597. />
  598. </div>
  599. {/if}
  600. {/key}
  601. {/if}
  602. {#if $stackAssetsStore.length > 0 && withStacked}
  603. <div
  604. id="stack-slideshow"
  605. class="z-[1005] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 mb-1 overflow-x-auto horizontal-scrollbar"
  606. >
  607. <div class="relative whitespace-nowrap transition-all">
  608. {#each $stackAssetsStore as stackedAsset (stackedAsset.id)}
  609. <div
  610. class="{stackedAsset.id == asset.id
  611. ? '-translate-y-[1px]'
  612. : '-translate-y-0'} inline-block px-1 transition-transform"
  613. >
  614. <Thumbnail
  615. class="{stackedAsset.id == asset.id
  616. ? 'bg-transparent border-2 border-white'
  617. : 'bg-gray-700/40'} inline-block hover:bg-transparent"
  618. asset={stackedAsset}
  619. on:click={() => (asset = stackedAsset)}
  620. on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
  621. readonly
  622. thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
  623. showStackedIcon={false}
  624. />
  625. {#if stackedAsset.id == asset.id}
  626. <div class="w-full flex place-items-center place-content-center">
  627. <div class="w-2 h-2 bg-white rounded-full flex mt-[2px]" />
  628. </div>
  629. {/if}
  630. </div>
  631. {/each}
  632. </div>
  633. </div>
  634. {/if}
  635. </div>
  636. {#if $slideshowState === SlideshowState.None && showNavigation}
  637. <div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
  638. <NavigationArea on:click={navigateAssetForward}><Icon path={mdiChevronRight} size="36" /></NavigationArea>
  639. </div>
  640. {/if}
  641. {#if $slideshowState === SlideshowState.None && $isShowDetail}
  642. <div
  643. transition:fly={{ duration: 150 }}
  644. id="detail-panel"
  645. class="z-[1002] row-start-1 row-span-4 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
  646. translate="yes"
  647. >
  648. <DetailPanel
  649. {asset}
  650. albumId={album?.id}
  651. albums={appearsInAlbums}
  652. on:close={() => ($isShowDetail = false)}
  653. on:close-viewer={handleCloseViewer}
  654. on:description-focus-in={disableKeyDownEvent}
  655. on:description-focus-out={enableKeyDownEvent}
  656. />
  657. </div>
  658. {/if}
  659. {#if isShared && album && isShowActivity && user}
  660. <div
  661. transition:fly={{ duration: 150 }}
  662. id="activity-panel"
  663. class="z-[1002] row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4"
  664. translate="yes"
  665. >
  666. <ActivityViewer
  667. {user}
  668. disabled={!album.isActivityEnabled}
  669. assetType={asset.type}
  670. albumOwnerId={album.ownerId}
  671. albumId={album.id}
  672. assetId={asset.id}
  673. {isLiked}
  674. bind:reactions
  675. on:addComment={handleAddComment}
  676. on:deleteComment={handleRemoveComment}
  677. on:deleteLike={() => (isLiked = null)}
  678. on:close={() => (isShowActivity = false)}
  679. />
  680. </div>
  681. {/if}
  682. {#if isShowAlbumPicker}
  683. <AlbumSelectionModal
  684. shared={addToSharedAlbum}
  685. on:newAlbum={handleAddToNewAlbum}
  686. on:newSharedAlbum={handleAddToNewAlbum}
  687. on:album={handleAddToAlbum}
  688. on:close={() => (isShowAlbumPicker = false)}
  689. />
  690. {/if}
  691. {#if isShowDeleteConfirmation}
  692. <ConfirmDialogue
  693. title="Delete {getAssetType(asset.type)}"
  694. confirmText="Delete"
  695. on:confirm={deleteAsset}
  696. on:cancel={() => (isShowDeleteConfirmation = false)}
  697. >
  698. <svelte:fragment slot="prompt">
  699. <p>
  700. Are you sure you want to delete this {getAssetType(asset.type).toLowerCase()}? This will also remove it from
  701. its album(s).
  702. </p>
  703. <p><b>You cannot undo this action!</b></p>
  704. </svelte:fragment>
  705. </ConfirmDialogue>
  706. {/if}
  707. {#if isShowProfileImageCrop}
  708. <ProfileImageCropper
  709. {asset}
  710. on:close={() => (isShowProfileImageCrop = false)}
  711. on:close-viewer={handleCloseViewer}
  712. />
  713. {/if}
  714. </section>
  715. <style>
  716. #immich-asset-viewer {
  717. contain: layout;
  718. }
  719. .horizontal-scrollbar::-webkit-scrollbar {
  720. width: 8px;
  721. height: 10px;
  722. }
  723. /* Track */
  724. .horizontal-scrollbar::-webkit-scrollbar-track {
  725. background: #000000;
  726. border-radius: 16px;
  727. }
  728. /* Handle */
  729. .horizontal-scrollbar::-webkit-scrollbar-thumb {
  730. background: rgba(159, 159, 159, 0.408);
  731. border-radius: 16px;
  732. }
  733. /* Handle on hover */
  734. .horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
  735. background: #adcbfa;
  736. border-radius: 16px;
  737. }
  738. </style>