fix(web): asset selection on memories page is broken (#16759)

* 16712: Proper intialisation of the memory store to avoid loading up duplicate object refs of the same asset.

* 16712: Add auth to memory mapping so isFavorite is actually return correctly from the server.

* 16712: Move logic that belongs in the store into the store.

* 16712: Cleanup.

* 16712: Fix init behaviour.

* 16712: Add comment.

* 16712: Make method private.

* 16712: Fix import.

* 16712: Fix format.

* 16712: Cleaner if/else and fix typo.

* fix: icon size mismatch

* 16712: Fixed up state machine managing memory playback:
* Updated to `Tween` (`tweened` was deprecated)
* Removed `resetPromise`. Setting progressController to 0 had the same effect, so not really sure why it was there?
* Removed the many duplicate places the `handleAction` method was called. Now we just called it on `afterNavigate` as well as when `galleryInView` or `$isViewing` state changes.

* 16712: Add aria tag.

* 16712: Fix memory player duplicate invocation bugs. Now we should only call 'reset' and 'play' once, after navigate/page load. This should hopefully fix all the various bugs around playback.

* 16712: Cleanup

* 16712: Cleanup

* 16712: Cleanup

* 16712: Cleanup

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Andreas 2025-03-19 03:34:09 +11:00 committed by GitHub
parent b609f35841
commit fe19f9ba84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 305 additions and 239 deletions

View File

@ -1082,7 +1082,9 @@
"remove_url": "Remove URL", "remove_url": "Remove URL",
"remove_user": "Remove user", "remove_user": "Remove user",
"removed_api_key": "Removed API Key: {name}", "removed_api_key": "Removed API Key: {name}",
"remove_memory": "Remove memory",
"removed_memory": "Removed memory", "removed_memory": "Removed memory",
"remove_photo_from_memory": "Remove photo from this memory",
"removed_photo_from_memory": "Removed photo from memory", "removed_photo_from_memory": "Removed photo from memory",
"removed_from_archive": "Removed from archive", "removed_from_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites", "removed_from_favorites": "Removed from favorites",

View File

@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryType } from 'src/enum'; import { MemoryType } from 'src/enum';
import { MemoryItem } from 'src/types'; import { MemoryItem } from 'src/types';
@ -88,7 +89,7 @@ export class MemoryResponseDto {
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
} }
export const mapMemory = (entity: MemoryItem): MemoryResponseDto => { export const mapMemory = (entity: MemoryItem, auth: AuthDto): MemoryResponseDto => {
return { return {
id: entity.id, id: entity.id,
createdAt: entity.createdAt, createdAt: entity.createdAt,
@ -102,6 +103,6 @@ export const mapMemory = (entity: MemoryItem): MemoryResponseDto => {
type: entity.type as MemoryType, type: entity.type as MemoryType,
data: entity.data as unknown as MemoryData, data: entity.data as unknown as MemoryData,
isSaved: entity.isSaved, isSaved: entity.isSaved,
assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(asset as AssetEntity)), assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(asset as AssetEntity, { auth })),
}; };
}; };

View File

