mirror of
https://github.com/immich-app/immich.git
synced 2026-02-14 21:08:15 +03:00
* Fix fade in for video-native-viewer. The previous implementation never actually faded in the video element. Fix this by ensuring the video element is only added to the DOM after mounting, so Svelte can handle the fade-in transition correctly. * Refactor asset viewing in memory page. Split photo and video viewing into separate components to ensure they work similarly to the assets viewer. The previous implementation faded out the assets, while the assets-viewer only fades assets in. For images, add a spinner while waiting for the image to load, before adding the image to the DOM. For videos, add the video to the DOM after mounting the component. In both cases, the assets fade in smoothly, like the regular assets viewer. * fix: styling --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
661 lines
24 KiB
Svelte
661 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { afterNavigate, goto } from '$app/navigation';
|
|
import { page } from '$app/state';
|
|
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
|
import { resizeObserver } from '$lib/actions/resize-observer';
|
|
import { shortcuts } from '$lib/actions/shortcut';
|
|
import MemoryPhotoViewer from '$lib/components/memory-page/memory-photo-viewer.svelte';
|
|
import MemoryVideoViewer from '$lib/components/memory-page/memory-video-viewer.svelte';
|
|
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
|
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
|
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
|
|
import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte';
|
|
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
|
|
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
|
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
|
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
|
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
|
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
|
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
|
import {
|
|
notificationController,
|
|
NotificationType,
|
|
} from '$lib/components/shared-components/notification/notification';
|
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
|
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
|
|
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
|
import { preferences } from '$lib/stores/user.store';
|
|
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
|
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
|
|
import { IconButton } from '@immich/ui';
|
|
import {
|
|
mdiCardsOutline,
|
|
mdiChevronDown,
|
|
mdiChevronLeft,
|
|
mdiChevronRight,
|
|
mdiChevronUp,
|
|
mdiDotsVertical,
|
|
mdiHeart,
|
|
mdiHeartOutline,
|
|
mdiImageMinusOutline,
|
|
mdiImageSearch,
|
|
mdiPause,
|
|
mdiPlay,
|
|
mdiPlus,
|
|
mdiSelectAll,
|
|
mdiVolumeHigh,
|
|
mdiVolumeOff,
|
|
} from '@mdi/js';
|
|
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
|
import { DateTime } from 'luxon';
|
|
import { t } from 'svelte-i18n';
|
|
import { Tween } from 'svelte/motion';
|
|
|
|
let memoryGallery: HTMLElement | undefined = $state();
|
|
let memoryWrapper: HTMLElement | undefined = $state();
|
|
let galleryInView = $state(false);
|
|
let galleryFirstLoad = $state(true);
|
|
let playerInitialized = $state(false);
|
|
let paused = $state(false);
|
|
let current = $state<MemoryAsset | undefined>(undefined);
|
|
let currentMemoryAssetFull = $derived.by(async () =>
|
|
current?.asset ? await getAssetInfo({ id: current.asset.id, key: authManager.key }) : undefined,
|
|
);
|
|
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
|
|
|
|
let isSaved = $derived(current?.memory.isSaved);
|
|
let viewerHeight = $state(0);
|
|
|
|
const { isViewing } = assetViewingStore;
|
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
|
// need to include padding in the viewport for gallery
|
|
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
|
|
const assetInteraction = new AssetInteraction();
|
|
let progressBarController: Tween<number> | undefined = $state(undefined);
|
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
|
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
|
const handleNavigate = async (asset?: { id: string }) => {
|
|
if ($isViewing) {
|
|
return asset;
|
|
}
|
|
|
|
if (!asset) {
|
|
return;
|
|
}
|
|
|
|
await goto(asHref(asset));
|
|
};
|
|
const setProgressDuration = (asset: TimelineAsset) => {
|
|
if (asset.isVideo) {
|
|
const timeParts = asset.duration!.split(':').map(Number);
|
|
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
|
|
progressBarController = new Tween<number>(0, {
|
|
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
|
|
});
|
|
} else {
|
|
progressBarController = new Tween<number>(0, {
|
|
duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0),
|
|
});
|
|
}
|
|
};
|
|
const handleNextAsset = () => handleNavigate(current?.next?.asset);
|
|
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
|
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
|
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
|
const handleEscape = async () => goto(AppRoute.PHOTOS);
|
|
const handleSelectAll = () =>
|
|
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
|
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
|
|
// leaving these log statements here as comments. Very useful to figure out what's going on during dev!
|
|
// console.log(`handleAction[${callingContext}] called with: ${action}`);
|
|
if (!progressBarController) {
|
|
// console.log(`handleAction[${callingContext}] NOT READY!`);
|
|
return;
|
|
}
|
|
|
|
switch (action) {
|
|
case 'play': {
|
|
try {
|
|
paused = false;
|
|
await videoPlayer?.play();
|
|
await progressBarController.set(1);
|
|
} catch (error) {
|
|
// this may happen if browser blocks auto-play of the video on first page load. This can either be a setting
|
|
// or just default in certain browsers on page load without any DOM interaction by user.
|
|
console.error(`handleAction[${callingContext}] videoPlayer play problem: ${error}`);
|
|
paused = true;
|
|
await progressBarController.set(0);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'pause': {
|
|
paused = true;
|
|
videoPlayer?.pause();
|
|
await progressBarController.set(progressBarController.current);
|
|
break;
|
|
}
|
|
|
|
case 'reset': {
|
|
paused = false;
|
|
videoPlayer?.pause();
|
|
await progressBarController.set(0);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
const handleProgress = async (progress: number) => {
|
|
if (!progressBarController) {
|
|
return;
|
|
}
|
|
|
|
if (progress === 1 && !paused) {
|
|
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
|
|
}
|
|
};
|
|
|
|
const toProgressPercentage = (index: number) => {
|
|
if (!progressBarController || current?.assetIndex === undefined) {
|
|
return 0;
|
|
}
|
|
if (index < current?.assetIndex) {
|
|
return 100;
|
|
}
|
|
if (index > current?.assetIndex) {
|
|
return 0;
|
|
}
|
|
return progressBarController.current * 100;
|
|
};
|
|
|
|
const handleDeleteOrArchiveAssets = (ids: string[]) => {
|
|
if (!current) {
|
|
return;
|
|
}
|
|
memoryStore.hideAssetsFromMemory(ids);
|
|
init(page);
|
|
};
|
|
const handleDeleteMemoryAsset = async () => {
|
|
if (!current) {
|
|
return;
|
|
}
|
|
|
|
await memoryStore.deleteAssetFromMemory(current.asset.id);
|
|
init(page);
|
|
};
|
|
const handleDeleteMemory = async () => {
|
|
if (!current) {
|
|
return;
|
|
}
|
|
|
|
await memoryStore.deleteMemory(current.memory.id);
|
|
notificationController.show({ message: $t('removed_memory'), type: NotificationType.Info });
|
|
init(page);
|
|
};
|
|
const handleSaveMemory = async () => {
|
|
if (!current) {
|
|
return;
|
|
}
|
|
|
|
const newSavedState = !current.memory.isSaved;
|
|
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
|
|
notificationController.show({
|
|
message: newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'),
|
|
type: NotificationType.Info,
|
|
});
|
|
init(page);
|
|
};
|
|
const handleGalleryScrollsIntoView = () => {
|
|
galleryInView = true;
|
|
handlePromiseError(handleAction('galleryInView', 'pause'));
|
|
};
|
|
const handleGalleryScrollsOutOfView = () => {
|
|
galleryInView = false;
|
|
// only call play after the first page load. When page first loads the gallery will not be visible
|
|
// and calling play here will result in duplicate invocation.
|
|
if (!galleryFirstLoad) {
|
|
handlePromiseError(handleAction('galleryOutOfView', 'play'));
|
|
}
|
|
galleryFirstLoad = false;
|
|
};
|
|
|
|
const loadFromParams = (page: Page | NavigationTarget | null) => {
|
|
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
|
|
return memoryStore.getMemoryAsset(assetId);
|
|
};
|
|
|
|
const init = (target: Page | NavigationTarget | null) => {
|
|
if (memoryStore.memories.length === 0) {
|
|
return handlePromiseError(goto(AppRoute.PHOTOS));
|
|
}
|
|
|
|
current = loadFromParams(target);
|
|
// Adjust the progress bar duration to the video length
|
|
if (current) {
|
|
setProgressDuration(current.asset);
|
|
}
|
|
playerInitialized = false;
|
|
};
|
|
|
|
const initPlayer = () => {
|
|
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.isVideo && !videoPlayer;
|
|
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
|
|
return;
|
|
}
|
|
if ($isViewing) {
|
|
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
|
} else {
|
|
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'reset'));
|
|
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'play'));
|
|
}
|
|
playerInitialized = true;
|
|
};
|
|
|
|
afterNavigate(({ from, to }) => {
|
|
memoryStore.initialize().then(
|
|
() => {
|
|
let target = null;
|
|
if (to?.params?.assetId) {
|
|
target = to;
|
|
} else if (from?.params?.assetId) {
|
|
target = from;
|
|
} else {
|
|
target = page;
|
|
}
|
|
|
|
init(target);
|
|
initPlayer();
|
|
},
|
|
(error) => {
|
|
console.error(`Error loading memories: ${error}`);
|
|
},
|
|
);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (progressBarController) {
|
|
handlePromiseError(handleProgress(progressBarController.current));
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (videoPlayer) {
|
|
videoPlayer.muted = $videoViewerMuted;
|
|
initPlayer();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<svelte:document
|
|
use:shortcuts={$isViewing
|
|
? []
|
|
: [
|
|
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
|
|
{ shortcut: { key: 'd' }, onShortcut: () => handleNextAsset() },
|
|
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => handlePreviousAsset() },
|
|
{ shortcut: { key: 'a' }, onShortcut: () => handlePreviousAsset() },
|
|
{ shortcut: { key: 'Escape' }, onShortcut: () => handleEscape() },
|
|
]}
|
|
/>
|
|
|
|
{#if assetInteraction.selectionActive}
|
|
<div class="sticky top-0 z-1 dark">
|
|
<AssetSelectControlBar
|
|
forceDark
|
|
assets={assetInteraction.selectedAssets}
|
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
|
>
|
|
<CreateSharedLink />
|
|
<IconButton
|
|
shape="round"
|
|
color="secondary"
|
|
variant="ghost"
|
|
aria-label={$t('select_all')}
|
|
icon={mdiSelectAll}
|
|
onclick={handleSelectAll}
|
|
/>
|
|
|
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
|
<AddToAlbum />
|
|
<AddToAlbum shared />
|
|
</ButtonContextMenu>
|
|
|
|
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
|
|
|
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
|
<DownloadAction menuItem />
|
|
<ChangeDate menuItem />
|
|
<ChangeDescription menuItem />
|
|
<ChangeLocation menuItem />
|
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={handleDeleteOrArchiveAssets} />
|
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
|
<TagAction menuItem />
|
|
{/if}
|
|
<DeleteAssets menuItem onAssetDelete={handleDeleteOrArchiveAssets} />
|
|
</ButtonContextMenu>
|
|
</AssetSelectControlBar>
|
|
</div>
|
|
{/if}
|
|
|
|
<section
|
|
id="memory-viewer"
|
|
class="w-full bg-immich-dark-gray"
|
|
bind:this={memoryWrapper}
|
|
use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}
|
|
>
|
|
{#if current}
|
|
<ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} forceDark multiRow>
|
|
{#snippet leading()}
|
|
{#if current}
|
|
<p class="text-lg">
|
|
{$memoryLaneTitle(current.memory)}
|
|
</p>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
|
|
<div class="w-[50px] dark">
|
|
<IconButton
|
|
shape="round"
|
|
variant="ghost"
|
|
color="secondary"
|
|
aria-label={paused ? $t('play_memories') : $t('pause_memories')}
|
|
icon={paused ? mdiPlay : mdiPause}
|
|
onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
|
|
/>
|
|
</div>
|
|
|
|
{#each current.memory.assets as asset, index (asset.id)}
|
|
<a class="relative w-full py-2" href={asHref(asset)} aria-label={$t('view')}>
|
|
<span class="absolute start-0 h-[2px] w-full bg-gray-500"></span>
|
|
<span class="absolute start-0 h-[2px] bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
|
|
</a>
|
|
{/each}
|
|
|
|
<div>
|
|
<p class="text-small">
|
|
{(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="w-[50px] dark">
|
|
<IconButton
|
|
shape="round"
|
|
variant="ghost"
|
|
color="secondary"
|
|
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
|
|
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
|
|
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</ControlAppBar>
|
|
|
|
{#if galleryInView}
|
|
<div
|
|
class="fixed top-10 start-1/2 -translate-x-1/2 transition-opacity dark z-1"
|
|
class:opacity-0={!galleryInView}
|
|
class:opacity-100={galleryInView}
|
|
>
|
|
<button
|
|
type="button"
|
|
onclick={() => memoryWrapper?.scrollIntoView({ behavior: 'smooth' })}
|
|
disabled={!galleryInView}
|
|
>
|
|
<IconButton
|
|
shape="round"
|
|
color="secondary"
|
|
aria-label={$t('hide_gallery')}
|
|
icon={mdiChevronUp}
|
|
onclick={() => {}}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
<!-- Viewer -->
|
|
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}>
|
|
<div
|
|
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
|
>
|
|
<!-- PREVIOUS MEMORY -->
|
|
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
|
<button
|
|
type="button"
|
|
class="relative h-full w-full rounded-2xl"
|
|
disabled={!current.previousMemory}
|
|
onclick={handlePreviousMemory}
|
|
>
|
|
{#if current.previousMemory && current.previousMemory.assets.length > 0}
|
|
<img
|
|
class="h-full w-full rounded-2xl object-cover"
|
|
src={getAssetThumbnailUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
|
alt={$t('previous_memory')}
|
|
draggable="false"
|
|
/>
|
|
{:else}
|
|
<enhanced:img
|
|
class="h-full w-full rounded-2xl object-cover"
|
|
src="$lib/assets/no-thumbnail.png"
|
|
sizes="min(271px,186px)"
|
|
alt={$t('previous_memory')}
|
|
draggable="false"
|
|
/>
|
|
{/if}
|
|
|
|
{#if current.previousMemory}
|
|
<div class="absolute bottom-4 end-4 text-start text-white">
|
|
<p class="text-xs font-semibold text-gray-200">{$t('previous').toUpperCase()}</p>
|
|
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- CURRENT MEMORY -->
|
|
<div
|
|
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
|
|
>
|
|
<div class="relative h-full w-full rounded-2xl bg-black">
|
|
{#key current.asset.id}
|
|
{#if current.asset.isVideo}
|
|
<MemoryVideoViewer
|
|
asset={current.asset}
|
|
bind:videoPlayer
|
|
videoViewerMuted={$videoViewerMuted}
|
|
videoViewerVolume={$videoViewerVolume}
|
|
/>
|
|
{:else}
|
|
<MemoryPhotoViewer asset={current.asset} />
|
|
{/if}
|
|
{/key}
|
|
|
|
<div
|
|
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
|
class:opacity-0={galleryInView}
|
|
class:opacity-100={!galleryInView}
|
|
>
|
|
<div class="flex items-center">
|
|
<IconButton
|
|
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
|
shape="round"
|
|
variant="ghost"
|
|
color="secondary"
|
|
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
|
onclick={() => handleSaveMemory()}
|
|
class="w-[48px] h-[48px]"
|
|
/>
|
|
<!-- <IconButton
|
|
icon={mdiShareVariantOutline}
|
|
shape="round"
|
|
variant="ghost"
|
|
size="giant"
|
|
color="secondary"
|
|
aria-label={$t('share')}
|
|
/> -->
|
|
<ButtonContextMenu
|
|
icon={mdiDotsVertical}
|
|
title={$t('menu')}
|
|
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
|
direction="left"
|
|
size="medium"
|
|
align="bottom-right"
|
|
>
|
|
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
|
<MenuOption
|
|
onClick={() => handleDeleteMemoryAsset()}
|
|
text={$t('remove_photo_from_memory')}
|
|
icon={mdiImageMinusOutline}
|
|
/>
|
|
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
|
</ButtonContextMenu>
|
|
</div>
|
|
|
|
<div>
|
|
<IconButton
|
|
href="{AppRoute.PHOTOS}?at={current.asset.id}"
|
|
icon={mdiImageSearch}
|
|
aria-label={$t('view_in_timeline')}
|
|
color="secondary"
|
|
variant="ghost"
|
|
shape="round"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- CONTROL BUTTONS -->
|
|
{#if current.previous}
|
|
<div class="absolute top-1/2 start-0 ms-4 dark">
|
|
<IconButton
|
|
shape="round"
|
|
aria-label={$t('previous_memory')}
|
|
icon={mdiChevronLeft}
|
|
variant="ghost"
|
|
color="secondary"
|
|
size="giant"
|
|
onclick={handlePreviousAsset}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if current.next}
|
|
<div class="absolute top-1/2 end-0 me-4 dark">
|
|
<IconButton
|
|
shape="round"
|
|
aria-label={$t('next_memory')}
|
|
icon={mdiChevronRight}
|
|
variant="ghost"
|
|
color="secondary"
|
|
size="giant"
|
|
onclick={handleNextAsset}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
|
<p>
|
|
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
|
locale: $locale,
|
|
})}
|
|
</p>
|
|
<p>
|
|
{#await currentMemoryAssetFull then asset}
|
|
{asset?.exifInfo?.city || ''}
|
|
{asset?.exifInfo?.country || ''}
|
|
{/await}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- NEXT MEMORY -->
|
|
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
|
<button
|
|
type="button"
|
|
class="relative h-full w-full rounded-2xl"
|
|
onclick={handleNextMemory}
|
|
disabled={!current.nextMemory}
|
|
>
|
|
{#if current.nextMemory && current.nextMemory.assets.length > 0}
|
|
<img
|
|
class="h-full w-full rounded-2xl object-cover"
|
|
src={getAssetThumbnailUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
|
alt={$t('next_memory')}
|
|
draggable="false"
|
|
/>
|
|
{:else}
|
|
<enhanced:img
|
|
class="h-full w-full rounded-2xl object-cover"
|
|
src="$lib/assets/no-thumbnail.png"
|
|
sizes="min(271px,186px)"
|
|
alt={$t('next_memory')}
|
|
draggable="false"
|
|
/>
|
|
{/if}
|
|
|
|
{#if current.nextMemory}
|
|
<div class="absolute bottom-4 start-4 text-start text-white">
|
|
<p class="text-xs font-semibold text-gray-200">{$t('up_next').toUpperCase()}</p>
|
|
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if current}
|
|
<!-- GALLERY VIEWER -->
|
|
<section class="bg-immich-dark-gray p-4">
|
|
<div
|
|
class="sticky mb-10 flex place-content-center place-items-center transition-all dark"
|
|
class:opacity-0={galleryInView}
|
|
class:opacity-100={!galleryInView}
|
|
>
|
|
<IconButton
|
|
shape="round"
|
|
color="secondary"
|
|
aria-label={$t('show_gallery')}
|
|
icon={mdiChevronDown}
|
|
onclick={() => memoryGallery?.scrollIntoView({ behavior: 'smooth' })}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
id="gallery-memory"
|
|
use:intersectionObserver={{
|
|
onIntersect: handleGalleryScrollsIntoView,
|
|
onSeparate: handleGalleryScrollsOutOfView,
|
|
bottom: '-200px',
|
|
}}
|
|
bind:this={memoryGallery}
|
|
>
|
|
<GalleryViewer
|
|
onNext={handleNextAsset}
|
|
onPrevious={handlePreviousAsset}
|
|
assets={currentTimelineAssets}
|
|
viewport={galleryViewport}
|
|
{assetInteraction}
|
|
slidingWindowOffset={viewerHeight}
|
|
/>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
.main-view {
|
|
box-shadow:
|
|
0 4px 4px 0 rgba(0, 0, 0, 0.3),
|
|
0 8px 12px 6px rgba(0, 0, 0, 0.15);
|
|
}
|
|
</style>
|