mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
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:
parent
b609f35841
commit
fe19f9ba84
@ -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",
|
||||||
|
@ -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 })),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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> {
|
||||||
|
@ -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': {
|
||||||
|
try {
|
||||||
paused = false;
|
paused = false;
|
||||||
await videoPlayer?.play();
|
await videoPlayer?.play();
|
||||||
await progressBarController.set(1);
|
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
|
||||||
|
if (current) {
|
||||||
setProgressDuration(current.asset);
|
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);
|
} else {
|
||||||
|
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'reset'));
|
||||||
|
handlePromiseError(handleAction('initPlayer[AssetViewClosed]', 'play'));
|
||||||
}
|
}
|
||||||
|
playerInitialized = true;
|
||||||
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) => {
|
afterNavigate(({ from, to, type }) => {
|
||||||
if (!current) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
memoryStore.initialize().then(
|
||||||
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;
|
let target = null;
|
||||||
if (to?.params?.assetId) {
|
if (to?.params?.assetId) {
|
||||||
target = to;
|
target = to;
|
||||||
} else if (from?.params?.assetId) {
|
} else if (from?.params?.assetId) {
|
||||||
target = from;
|
target = from;
|
||||||
} else {
|
} else {
|
||||||
target = $page;
|
target = page;
|
||||||
}
|
}
|
||||||
|
|
||||||
current = loadFromParams($memories, target);
|
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))}
|
||||||
|
@ -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,8 +74,7 @@
|
|||||||
</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}"
|
||||||
@ -93,7 +92,6 @@
|
|||||||
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>
|
||||||
|
119
web/src/lib/stores/memory.store.svelte.ts
Normal file
119
web/src/lib/stores/memory.store.svelte.ts
Normal 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();
|
@ -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);
|
|
||||||
};
|
|
Loading…
x
Reference in New Issue
Block a user