@ -74,13 +74,13 @@ export class MemoryService extends BaseService {
async search(auth: AuthDto, dto: MemorySearchDto) { async search(auth: AuthDto, dto: MemorySearchDto) {
const memories = await this.memoryRepository.search(auth.user.id, dto); const memories = await this.memoryRepository.search(auth.user.id, dto);
return memories.map((memory) => mapMemory(memory)); return memories.map((memory) => mapMemory(memory, auth));
} }
async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> { async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> {
await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] });
const memory = await this.findOrFail(id); const memory = await this.findOrFail(id);
return mapMemory(memory); return mapMemory(memory, auth);
} }
async create(auth: AuthDto, dto: MemoryCreateDto) { async create(auth: AuthDto, dto: MemoryCreateDto) {
@ -104,7 +104,7 @@ export class MemoryService extends BaseService {
allowedAssetIds, allowedAssetIds,
); );
return mapMemory(memory); return mapMemory(memory, auth);
} }
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> { async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
@ -116,7 +116,7 @@ export class MemoryService extends BaseService {
seenAt: dto.seenAt, seenAt: dto.seenAt,
}); });
return mapMemory(memory); return mapMemory(memory, auth);
} }
async remove(auth: AuthDto, id: string): Promise<void> { async remove(auth: AuthDto, id: string): Promise<void> {

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/state';
import { intersectionObserver } from '$lib/actions/intersection-observer'; import { intersectionObserver } from '$lib/actions/intersection-observer';
import { resizeObserver } from '$lib/actions/resize-observer'; import { resizeObserver } from '$lib/actions/resize-observer';
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
@ -27,21 +27,13 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type Viewport } from '$lib/stores/assets-store.svelte'; import { type Viewport } from '$lib/stores/assets-store.svelte';
import { loadMemories, memoryStore } from '$lib/stores/memory.store'; import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util'; import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { import { AssetMediaSize, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
AssetMediaSize,
AssetTypeEnum,
deleteMemory,
removeMemoryAssets,
updateMemory,
type AssetResponseDto,
type MemoryResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { IconButton } from '@immich/ui';
import { import {
mdiCardsOutline, mdiCardsOutline,
@ -58,105 +50,50 @@
mdiPlay, mdiPlay,
mdiPlus, mdiPlus,
mdiSelectAll, mdiSelectAll,
mdiVolumeOff,
mdiVolumeHigh, mdiVolumeHigh,
mdiVolumeOff,
} from '@mdi/js'; } from '@mdi/js';
import type { NavigationTarget } from '@sveltejs/kit'; import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { tweened } from 'svelte/motion'; import { Tween } from 'svelte/motion';
import { derived as storeDerived } from 'svelte/store';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
type MemoryIndex = {
memoryIndex: number;
assetIndex: number;
};
type MemoryAsset = MemoryIndex & {
memory: MemoryResponseDto;
asset: AssetResponseDto;
previousMemory?: MemoryResponseDto;
previous?: MemoryAsset;
next?: MemoryAsset;
nextMemory?: MemoryResponseDto;
};
let memoryGallery: HTMLElement | undefined = $state(); let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state(); let memoryWrapper: HTMLElement | undefined = $state();
let galleryInView = $state(false); let galleryInView = $state(false);
let galleryFirstLoad = $state(true);
let playerInitialized = $state(false);
let paused = $state(false); let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined); let current = $state<MemoryAsset | undefined>(undefined);
let isSaved = $derived(current?.memory.isSaved); let isSaved = $derived(current?.memory.isSaved);
let resetPromise = $state(Promise.resolve());
const { isViewing } = assetViewingStore; const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
let progressBarController = tweened<number>(0, { let progressBarController: Tween<number> | undefined = $state(undefined);
duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0),
});
let videoPlayer: HTMLVideoElement | undefined = $state(); let videoPlayer: HTMLVideoElement | undefined = $state();
const memories = storeDerived(memoryStore, (memories) => {
memories = memories ?? [];
const memoryAssets: MemoryAsset[] = [];
let previous: MemoryAsset | undefined;
for (const [memoryIndex, memory] of memories.entries()) {
for (const [assetIndex, asset] of memory.assets.entries()) {
const current = {
memory,
memoryIndex,
previousMemory: memories[memoryIndex - 1],
nextMemory: memories[memoryIndex + 1],
asset,
assetIndex,
previous,
};
memoryAssets.push(current);
if (previous) {
previous.next = current;
}
previous = current;
}
}
return memoryAssets;
});
const loadFromParams = (memories: MemoryAsset[], page: typeof $page | NavigationTarget | null) => {
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
handlePromiseError(handleAction($isViewing ? 'pause' : 'reset'));
return memories.find((memory) => memory.asset.id === assetId) ?? memories[0];
};
const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`; const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: AssetResponseDto) => { const handleNavigate = async (asset?: AssetResponseDto) => {
if ($isViewing) { if ($isViewing) {
return asset; return asset;
} }
await handleAction('reset');
if (!asset) { if (!asset) {
return; return;
} }
// Adjust the progress bar duration to the video length
setProgressDuration(asset);
await goto(asHref(asset)); await goto(asHref(asset));
}; };
const setProgressDuration = (asset: AssetResponseDto) => { const setProgressDuration = (asset: AssetResponseDto) => {
if (asset.type === AssetTypeEnum.Video) { if (asset.type === AssetTypeEnum.Video) {
const timeParts = asset.duration.split(':').map(Number); const timeParts = asset.duration.split(':').map(Number);
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000; const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
progressBarController = tweened<number>(0, { progressBarController = new Tween<number>(0, {
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0), duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
}); });
} else { } else {
progressBarController = tweened<number>(0, { progressBarController = new Tween<number>(0, {
duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0),
}); });
} }
@ -167,159 +104,187 @@
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(AppRoute.PHOTOS); const handleEscape = async () => goto(AppRoute.PHOTOS);
const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []); const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []);
const handleAction = async (action: 'reset' | 'pause' | 'play') => { 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) { switch (action) {
case 'play': { case 'play': {
paused = false; try {
await videoPlayer?.play(); paused = false;
await progressBarController.set(1); 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 defaut 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; break;
} }
case 'pause': { case 'pause': {
paused = true; paused = true;
videoPlayer?.pause(); videoPlayer?.pause();
await progressBarController.set($progressBarController); await progressBarController.set(progressBarController.current);
break; break;
} }
case 'reset': { case 'reset': {
paused = false; paused = false;
videoPlayer?.pause(); videoPlayer?.pause();
resetPromise = progressBarController.set(0); await progressBarController.set(0);
break; break;
} }
} }
}; };
const handleProgress = async (progress: number) => { const handleProgress = async (progress: number) => {
if (progress === 0 && !paused) { if (!progressBarController) {
await handleAction('play');
return; return;
} }
if (progress === 1) { if (progress === 1 && !paused) {
await progressBarController.set(0); await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
await (current?.next ? handleNextAsset() : handleAction('pause'));
} }
}; };
const handleUpdate = () => {
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) { if (!current) {
return; return;
} }
// eslint-disable-next-line no-self-assign memoryStore.hideAssetsFromMemory(ids);
current.memory.assets = current.memory.assets; init(page);
}; };
const handleDeleteMemoryAsset = async () => {
const handleRemove = (ids: string[]) => {
if (!current) { if (!current) {
return; return;
} }
const idSet = new Set(ids);
current.memory.assets = current.memory.assets.filter((asset) => !idSet.has(asset.id)); await memoryStore.deleteAssetFromMemory(current.asset.id);
init(); 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 init = () => { const loadFromParams = (page: Page | NavigationTarget | null) => {
$memoryStore = $memoryStore.filter((memory) => memory.assets.length > 0); const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
if ($memoryStore.length === 0) { return memoryStore.getMemoryAsset(assetId);
};
const init = (target: Page | NavigationTarget | null) => {
if (memoryStore.memories.length === 0) {
return handlePromiseError(goto(AppRoute.PHOTOS)); return handlePromiseError(goto(AppRoute.PHOTOS));
} }
current = loadFromParams($memories, $page); current = loadFromParams(target);
// Adjust the progress bar duration to the video length // Adjust the progress bar duration to the video length
setProgressDuration(current.asset); if (current) {
setProgressDuration(current.asset);
}
playerInitialized = false;
}; };
const handleDeleteMemoryAsset = async (current?: MemoryAsset) => { const initPlayer = () => {
if (!current) { const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.type === AssetTypeEnum.Video && !videoPlayer;
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return; return;
} }
if ($isViewing) {
if (current.memory.assets.length === 1) { handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
return handleDeleteMemory(current);
}
if (current.previous) {
current.previous.next = current.next;
}
if (current.next) {
current.next.previous = current.previous;
}
current.memory.assets = current.memory.assets.filter((asset) => asset.id !== current.asset.id);
// eslint-disable-next-line no-self-assign
$memoryStore = $memoryStore;
await removeMemoryAssets({ id: current.memory.id, bulkIdsDto: { ids: [current.asset.id] } });
};
const handleDeleteMemory = async (current?: MemoryAsset) => {
if (!current) {
return;
}
await deleteMemory({ id: current.memory.id });
notificationController.show({ message: $t('removed_memory'), type: NotificationType.Info });
await loadMemories();
init();
};
const handleSaveMemory = async (current?: MemoryAsset) => {
if (!current) {
return;
}
current.memory.isSaved = !current.memory.isSaved;
await updateMemory({
id: current.memory.id,
memoryUpdateDto: {
isSaved: current.memory.isSaved,
},
});
notificationController.show({
message: current.memory.isSaved ? $t('added_to_favorites') : $t('removed_from_favorites'),
type: NotificationType.Info,
});
};
onMount(async () => {
if (!$memoryStore) {
await loadMemories();
}
init();
});
afterNavigate(({ from, to }) => {
let target = null;
if (to?.params?.assetId) {
target = to;
} else if (from?.params?.assetId) {
target = from;
} else { } else {
target = $page; handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'reset'));
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'play'));
} }
playerInitialized = true;
};
current = loadFromParams($memories, target); afterNavigate(({ from, to, type }) => {
if (type === 'enter') {
// afterNavigate triggers twice on first page load (once when mounted with 'enter' and then a second time
// with the actual 'goto' to URL).
return;
}
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(() => { $effect(() => {
handlePromiseError(handleProgress($progressBarController)); if (progressBarController) {
}); handlePromiseError(handleProgress(progressBarController.current));
}
$effect(() => {
handlePromiseError(handleAction(galleryInView ? 'pause' : 'play'));
}); });
$effect(() => { $effect(() => {
if (videoPlayer) { if (videoPlayer) {
videoPlayer.muted = $videoViewerMuted; videoPlayer.muted = $videoViewerMuted;
initPlayer();
} }
}); });
</script> </script>
@ -350,24 +315,24 @@
<AddToAlbum shared /> <AddToAlbum shared />
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} onFavorite={handleUpdate} /> <FavoriteAction removeFavorite={assetInteraction.isAllFavorite} />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={handleRemove} /> <ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} onArchive={handleDeleteOrArchiveAssets} />
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
<DeleteAssets menuItem onAssetDelete={handleRemove} /> <DeleteAssets menuItem onAssetDelete={handleDeleteOrArchiveAssets} />
</ButtonContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
</div> </div>
{/if} {/if}
<section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}> <section id="memory-viewer" class="w-full bg-immich-dark-gray" bind:this={memoryWrapper}>
{#if current && current.memory.assets.length > 0} {#if current}
<ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} forceDark multiRow> <ControlAppBar onClose={() => goto(AppRoute.PHOTOS)} forceDark multiRow>
{#snippet leading()} {#snippet leading()}
{#if current} {#if current}
@ -381,22 +346,14 @@
<CircleIconButton <CircleIconButton
title={paused ? $t('play_memories') : $t('pause_memories')} title={paused ? $t('play_memories') : $t('pause_memories')}
icon={paused ? mdiPlay : mdiPause} icon={paused ? mdiPlay : mdiPause}
onclick={() => handleAction(paused ? 'play' : 'pause')} onclick={() => handlePromiseError(handleAction('PlayPauseButtonClick', paused ? 'play' : 'pause'))}
class="hover:text-black" class="hover:text-black"
/> />
{#each current.memory.assets as asset, index (asset.id)} {#each current.memory.assets as asset, index (asset.id)}
<a class="relative w-full py-2" href={asHref(asset)}> <a class="relative w-full py-2" href={asHref(asset)} aria-label={$t('view')}>
<span class="absolute left-0 h-[2px] w-full bg-gray-500"></span> <span class="absolute left-0 h-[2px] w-full bg-gray-500"></span>
{#await resetPromise} <span class="absolute left-0 h-[2px] bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
<span class="absolute left-0 h-[2px] bg-white" style:width={`${index < current.assetIndex ? 100 : 0}%`}
></span>
{:then}
<span
class="absolute left-0 h-[2px] bg-white"
style:width={`${index < current.assetIndex ? 100 : index > current.assetIndex ? 0 : $progressBarController * 100}%`}
></span>
{/await}
</a> </a>
{/each} {/each}
@ -474,7 +431,7 @@
<div class="relative h-full w-full rounded-2xl bg-black"> <div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id} {#key current.asset.id}
<div transition:fade class="h-full w-full"> <div transition:fade class="h-full w-full">
{#if current.asset.type == AssetTypeEnum.Video} {#if current.asset.type === AssetTypeEnum.Video}
<video <video
bind:this={videoPlayer} bind:this={videoPlayer}
autoplay autoplay
@ -510,8 +467,8 @@
variant="ghost" variant="ghost"
color="secondary" color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')} aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory(current)} onclick={() => handleSaveMemory()}
class="text-white dark:text-white" class="text-white dark:text-white w-[48px] h-[48px]"
/> />
<!-- <IconButton <!-- <IconButton
icon={mdiShareVariantOutline} icon={mdiShareVariantOutline}
@ -525,16 +482,16 @@
icon={mdiDotsVertical} icon={mdiDotsVertical}
padding="3" padding="3"
title={$t('menu')} title={$t('menu')}
onclick={() => handleAction('pause')} onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
direction="left" direction="left"
size="20" size="20"
align="bottom-right" align="bottom-right"
class="text-white dark:text-white" class="text-white dark:text-white"
> >
<MenuOption onClick={() => handleDeleteMemory(current)} text="Remove memory" icon={mdiCardsOutline} /> <MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
<MenuOption <MenuOption
onClick={() => handleDeleteMemoryAsset(current)} onClick={() => handleDeleteMemoryAsset()}
text="Remove photo from this memory" text={$t('remove_photo_from_memory')}
icon={mdiImageMinusOutline} icon={mdiImageMinusOutline}
/> />
<!-- shortcut={{ key: 'l', shift: shared }} --> <!-- shortcut={{ key: 'l', shift: shared }} -->
@ -642,8 +599,8 @@
<div <div
id="gallery-memory" id="gallery-memory"
use:intersectionObserver={{ use:intersectionObserver={{
onIntersect: () => (galleryInView = true), onIntersect: handleGalleryScrollsIntoView,
onSeparate: () => (galleryInView = false), onSeparate: handleGalleryScrollsOutOfView,
bottom: '-200px', bottom: '-200px',
}} }}
use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))} use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))}

View File

@ -2,7 +2,7 @@
import { resizeObserver } from '$lib/actions/resize-observer'; import { resizeObserver } from '$lib/actions/resize-observer';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { loadMemories, memoryStore } from '$lib/stores/memory.store'; import { memoryStore } from '$lib/stores/memory.store.svelte';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils'; import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
@ -10,10 +10,10 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let shouldRender = $derived($memoryStore?.length > 0); let shouldRender = $derived(memoryStore.memories?.length > 0);
onMount(async () => { onMount(async () => {
await loadMemories(); await memoryStore.initialize();
}); });
let memoryLaneElement: HTMLElement | undefined = $state(); let memoryLaneElement: HTMLElement | undefined = $state();
@ -74,26 +74,24 @@
</div> </div>
{/if} {/if}
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}> <div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each $memoryStore as memory (memory.id)} {#each memoryStore.memories as memory (memory.id)}
{#if memory.assets.length > 0} <a
<a class="memory-card relative mr-8 last:mr-0 inline-block aspect-[3/4] md:aspect-[4/3] xl:aspect-video h-[215px] rounded-xl"
class="memory-card relative mr-8 last:mr-0 inline-block aspect-[3/4] md:aspect-[4/3] xl:aspect-video h-[215px] rounded-xl" href="{AppRoute.MEMORY}?{QueryParameter.ID}={memory.assets[0].id}"
href="{AppRoute.MEMORY}?{QueryParameter.ID}={memory.assets[0].id}" >
> <img
<img class="h-full w-full rounded-xl object-cover"
class="h-full w-full rounded-xl object-cover" src={getAssetThumbnailUrl(memory.assets[0].id)}
src={getAssetThumbnailUrl(memory.assets[0].id)} alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })}
alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })} draggable="false"
draggable="false" />
/> <p class="absolute bottom-2 left-4 z-10 text-lg text-white">
<p class="absolute bottom-2 left-4 z-10 text-lg text-white"> {$memoryLaneTitle(memory)}
{$memoryLaneTitle(memory)} </p>
</p> <div
<div class="absolute left-0 top-0 z-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
class="absolute left-0 top-0 z-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20" ></div>
></div> </a>
</a>
{/if}
{/each} {/each}
</div> </div>
</section> </section>

View File

@ -0,0 +1,119 @@
import { asLocalTimeISO } from '$lib/utils/date-time';
import {
type AssetResponseDto,
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
} from '@immich/sdk';
import { DateTime } from 'luxon';
type MemoryIndex = {
memoryIndex: number;
assetIndex: number;
};
export type MemoryAsset = MemoryIndex & {
memory: MemoryResponseDto;
asset: AssetResponseDto;
previousMemory?: MemoryResponseDto;
previous?: MemoryAsset;
next?: MemoryAsset;
nextMemory?: MemoryResponseDto;
};
class MemoryStoreSvelte {
memories = $state<MemoryResponseDto[]>([]);
private initialized = false;
private memoryAssets = $derived.by(() => {
const memoryAssets: MemoryAsset[] = [];
let previous: MemoryAsset | undefined;
for (const [memoryIndex, memory] of this.memories.entries()) {
for (const [assetIndex, asset] of memory.assets.entries()) {
const current = {
memory,
memoryIndex,
previousMemory: this.memories[memoryIndex - 1],
nextMemory: this.memories[memoryIndex + 1],
asset,
assetIndex,
previous,
};
memoryAssets.push(current);
if (previous) {
previous.next = current;
}
previous = current;
}
}
return memoryAssets;
});
getMemoryAsset(assetId: string | undefined) {
return this.memoryAssets.find((memoryAsset) => memoryAsset.asset.id === assetId) ?? this.memoryAssets[0];
}
hideAssetsFromMemory(ids: string[]) {
const idSet = new Set<string>(ids);
for (const memory of this.memories) {
memory.assets = memory.assets.filter((asset) => !idSet.has(asset.id));
}
// if we removed all assets from a memory, then lets remove those memories (we don't show memories with 0 assets)
this.memories = this.memories.filter((memory) => memory.assets.length > 0);
}
async deleteMemory(id: string) {
const memory = this.memories.find((memory) => memory.id === id);
if (memory) {
await deleteMemory({ id: memory.id });
this.memories = this.memories.filter((memory) => memory.id !== id);
}
}
async deleteAssetFromMemory(assetId: string) {
const memoryWithAsset = this.memories.find((memory) => memory.assets.some((asset) => asset.id === assetId));
if (memoryWithAsset) {
if (memoryWithAsset.assets.length === 1) {
await this.deleteMemory(memoryWithAsset.id);
} else {
await removeMemoryAssets({ id: memoryWithAsset.id, bulkIdsDto: { ids: [assetId] } });
memoryWithAsset.assets = memoryWithAsset.assets.filter((asset) => asset.id !== assetId);
}
}
}
async updateMemorySaved(id: string, isSaved: boolean) {
const memory = this.memories.find((memory) => memory.id === id);
if (memory) {
await updateMemory({
id,
memoryUpdateDto: {
isSaved,
},
});
memory.isSaved = isSaved;
}
}
async initialize() {
if (this.initialized) {
return;
}
this.initialized = true;
await this.loadAllMemories();
}
private async loadAllMemories() {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
}
}
export const memoryStore = new MemoryStoreSvelte();

View File

@ -1,11 +0,0 @@
import { asLocalTimeISO } from '$lib/utils/date-time';
import { searchMemories, type MemoryResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { writable } from 'svelte/store';
export const memoryStore = writable<MemoryResponseDto[]>();
export const loadMemories = async () => {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
memoryStore.set(memories);
};