asset-viewer.svelte 25 KB

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