1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-17 15:47:54 +02:00

fix(web): more refactoring and tweaking of the memory viewer. (#19214)

* 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>
This commit is contained in:
Dag Stuan
2025-06-17 16:09:34 +02:00
committed by GitHub
parent 749f63e4a0
commit bd70824961
5 changed files with 209 additions and 107 deletions

View File

@ -2,6 +2,7 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
@ -41,8 +42,11 @@
let assetFileUrl = $state('');
let forceMuted = $state(false);
let isScrubbing = $state(false);
let showVideo = $state(false);
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
forceMuted = false;
@ -102,59 +106,61 @@
});
</script>
<div
transition:fade={{ duration: 150 }}
class="flex h-full select-none place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay
playsinline
controls
class="h-full object-contain"
use:swipe={() => ({})}
onswipe={onSwipe}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => {
if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted;
}
}}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
{#if showVideo}
<div
transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full select-none place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{/if}
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay
playsinline
controls
class="h-full object-contain"
use:swipe={() => ({})}
onswipe={onSwipe}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => {
if (!forceMuted) {
$videoViewerMuted = e.currentTarget.muted;
}
}}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
{/if}
{/if}
</div>
</div>
{/if}

View File

@ -0,0 +1,69 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
asset: TimelineAsset;
}
const { asset }: Props = $props();
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let loader = $state<HTMLImageElement>();
const onload = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
};
onMount(() => {
if (loader?.complete) {
onload();
}
loader?.addEventListener('load', onload);
return () => {
loader?.removeEventListener('load', onload);
};
});
const imageLoaderUrl = $derived(getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview }));
</script>
{#if !imageLoaded}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
{/if}
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if imageLoaded}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
<img
class="h-full w-full rounded-2xl object-contain transition-all"
src={assetFileUrl}
alt={$getAltText(asset)}
draggable="false"
/>
</div>
{/if}
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
asset: TimelineAsset;
videoPlayer: HTMLVideoElement | undefined;
videoViewerMuted?: boolean;
videoViewerVolume?: number;
}
let { asset, videoPlayer = $bindable(), videoViewerVolume, videoViewerMuted }: Props = $props();
let showVideo: boolean = $state(false);
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
});
</script>
{#if showVideo}
<div class="h-full w-full bg-pink-9000" transition:fade={{ duration: assetViewerFadeDuration }}>
<video
bind:this={videoPlayer}
autoplay
playsinline
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
muted={videoViewerMuted}
volume={videoViewerVolume}
></video>
</div>
{/if}

View File

@ -4,6 +4,8 @@
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';
@ -23,7 +25,7 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, assetViewerFadeDuration, QueryParameter } from '$lib/constants';
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';
@ -31,9 +33,8 @@
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 { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui';
@ -59,7 +60,6 @@
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { Tween } from 'svelte/motion';
import { fade } from 'svelte/transition';
let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state();
@ -363,15 +363,16 @@
{/snippet}
<div class="flex place-content-center place-items-center gap-2 overflow-hidden">
<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'))}
class="hover:text-black"
/>
<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')}>
@ -385,20 +386,23 @@
{(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)}
</p>
</div>
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$videoViewerMuted ? $t('unmute_memories') : $t('mute_memories')}
icon={$videoViewerMuted ? mdiVolumeOff : mdiVolumeHigh}
onclick={() => ($videoViewerMuted = !$videoViewerMuted)}
/>
<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-20 start-1/2 -translate-x-1/2 transition-opacity"
class="fixed top-10 start-1/2 -translate-x-1/2 transition-opacity dark z-1"
class:opacity-0={!galleryInView}
class:opacity-100={galleryInView}
>
@ -409,7 +413,6 @@
>
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('hide_gallery')}
icon={mdiChevronUp}
@ -463,30 +466,16 @@
>
<div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
{#if current.asset.isVideo}
<video
bind:this={videoPlayer}
autoplay
playsinline
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetPlaybackUrl({ id: current.asset.id })}
poster={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
draggable="false"
muted={$videoViewerMuted}
volume={$videoViewerVolume}
transition:fade
></video>
{:else}
<img
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
alt={$getAltText(current.asset)}
draggable="false"
transition:fade
/>
{/if}
</div>
{#if current.asset.isVideo}
<MemoryVideoViewer
asset={current.asset}
bind:videoPlayer
videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume}
/>
{:else}
<MemoryPhotoViewer asset={current.asset} />
{/if}
{/key}
<div
@ -543,26 +532,28 @@
</div>
<!-- CONTROL BUTTONS -->
{#if current.previous}
<div class="absolute top-1/2 start-0 ms-4">
<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">
<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>
@ -626,13 +617,12 @@
<!-- GALLERY VIEWER -->
<section class="bg-immich-dark-gray p-4">
<div
class="sticky mb-10 flex place-content-center place-items-center transition-all"
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"
variant="ghost"
color="secondary"
aria-label={$t('show_gallery')}
icon={mdiChevronDown}

View File

@ -62,8 +62,6 @@
document.removeEventListener('scroll', onScroll);
}
});
let buttonClass = $derived(forceDark ? 'hover:text-immich-dark-gray' : undefined);
</script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
@ -90,7 +88,6 @@
variant="ghost"
icon={backIcon}
size="large"
class={buttonClass}
/>
{/if}
{@render leading?.()}