feat: memories view (#28675)

* wip web memories view

* paginate searchMemories

* improve memories ui

* feat(mobile): memories view

* improve memories web ui

* fix(web): avoid unnecessary memory refreshes

* refactor(web): move timline memory manager filter

* fix(web): memories infinitely loading on 0 results

* fix(web): memories resetting

* feat(web): animated memory previews

* feat(web): memory page dark styling

* chore(mobile): fix failing memory lane tests

* chore(mobile): remove old import

* chore(e2e): fix failing memory lane tests

* chore(server): revert searchMemories changes

* chore(server): lint

* chore(mobile): fix bad merge

* chore(e2e): fix memory ui tests

* chore(web): rename /memory to /memories

* chore(mobile): linting

* chore(web): linting

* fix(web): prevent duplicate memory page loading

* fix(web): lint

* fix(web): memories page improvements

* chore(mobile): lint

* fix(mobile): fix translations

* fix(mobile): build errors

* fix(web): prevent memories from infinitely loading

* chore(e2e)): fix failng memory viewer test

* chore(web): clean up types in memory manager

* chore(web): lint

* feat: update list and detail memory page layouts

* onlyFavorites through drift

* rename to get all

* mobile: use widgetWhen in memory list page

* remove redundant check

* fix: e2e

---------

Co-authored-by: Timon <mail@timonrieger.de>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Ben Beckford
2026-08-24 19:16:33 -05:00
committed by GitHub
co-authored by Timon Jason Rasmussen shenlong-tanwen
parent 8dcfd36fa5
commit 351be95769
49 changed files with 1191 additions and 335 deletions
@@ -80,5 +80,7 @@ export function generateMemoriesFromTimeline(
);
}
memories.sort((a, b) => new Date(b.memoryAt).getMilliseconds() - new Date(a.memoryAt).getMilliseconds());
return memories;
}
+10
View File
@@ -62,4 +62,14 @@ export const setupMemoryMockApiRoutes = async (
await route.fallback();
});
await context.route('**/api/memories/statistics*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
total: memories.length,
},
});
});
};
@@ -79,7 +79,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const firstMemory = memories[0];
const middleAsset = firstMemory.assets[1];
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, middleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -93,7 +93,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const firstMemory = memories[0];
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirstMemory.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -107,7 +107,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const lastMemory = memories.at(-1)!;
const firstAssetOfLastMemory = lastMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, firstAssetOfLastMemory.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -123,7 +123,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -141,7 +141,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -155,7 +155,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const firstMemory = memories[0];
const veryFirstAsset = firstMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, veryFirstAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -169,7 +169,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const lastMemory = memories.at(-1)!;
const veryLastAsset = lastMemory.assets.at(-1)!;
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, lastMemory.id, veryLastAsset.id);
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -187,7 +187,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, secondMemory.id, firstAssetOfSecond.id);
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -203,7 +203,7 @@ test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
const firstAssetOfSecond = secondMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, firstMemory.id, lastAssetOfFirst.id);
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
@@ -277,7 +277,7 @@ test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
const singleAssetMemory = memories[1];
const singleAsset = singleAssetMemory.assets[0];
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAssetMemory.id, singleAsset.id);
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
await memoryAssetViewerUtils.waitForViewerOpen(page);
+4 -9
View File
@@ -3,11 +3,11 @@ import type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test';
function getAssetIdFromUrl(url: URL): string | null {
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
const pathMatch = url.pathname.match(/\/memories\/[^/]+\/photos\/([^/]+)/);
if (pathMatch) {
return pathMatch[1];
}
return url.searchParams.get('id');
return url.searchParams.get('assetId');
}
export const memoryViewerUtils = {
@@ -20,13 +20,8 @@ export const memoryViewerUtils = {
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
},
async openMemoryPage(page: Page) {
await page.goto('/memory');
await this.waitForMemoryLoad(page);
},
async openMemoryPageWithAsset(page: Page, assetId: string) {
await page.goto(`/memory?id=${assetId}`);
async openMemoryPageWithAsset(page: Page, memoryId: string, assetId: string) {
await page.goto(`/memories/${memoryId}?assetId=${assetId}`);
await this.waitForMemoryLoad(page);
},
};
+3
View File
@@ -1453,9 +1453,12 @@
"memories": "Memories",
"memories_all_caught_up": "All caught up",
"memories_check_back_tomorrow": "Check back tomorrow for more memories",
"memories_current": "Current",
"memories_setting_description": "Manage what you see in your memories",
"memories_show_upcoming": "Show upcoming memories",
"memories_start_over": "Start Over",
"memories_swipe_to_close": "Swipe up to close",
"memories_upcoming": "Upcoming",
"memory": "Memory",
"memory_lane_title": "Memory Lane {title}",
"menu": "Menu",
+12 -12
View File
@@ -91,18 +91,6 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
@@ -145,6 +133,18 @@ checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
@@ -13,6 +13,10 @@ class DriftMemoryService {
return _repository.getAll(ownerId);
}
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyFavorites = false}) {
return _repository.getAll(ownerId, onlyToday: false, onlyFavorites: onlyFavorites);
}
Future<DriftMemory?> get(String memoryId) {
return _repository.get(memoryId);
}
@@ -12,10 +12,7 @@ class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMix
Drift get _db => attachedDatabase;
Future<List<DriftMemory>> getAll(String ownerId) async {
final now = DateTime.now();
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true, bool onlyFavorites = false}) async {
final query =
_db.select(_db.memoryEntity).join([
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
@@ -27,10 +24,21 @@ class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMix
),
])
..where(_db.memoryEntity.ownerId.equals(ownerId))
..where(_db.memoryEntity.deletedAt.isNull())
..where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc))
..where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc))
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
..where(_db.memoryEntity.deletedAt.isNull());
if (onlyFavorites) {
query.where(_db.memoryEntity.isSaved.equals(true));
}
if (onlyToday) {
final now = DateTime.now();
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
query.where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc));
query.where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc));
}
query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
final rows = await query.get();
if (rows.isEmpty) {
+1 -1
View File
@@ -111,7 +111,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
}
if (index == kPhotoTabIndex) {
ref.invalidate(driftMemoryFutureProvider);
ref.invalidate(driftMemoryLaneProvider);
}
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
@@ -39,7 +39,7 @@ class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@override
Widget build(BuildContext context) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
@@ -7,8 +7,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -133,7 +135,12 @@ class _CollectionCards extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
children: [
_PeopleCollectionCard(),
_PlacesCollectionCard(),
_LocalAlbumsCollectionCard(),
_MemoriesCollectionCard(),
],
),
),
);
@@ -329,6 +336,76 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
}
}
class _MemoriesCollectionCard extends ConsumerWidget {
const _MemoriesCollectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final memories = ref.watch(driftAllMemoriesProvider(false));
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const DriftMemoryListRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: memories.widgetWhen(
onLoading: () => const Center(child: CircularProgressIndicator()),
onData: (memories) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: memories.take(4).map((memory) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Thumbnail.remote(
remoteId: memory.assets[0].id,
thumbhash: memory.assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
);
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
context.t.memories,
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
@visibleForTesting
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class DriftMemoryListPage extends ConsumerStatefulWidget {
const DriftMemoryListPage({super.key});
@override
ConsumerState<DriftMemoryListPage> createState() => _DriftMemoryListPageState();
}
class _DriftMemoryListPageState extends ConsumerState<DriftMemoryListPage> {
bool _onlyFavorites = false;
@override
Widget build(BuildContext context) {
final memories = ref.watch(driftAllMemoriesProvider(_onlyFavorites));
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
appBar: AppBar(
title: Text(context.t.memories),
actions: [
IconButton(
icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline),
onPressed: () {
setState(() => _onlyFavorites = !_onlyFavorites);
},
),
],
),
body: SafeArea(
child: memories.widgetWhen(
onData: (memories) {
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: constraints.maxWidth > 600 ? 4 : 2,
childAspectRatio: 0.5625,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
padding: const EdgeInsets.all(16),
itemCount: memories.length,
itemBuilder: (context, index) => GestureDetector(
onTap: () {
DriftMemoryPage.setMemory(ref, memories[index]);
unawaited(context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)));
},
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
child: AbsorbPointer(
child: Thumbnail.remote(
remoteId: memories[index].assets[0].id,
thumbhash: memories[index].assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
),
),
),
Positioned(
bottom: 16,
left: 16,
child: Text(
DateFormat.yMMMMd().format(memories[index].memoryAt),
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15),
),
),
if (memories[index].isSaved)
const Positioned(
top: 16,
right: 16,
child: Icon(Icons.favorite, color: Colors.white, size: 24),
),
],
),
),
);
},
),
),
);
},
);
}
}
@@ -16,7 +16,7 @@ class DriftMemoryLane extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
final memoryLaneProvider = ref.watch(driftMemoryLaneProvider);
final memories = memoryLaneProvider.value ?? const [];
if (memories.isEmpty) {
return const SizedBox.shrink();
@@ -128,7 +128,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
syncSuccess = await backgroundManager.syncRemote();
}, "syncRemote"),
]);
_ref.invalidate(driftMemoryFutureProvider);
_ref.invalidate(driftAllMemoriesProvider);
if (syncSuccess) {
await Future.wait([
_safeRun(backgroundManager.hashAssets, "hashAssets").then((_) {
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/services/memory.service.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
@@ -20,3 +20,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>(
final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository);
return service.getMemoryLane(userId);
});
final driftAllMemoriesProvider = FutureProvider.autoDispose.family<List<DriftMemory>, bool>((ref, onlyFavorites) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
}
final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository);
return service.getAll(userId, onlyFavorites: onlyFavorites);
});
+2
View File
@@ -52,6 +52,7 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart';
import 'package:immich_mobile/presentation/pages/drift_map.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory_list.page.dart';
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
@@ -191,6 +192,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMemoryListRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
+2 -2
View File
@@ -19,7 +19,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'UserPreferencesResponseDto': {
'download.includeEmbeddedVideos': false,
'folders': FoldersResponse(enabled: false, sidebarWeb: false).toJson(),
'memories': MemoriesResponse(enabled: true, duration: 5).toJson(),
'memories': MemoriesResponse(enabled: true, duration: 5, sidebarWeb: false).toJson(),
'ratings': RatingsResponse(enabled: false).toJson(),
'people': PeopleResponse(enabled: true, sidebarWeb: false).toJson(),
'tags': TagsResponse(enabled: false, sidebarWeb: false).toJson(),
@@ -40,7 +40,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
'SyncAssetV1': {'isEdited': false},
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
'MemoriesResponse': {'duration': 5},
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
'WorkflowResponseDto': {'logging': false},
};
@@ -52,11 +52,11 @@ void main() {
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
});
group('driftMemoryFutureProvider', () {
group('driftMemoryLaneProvider', () {
test('re-queries after local midnight', () {
fakeAsync((async) {
final container = makeContainer();
container.listen(driftMemoryFutureProvider, (_, _) {});
container.listen(driftMemoryLaneProvider, (_, _) {});
async.flushMicrotasks();
verify(() => memoryRepository.getAll('user-1')).called(1);
@@ -74,7 +74,7 @@ void main() {
test('cancels the midnight timer when disposed', () {
fakeAsync((async) {
final container = makeContainer();
final subscription = container.listen(driftMemoryFutureProvider, (_, _) {});
final subscription = container.listen(driftMemoryLaneProvider, (_, _) {});
async.flushMicrotasks();
verify(() => memoryRepository.getAll('user-1')).called(1);
@@ -91,7 +91,7 @@ void main() {
fakeAsync((async) {
final container = makeContainer();
container.listen(driftMemoryFutureProvider, (_, _) {});
container.listen(driftMemoryLaneProvider, (_, _) {});
async.flushMicrotasks();
async.elapse(const Duration(hours: 25));
+72 -1
View File
@@ -8064,6 +8064,17 @@
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"description": "Memory ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "isSaved",
"required": false,
@@ -8082,6 +8093,15 @@
"type": "boolean"
}
},
{
"name": "isUpcoming",
"required": false,
"in": "query",
"description": "Filter by memories that have not been shown yet",
"schema": {
"type": "boolean"
}
},
{
"name": "order",
"required": false,
@@ -8090,6 +8110,17 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -8233,6 +8264,17 @@
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"description": "Memory ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "isSaved",
"required": false,
@@ -8251,6 +8293,15 @@
"type": "boolean"
}
},
{
"name": "isUpcoming",
"required": false,
"in": "query",
"description": "Filter by memories that have not been shown yet",
"schema": {
"type": "boolean"
}
},
{
"name": "order",
"required": false,
@@ -8259,6 +8310,17 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -22179,11 +22241,16 @@
"enabled": {
"description": "Whether memories are enabled",
"type": "boolean"
},
"sidebarWeb": {
"description": "Whether memories appear in web sidebar",
"type": "boolean"
}
},
"required": [
"duration",
"enabled"
"enabled",
"sidebarWeb"
],
"type": "object"
},
@@ -22198,6 +22265,10 @@
"enabled": {
"description": "Whether memories are enabled",
"type": "boolean"
},
"sidebarWeb": {
"description": "Whether memories appear in web sidebar",
"type": "boolean"
}
},
"type": "object"
+18 -2
View File
@@ -678,6 +678,8 @@ export type MemoriesResponse = {
duration: number;
/** Whether memories are enabled */
enabled: boolean;
/** Whether memories appear in web sidebar */
sidebarWeb: boolean;
};
export type PeopleResponse = {
/** Whether people are enabled */
@@ -762,6 +764,8 @@ export type MemoriesUpdate = {
duration?: number;
/** Whether memories are enabled */
enabled?: boolean;
/** Whether memories appear in web sidebar */
sidebarWeb?: boolean;
};
export type PeopleUpdate = {
/** Whether people are enabled */
@@ -5411,11 +5415,14 @@ export function reverseGeocode({ lat, lon }: {
/**
* Retrieve memories
*/
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
export function searchMemories({ $for, id, isSaved, isTrashed, isUpcoming, order, page, size, $type }: {
$for?: string;
id?: string;
isSaved?: boolean;
isTrashed?: boolean;
isUpcoming?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
@@ -5424,9 +5431,12 @@ export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }:
data: MemoryResponseDto[];
}>(`/memories${QS.query(QS.explode({
"for": $for,
id,
isSaved,
isTrashed,
isUpcoming,
order,
page,
size,
"type": $type
}))}`, {
@@ -5451,11 +5461,14 @@ export function createMemory({ memoryCreateDto }: {
/**
* Retrieve memories statistics
*/
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
export function memoriesStatistics({ $for, id, isSaved, isTrashed, isUpcoming, order, page, size, $type }: {
$for?: string;
id?: string;
isSaved?: boolean;
isTrashed?: boolean;
isUpcoming?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
@@ -5464,9 +5477,12 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
data: MemoryStatisticsResponseDto;
}>(`/memories/statistics${QS.query(QS.explode({
"for": $for,
id,
isSaved,
isTrashed,
isUpcoming,
order,
page,
size,
"type": $type
}))}`, {
+3
View File
@@ -9,11 +9,14 @@ import z from 'zod';
const MemorySearchSchema = z
.object({
id: z.uuidv4().optional().describe('Memory ID'),
type: MemoryTypeSchema.optional(),
for: isoDateToDate.optional().describe('Filter by date'),
isTrashed: stringToBool.optional().describe('Include trashed memories'),
isSaved: stringToBool.optional().describe('Filter by saved status'),
isUpcoming: stringToBool.optional().describe('Filter by memories that have not been shown yet'),
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
page: z.coerce.number().int().min(1).optional().describe('Page number'),
order: AssetOrderWithRandomSchema.optional(),
})
.meta({ id: 'MemorySearchDto' });
+2
View File
@@ -22,6 +22,7 @@ const MemoriesUpdateSchema = z
.object({
enabled: z.boolean().optional().describe('Whether memories are enabled'),
duration: z.int().min(1).optional().describe('Memory duration in seconds'),
sidebarWeb: z.boolean().optional().describe('Whether memories appear in web sidebar'),
})
.optional()
.meta({ id: 'MemoriesUpdate' });
@@ -140,6 +141,7 @@ const MemoriesResponseSchema = z
.object({
enabled: z.boolean().describe('Whether memories are enabled'),
duration: z.int().describe('Memory duration in seconds'),
sidebarWeb: z.boolean().describe('Whether memories appear in web sidebar'),
})
.meta({ id: 'MemoriesResponse' });
+89
View File
@@ -64,6 +64,7 @@ where
"deletedAt" is null
and "ownerId" = $3
order by
"showAt" desc nulls last,
"memoryAt" desc
-- MemoryRepository.search (date filter)
@@ -112,6 +113,94 @@ where
and "deletedAt" is null
and "ownerId" = $5
order by
"showAt" desc nulls last,
"memoryAt" desc
-- MemoryRepository.search (upcoming filter)
select
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset".*
from
"asset"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
where
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
and not exists (
select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
)
order by
"asset"."fileCreatedAt" asc
) as agg
) as "assets",
"memory".*
from
"memory"
where
"showAt" > $3
and "deletedAt" is null
and "ownerId" = $4
order by
"showAt" desc nulls last,
"memoryAt" desc
-- MemoryRepository.search (not upcoming filter)
select
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset".*
from
"asset"
inner join "memory_asset" on "asset"."id" = "memory_asset"."assetId"
where
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
and not exists (
select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId"
and "person"."ownerId" = "asset"."ownerId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
)
order by
"asset"."fileCreatedAt" asc
) as agg
) as "assets",
"memory".*
from
"memory"
where
(
"showAt" is null
or "showAt" <= $3
)
and "deletedAt" is null
and "ownerId" = $4
order by
"showAt" desc nulls last,
"memoryAt" desc
-- MemoryRepository.get
+20 -5
View File
@@ -39,6 +39,12 @@ export class MemoryRepository implements IBulkAsset {
.where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)]))
.where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])),
)
.$if(dto.isUpcoming !== undefined, (qb) => {
const now = DateTime.now().toJSDate();
return dto.isUpcoming
? qb.where('showAt', '>', now)
: qb.where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', now)]));
})
.where('deletedAt', dto.isTrashed ? 'is not' : 'is', null)
.where('ownerId', '=', ownerId);
}
@@ -56,6 +62,8 @@ export class MemoryRepository implements IBulkAsset {
@GenerateSql(
{ params: [DummyValue.UUID, {}] },
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
{ name: 'upcoming filter', params: [DummyValue.UUID, { isUpcoming: true }] },
{ name: 'not upcoming filter', params: [DummyValue.UUID, { isUpcoming: false }] },
)
search(ownerId: string, dto: MemorySearchDto) {
return this.searchBuilder(ownerId, dto)
@@ -88,12 +96,19 @@ export class MemoryRepository implements IBulkAsset {
).as('assets'),
)
.selectAll('memory')
.$call((qb) =>
dto.order === AssetOrderWithRandom.Random
? qb.orderBy(sql`RANDOM()`)
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
)
.$call((qb) => {
if (dto.order === AssetOrderWithRandom.Random) {
return qb.orderBy(sql`RANDOM()`);
}
const direction = (dto.order?.toLowerCase() || 'desc') as OrderByDirection;
return qb
.orderBy('showAt', (ob) => (direction === 'asc' ? ob.asc() : ob.desc()).nullsLast())
.orderBy('memoryAt', direction);
})
.$if(dto.id !== undefined, (qb) => qb.where('id', '=', dto.id!))
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
.execute();
}
@@ -35,6 +35,7 @@ describe(MemoryService.name, () => {
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
const memory2 = MemoryFactory.create({ ownerId: userId });
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
mocks.memory.statistics.mockResolvedValue({ total: 2 });
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
expect.arrayContaining([
@@ -44,6 +45,8 @@ describe(MemoryService.name, () => {
}),
]),
);
mocks.memory.search.mockResolvedValue([]);
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
});
it('should map empty result', async () => {
+1
View File
@@ -557,6 +557,7 @@ export type UserPreferences = {
memories: {
enabled: boolean;
duration: number;
sidebarWeb: boolean;
};
people: {
enabled: boolean;
+1
View File
@@ -17,6 +17,7 @@ const getDefaultPreferences = (): UserPreferences => {
memories: {
enabled: true,
duration: 5,
sidebarWeb: false,
},
people: {
enabled: true,
@@ -14,6 +14,8 @@
mdiAccountOutline,
mdiArchiveArrowDown,
mdiArchiveArrowDownOutline,
mdiCards,
mdiCardsOutline,
mdiFolderOutline,
mdiHeart,
mdiHeartOutline,
@@ -48,6 +50,10 @@
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
{/if}
{#if authManager.preferences.memories.enabled && authManager.preferences.memories.sidebarWeb}
<NavbarItem title={$t('memories')} href={Route.memories()} icon={mdiCardsOutline} activeIcon={mdiCards} />
{/if}
{#if authManager.preferences.people.enabled && authManager.preferences.people.sidebarWeb}
<NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} />
{/if}
+2
View File
@@ -44,8 +44,10 @@ export const dateFormats = {
export enum QueryParameter {
ACTION = 'action',
ASSET_ID = 'assetId',
ID = 'id',
IS_OPEN = 'isOpen',
IS_SAVED = 'isSaved',
OPEN_SETTING = 'openSetting',
PREVIOUS_ROUTE = 'previousRoute',
QUERY = 'query',
@@ -2,11 +2,11 @@ import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import type { ZoomImageWheelState } from '@zoom-image/core';
import { cubicOut } from 'svelte/easing';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { PersistedLocalStorage } from '$lib/utils/persisted';
export interface Faces {
id: string;
@@ -18,9 +18,6 @@ export interface Faces {
boundingBoxY2: number;
}
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
const createDefaultZoomState = (): ZoomImageWheelState => ({
currentRotation: 0,
currentZoom: 1,
@@ -75,11 +72,11 @@ class AssetViewerManager extends BaseEventManager<Events> {
}
get isShowDetailPanel() {
return isShowDetailPanel.current;
return userPreferencesManager.showDetailPanel;
}
get isShowAssetPath() {
return isShowAssetPath.current;
return userPreferencesManager.showAssetPath;
}
get isFaceEditMode() {
@@ -121,11 +118,11 @@ class AssetViewerManager extends BaseEventManager<Events> {
}
private set isShowDetailPanel(value: boolean) {
isShowDetailPanel.current = value;
userPreferencesManager.showDetailPanel = value;
}
private set isShowAssetPath(value: boolean) {
isShowAssetPath.current = value;
userPreferencesManager.showAssetPath = value;
}
onZoomChange(state: ZoomImageWheelState) {
+303 -71
View File
@@ -1,26 +1,58 @@
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
import {
type AssetResponseDto,
MemorySearchOrder,
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
memoriesStatistics,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { isEqual, omitBy } from 'lodash-es';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
import { fromStore, get } from 'svelte/store';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
import { Route } from '$lib/route';
import { memoryLaneTitle } from '$lib/utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
type MemoryIndex = {
memoryIndex: number;
assetIndex: number;
type MemoriesSearchDto = Parameters<typeof searchMemories>[0];
type AdjacentMemory = {
href: string;
assetId: string;
title: string;
};
export type MemoryAsset = MemoryIndex & {
/** Everything the memory viewer renders, for the position currently in the url. */
export type MemoryState = {
memory: MemoryResponseDto;
asset: TimelineAsset;
previousMemory?: MemoryResponseDto;
previous?: MemoryAsset;
next?: MemoryAsset;
nextMemory?: MemoryResponseDto;
/** The assets the asset viewer can navigate through, spanning the adjacent memories. */
viewerAssets: AssetResponseDto[];
previousMemory?: AdjacentMemory;
nextMemory?: AdjacentMemory;
previousHref?: string;
nextHref?: string;
getAssetHref: (assetId: string) => string | undefined;
};
class MemoryManager {
#loading: Promise<void> | undefined;
#loading = $state<Promise<void>>();
#filters = $state<MemoriesSearchDto>({ size: 250, order: MemorySearchOrder.Desc });
#hasNextPage: boolean = true;
#page: number = 1;
#total: number | undefined = $state();
#queued: boolean = false;
constructor() {
eventManager.on({
@@ -36,44 +68,158 @@ class MemoryManager {
this.scheduleHourlyRefresh();
}
ready() {
get filters() {
return this.#filters;
}
setFilters(filters: MemoriesSearchDto) {
filters = omitBy(filters, (item) => item === undefined);
if (!isEqual(this.#filters, filters)) {
this.#filters = filters;
this.clearCache();
void this.loadNextPage();
}
}
refresh() {
return this.initialize();
}
memories = $state<MemoryResponseDto[]>([]);
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: toTimelineAsset(asset),
assetIndex,
previous,
};
applyPreferences() {
const { showUpcoming, onlyFavorites } = userPreferencesManager.memories;
this.setFilters({
order: MemorySearchOrder.Desc,
isSaved: onlyFavorites ? true : undefined,
isUpcoming: showUpcoming ? undefined : false,
});
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];
return this.refresh();
}
hideAssetsFromMemory(ids: string[]) {
memories = $state<MemoryResponseDto[]>([]);
#memoryLaneTitle = fromStore(memoryLaneTitle);
#url = $derived({
memoryId: page.params.id,
assetId: page.url.searchParams.get(QueryParameter.ASSET_ID) ?? undefined,
isSaved: page.url.searchParams.get(QueryParameter.IS_SAVED) === 'true' || undefined,
});
memoriesHref = $derived(Route.memories({ isSaved: this.#url.isSaved }));
#lookup = $derived.by(() => {
const lookup = new SvelteMap<string, { memoryIndex: number; assetIndexes: Map<string, number> }>();
for (const [memoryIndex, memory] of this.memories.entries()) {
const assetIndexes = new Map(memory.assets.map((asset, assetIndex) => [asset.id, assetIndex] as const));
lookup.set(memory.id, { memoryIndex, assetIndexes });
}
return lookup;
});
current = $derived.by((): MemoryState | undefined => {
const { memoryId, assetId } = this.#url;
if (memoryId === undefined || assetId === undefined) {
return;
}
const location = this.#lookup.get(memoryId);
if (!location) {
return;
}
const { memoryIndex, assetIndexes } = location;
const assetIndex = assetIndexes.get(assetId);
if (assetIndex === undefined) {
return;
}
const memory = this.memories[memoryIndex];
const previousMemory = this.memories[memoryIndex - 1];
const nextMemory = this.memories[memoryIndex + 1];
return {
memory,
asset: toTimelineAsset(memory.assets[assetIndex]),
viewerAssets: [...(previousMemory?.assets ?? []), ...memory.assets, ...(nextMemory?.assets ?? [])],
previousMemory: this.#toAdjacentMemory(previousMemory),
nextMemory: this.#toAdjacentMemory(nextMemory),
previousHref:
assetIndex > 0
? this.#toHref(memory, memory.assets[assetIndex - 1])
: this.#toHref(previousMemory, previousMemory?.assets.at(-1)),
nextHref:
assetIndex < memory.assets.length - 1
? this.#toHref(memory, memory.assets[assetIndex + 1])
: this.#toHref(nextMemory, nextMemory?.assets[0]),
getAssetHref: (assetId: string) => this.#toHref(memory, { id: assetId }),
};
});
#toHref(memory: { id: string } | undefined, asset: { id: string } | undefined) {
return memory && asset
? Route.viewMemory({ id: memory.id, assetId: asset.id, isSaved: this.#url.isSaved })
: undefined;
}
#toAdjacentMemory(memory: MemoryResponseDto | undefined): AdjacentMemory | undefined {
if (!memory) {
return;
}
const asset = memory.assets[0];
const href = this.#toHref(memory, asset);
if (!href) {
return;
}
return { href, assetId: asset.id, title: this.#memoryLaneTitle.current(memory) };
}
// the memory holding an asset, checking the given memory first, then the ones next to it
getMemoryWithAsset(assetId: string, memoryId: string) {
const location = this.#lookup.get(memoryId);
if (!location) {
return;
}
const { memoryIndex } = location;
return [memoryIndex, memoryIndex - 1, memoryIndex + 1]
.map((index) => this.memories[index])
.find((memory) => memory && this.#lookup.get(memory.id)?.assetIndexes.has(assetId));
}
#getMemory(id: string) {
const location = this.#lookup.get(id);
return location ? this.memories[location.memoryIndex] : undefined;
}
/** Fetch a memory that is not part of the loaded pages, i.e. when opening a link to an older one. */
async loadMemory(id: string) {
const memory = this.#getMemory(id);
if (memory) {
return memory;
}
const [loaded] = await searchMemories({ id }).catch(() => []);
if (!loaded) {
return;
}
// memories are ordered by date, descending
const index = this.memories.findIndex(({ memoryAt }) => memoryAt < loaded.memoryAt);
this.memories.splice(index === -1 ? this.memories.length : index, 0, loaded);
return loaded;
}
async hideAssets(ids: string[]) {
if (this.current && ids.includes(this.current.asset.id)) {
await this.#leaveCurrentAsset();
}
const idSet = new Set<string>(ids);
for (const memory of this.memories) {
memory.assets = memory.assets.filter((asset) => !idSet.has(asset.id));
@@ -82,56 +228,135 @@ class MemoryManager {
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 deleteCurrentAsset() {
const current = this.current;
if (!current) {
return;
}
const memoryId = current.memory.id;
const assetId = current.asset.id;
await this.#leaveCurrentAsset();
await this.#deleteAsset(memoryId, assetId);
}
async deleteAssetFromMemory(assetId: string) {
const memoryWithAsset = this.memories.find((memory) => memory.assets.some((asset) => asset.id === assetId));
async deleteCurrentMemory() {
const current = this.current;
if (!current) {
return;
}
if (memoryWithAsset) {
if (memoryWithAsset.assets.length === 1) {
await this.deleteMemory(memoryWithAsset.id);
const { id } = current.memory;
await this.#goto(current.nextMemory?.href ?? current.previousMemory?.href ?? this.memoriesHref);
await this.#deleteMemory(id);
toastManager.primary(get(t)('removed_memory'));
}
async toggleCurrentMemorySaved() {
const memory = this.current?.memory;
if (!memory) {
return;
}
const isSaved = !memory.isSaved;
await updateMemory({ id: memory.id, memoryUpdateDto: { isSaved } });
memory.isSaved = isSaved;
toastManager.primary(get(t)(isSaved ? 'added_to_favorites' : 'removed_from_favorites'));
}
// navigate away before removing something, so the url never points at a deleted position
#leaveCurrentAsset() {
const { nextHref, previousHref } = this.current ?? {};
return this.#goto(nextHref ?? previousHref ?? this.memoriesHref);
}
#goto(href: string) {
return goto(href, { replaceState: true, noScroll: true, keepFocus: true });
}
async #deleteMemory(id: string) {
if (!this.#getMemory(id)) {
return;
}
await deleteMemory({ id });
this.memories = this.memories.filter((memory) => memory.id !== id);
}
async #deleteAsset(memoryId: string, assetId: string) {
const memory = this.#getMemory(memoryId);
if (!memory?.assets.some((asset) => asset.id === assetId)) {
return;
}
if (memory.assets.length === 1) {
await this.#deleteMemory(memoryId);
return;
}
await removeMemoryAssets({ id: memoryId, bulkIdsDto: { ids: [assetId] } });
memory.assets = memory.assets.filter((asset) => asset.id !== assetId);
}
loadNextPage() {
if (this.#hasNextPage) {
if (this.#loading === undefined) {
this.#loading = this.load(this.#page++);
} else {
await removeMemoryAssets({ id: memoryWithAsset.id, bulkIdsDto: { ids: [assetId] } });
memoryWithAsset.assets = memoryWithAsset.assets.filter((asset) => asset.id !== assetId);
this.#queued = true;
}
}
}
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;
}
get hasNextPage() {
return this.#hasNextPage;
}
get total() {
return this.#total;
}
get loading() {
return this.#loading;
}
private clearCache() {
this.#loading = undefined;
this.#hasNextPage = true;
this.#page = 1;
this.#total = undefined;
this.memories = [];
}
private initialize() {
if (!this.#loading) {
this.#loading = this.load();
this.#loading = this.load(this.#page++);
}
return this.#loading;
}
private async load() {
const memories = await searchMemories({ $for: DateTime.now().toFormat('yyyy-MM-dd') });
this.memories = memories.filter((memory) => memory.assets.length > 0);
private async load(page: number) {
const items = await searchMemories({ ...this.#filters, page });
for (const item of items) {
if (!this.#lookup.has(item.id)) {
this.memories.push(item);
}
}
if (this.#total === undefined) {
const { total } = await memoriesStatistics(this.#filters);
this.#total = total;
}
this.#hasNextPage = this.memories.length < this.#total;
this.#loading = undefined;
if (this.#queued) {
this.#queued = false;
this.#loading = this.load(this.#page++);
await this.#loading;
}
}
private scheduleHourlyRefresh() {
@@ -145,12 +370,19 @@ class MemoryManager {
const initialDelay = nextEvent.diff(now).as('milliseconds');
setTimeout(() => {
this.#loading = this.load();
if (this.#page <= 2) {
this.clearCache();
this.loadNextPage();
}
// Schedule subsequent events hourly
setInterval(
() => {
this.#loading = this.load();
if (this.#page > 2) {
return;
}
this.clearCache();
this.loadNextPage();
},
60 * 60 * 1000,
);
@@ -17,8 +17,8 @@ import {
retrieveRange as retrieveRangeUtil,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
getOrderingDate,
isAssetResponseDto,
@@ -94,19 +94,18 @@ export class TimelineManager extends VirtualScrollManager {
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#updatingViewportProximities = false;
#scrollableElement: HTMLElement | undefined = $state();
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
#unsubscribes: Array<() => void> = [];
get showAssetOwners() {
return this.#showAssetOwners.current;
return userPreferencesManager.showAssetOwners;
}
setShowAssetOwners(value: boolean) {
this.#showAssetOwners.current = value;
userPreferencesManager.showAssetOwners = value;
}
toggleShowAssetOwners() {
this.#showAssetOwners.current = !this.#showAssetOwners.current;
userPreferencesManager.showAssetOwners = !userPreferencesManager.showAssetOwners;
}
constructor() {
@@ -0,0 +1,59 @@
import { isEqual } from 'lodash-es';
import { PersistedLocalStorage } from '$lib/utils/persisted';
export type MemoriesPreferences = {
showUpcoming: boolean;
onlyFavorites: boolean;
};
class UserPreferencesManager {
#showDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
#showAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
#defaultMemories: MemoriesPreferences = { showUpcoming: false, onlyFavorites: false };
#memories = new PersistedLocalStorage<MemoriesPreferences>(
'memories-settings',
{ ...this.#defaultMemories },
{
upgrade: 'merge',
},
);
get showDetailPanel() {
return this.#showDetailPanel.current;
}
set showDetailPanel(value: boolean) {
this.#showDetailPanel.current = value;
}
get showAssetPath() {
return this.#showAssetPath.current;
}
set showAssetPath(value: boolean) {
this.#showAssetPath.current = value;
}
get showAssetOwners() {
return this.#showAssetOwners.current;
}
set showAssetOwners(value: boolean) {
this.#showAssetOwners.current = value;
}
get memories() {
return this.#memories.current;
}
set memories(value: MemoriesPreferences) {
this.#memories.current = value;
}
hasMemoryPreferences() {
return !isEqual(this.memories, this.#defaultMemories);
}
}
export const userPreferencesManager = new UserPreferencesManager();
@@ -0,0 +1,27 @@
<script lang="ts">
import { userPreferencesManager, type MemoriesPreferences } from '$lib/managers/user-preferences-manager.svelte';
import { Field, FormModal, Stack, Switch } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: (settings?: MemoriesPreferences) => void;
};
let { onClose }: Props = $props();
let settings = $state({ ...userPreferencesManager.memories });
const onSubmit = () => {
onClose(settings);
};
</script>
<FormModal title={$t('filters')} {onClose} {onSubmit} size="small">
<Stack gap={4}>
<Field label={$t('memories_show_upcoming')}>
<Switch bind:checked={settings.showUpcoming} />
</Field>
<Field label={$t('only_favorites')}>
<Switch bind:checked={settings.onlyFavorites} />
</Field>
</Stack>
</FormModal>
+6 -2
View File
@@ -13,7 +13,7 @@ export const fromQueueSlug = (slug: string): QueueName | undefined => {
}
};
type QueryValue = number | string;
type QueryValue = number | string | boolean;
const asQueryString = (
params?: Record<string, QueryValue | undefined>,
options?: { skipEmptyStrings?: boolean; skipNullValues?: boolean },
@@ -77,7 +77,11 @@ export const Route = {
'/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''),
// memories
memories: (params?: { id?: string }) => '/memory' + asQueryString(params),
memories: (params?: { isSaved?: boolean }) => '/memories' + asQueryString(params),
viewMemory: ({ id, ...params }: { id: string; assetId?: string; isSaved?: boolean }) =>
`/memories/${id}` + asQueryString(params),
viewMemoryAsset: ({ id, assetId, ...params }: { id: string; assetId: string; isSaved?: boolean }) =>
`/memories/${id}/photos/${assetId}` + asQueryString({ assetId, ...params }),
// partners
viewPartner: ({ id }: { id: string }) => `/partners/${id}`,
+8 -3
View File
@@ -20,11 +20,12 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
import { DateTime } from 'luxon';
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
import { defaultLang, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { convertBCP47, langs } from '$lib/utils/i18n';
@@ -387,9 +388,13 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
export const memoryLaneTitle = derived(t, ($t) => {
return (memory: MemoryResponseDto) => {
const now = new Date();
if (memory.type === MemoryType.OnThisDay) {
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
const now = new Date();
const memoryDate = new Date(memory.memoryAt);
return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth()
? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } })
: DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
}
return $t('unknown');
+29 -6
View File
@@ -6,10 +6,10 @@
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui';
import { Icon, ImageCarousel } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -28,13 +28,22 @@
return targetField?.items || [];
};
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
let recents = $derived(
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
);
let people = $state(data.people.people);
let memories = $derived(
data.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
let people = $state(data.response.people);
let hasPeople = $derived(data.response.total > 0);
let hasPeople = $derived(data.people.total > 0);
const onPersonThumbnailReady = ({ id }: { id: string }) => {
for (const person of people) {
@@ -124,6 +133,20 @@
</div>
{/if}
{#if memories.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('memories')}</p>
<a
href={Route.memories()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
</div>
<ImageCarousel items={memories} />
</div>
{/if}
{#if recents.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
+12 -4
View File
@@ -1,16 +1,24 @@
import { getAllPeople, getExploreData } from '@immich/sdk';
import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]);
memoryManager.setFilters({ size: 12, order: MemorySearchOrder.Desc });
const [explore, people] = await Promise.all([
getExploreData(),
getAllPeople({ withHidden: false }),
memoryManager.refresh(),
]);
const $t = await getFormatter();
return {
items,
response,
explore,
people,
memories: memoryManager.memories,
meta: {
title: $t('explore'),
},
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
import MemoriesSettingsModal from '$lib/modals/MemoriesSettingsModal.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { MemoryResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, type CarouselImageItem } from '@immich/ui';
import { mdiHeart, mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
// memories are generated a few days ahead of time, and sorted by `showAt`, so the ones that have
// not been shown yet come first
const isUpcoming = (memory: MemoryResponseDto) => !!memory.showAt && new Date(memory.showAt).getTime() > Date.now();
const upcomingCount = $derived(memoryManager.memories.filter((memory) => isUpcoming(memory)).length);
const hasUpcoming = $derived(upcomingCount > 0);
const handleSettings = async () => {
const settings = await modalManager.show(MemoriesSettingsModal);
if (!settings) {
return;
}
userPreferencesManager.memories = settings;
await memoryManager.applyPreferences();
};
</script>
{#snippet card(item: CarouselImageItem & { isSaved?: boolean })}
<a
class="item-card relative me-2 inline-block aspect-3/4 size-full overflow-hidden rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
href={item.href}
>
<img class="size-full rounded-xl object-cover" src={item.src} alt={item.alt ?? item.title} draggable="false" />
{#if item.isSaved}
<div class="absolute inset-s-2 top-2 p-1">
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-white" />
</div>
{/if}
<div
class="absolute inset-s-0 top-0 size-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
></div>
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
{item.title}
</p>
</a>
{/snippet}
<UserPageLayout
title={data.meta.title}
description={memoryManager.total === undefined ? undefined : `(${memoryManager.total.toLocaleString($locale)})`}
>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<IconButton
shape="round"
color="secondary"
variant="ghost"
indicator={userPreferencesManager.hasMemoryPreferences() ? 'primary' : undefined}
icon={mdiTune}
aria-label={$t('filters')}
onclick={handleSettings}
/>
</div>
{/snippet}
{#if memoryManager.memories.length > 0}
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{#if hasUpcoming}
<div class="col-span-full outline-none">{$t('memories_upcoming')}</div>
{/if}
{#each memoryManager.memories as memory, index (memory.id)}
{#if hasUpcoming && index === upcomingCount}
<div class="col-span-full outline-none">{$t('memories_current')}</div>
{/if}
{@render card({
title: $memoryLaneTitle(memory),
href: Route.viewMemory({
id: memory.id,
assetId: memory.assets[0].id,
isSaved: userPreferencesManager.memories.onlyFavorites || undefined,
}),
src: getAssetMediaUrl({ id: memory.assets[0].id }),
alt: $getAltText(toTimelineAsset(memory.assets[0])),
isSaved: memory.isSaved,
id: memory.id,
})}
{/each}
</div>
{:else if memoryManager.loading}
<div class="flex items-center justify-center py-16">
<LoadingSpinner size="giant" />
</div>
{/if}
</UserPageLayout>
+18
View File
@@ -0,0 +1,18 @@
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
const user = await authenticate(url);
const $t = await getFormatter();
await memoryManager.applyPreferences();
return {
user,
meta: {
title: $t('memories'),
},
};
}) satisfies PageLoad;
@@ -0,0 +1,51 @@
import { redirect } from '@sveltejs/kit';
import { QueryParameter, UUID_REGEX } from '$lib/constants';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { Route } from '$lib/route';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url, params }) => {
await authenticate(url);
const $t = await getFormatter();
if (!UUID_REGEX.test(params.id)) {
redirect(307, Route.memories());
}
await memoryManager.loading;
const memory = await memoryManager.loadMemory(params.id);
if (!memory) {
redirect(307, memoryManager.memoriesHref);
}
if (params.assetId) {
const targetMemory = memoryManager.getMemoryWithAsset(params.assetId, memory.id);
if (targetMemory && targetMemory.id !== memory.id) {
redirect(307, Route.viewMemoryAsset({ id: targetMemory.id, assetId: params.assetId }));
}
}
const assetIds = new Set(memory.assets.map((asset) => asset.id));
const assetId =
[params.assetId, url.searchParams.get(QueryParameter.ASSET_ID)].find((id) => id && assetIds.has(id)) ??
memory.assets[0]?.id;
if (!assetId) {
redirect(307, memoryManager.memoriesHref);
}
if (url.searchParams.get(QueryParameter.ASSET_ID) !== assetId) {
const target = new URL(url);
target.searchParams.set(QueryParameter.ASSET_ID, assetId);
redirect(307, target.pathname + target.search);
}
return {
meta: {
title: $t('memory'),
},
};
}) satisfies PageLoad;
@@ -1,6 +1,5 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { shortcuts } from '$lib/actions/shortcut';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
@@ -15,11 +14,10 @@
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
@@ -27,7 +25,7 @@
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { ActionButton, IconButton, Text, toastManager } from '@immich/ui';
import { ActionButton, IconButton, Text } from '@immich/ui';
import {
mdiCardsOutline,
mdiChevronDown,
@@ -46,9 +44,9 @@
mdiVolumeHigh,
mdiVolumeOff,
} from '@mdi/js';
import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import 'media-chrome/media-mute-button';
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion';
@@ -61,19 +59,15 @@
let galleryFirstLoad = $state(true);
let playerInitialized = $state(false);
let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined);
const current = $derived(memoryManager.current);
const currentAssetId = $derived(current?.asset.id);
// where the asset sits in its memory, for the progress bar and counter
const assetIndex = $derived(current ? current.memory.assets.findIndex(({ id }) => id === currentAssetId) : -1);
const currentMemoryAssetFull = $derived.by(async () =>
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
let viewerAssets = $derived([
...(current?.previousMemory?.assets ?? []),
...(current?.memory.assets ?? []),
...(current?.nextMemory?.assets ?? []),
]);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
const viewport: Viewport = $state({ width: 0, height: 0 });
@@ -81,18 +75,12 @@
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
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 (assetViewerManager.isViewing) {
return asset;
}
if (!asset) {
const handleNavigate = async (href: string | undefined, options?: { replaceState?: boolean }) => {
if (assetViewerManager.isViewing || !href) {
return;
}
await goto(asHref(asset));
await goto(href, { noScroll: true, keepFocus: true, ...options });
};
const setProgressDuration = (asset: TimelineAsset) => {
@@ -102,11 +90,7 @@
});
};
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(Route.photos());
const handleEscape = async () => goto(memoryManager.memoriesHref);
const handleSelectAll = () =>
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -156,60 +140,28 @@
}
if (progress === 1 && !paused) {
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
if (current?.nextHref) {
await handleNavigate(current.nextHref, { replaceState: true });
} else {
await handleAction('handleProgressLast', 'pause');
}
}
};
const toProgressPercentage = (index: number) => {
if (!progressBarController || current?.assetIndex === undefined) {
if (!progressBarController || assetIndex === -1) {
return 0;
}
if (index < current?.assetIndex) {
if (index < assetIndex) {
return 100;
}
if (index > current?.assetIndex) {
if (index > assetIndex) {
return 0;
}
return progressBarController.current * 100;
};
const handleDeleteOrArchiveAssets = (ids: string[]) => {
if (!current) {
return;
}
memoryManager.hideAssetsFromMemory(ids);
init(page);
};
const handleDeleteMemoryAsset = async () => {
if (!current) {
return;
}
await memoryManager.deleteAssetFromMemory(current.asset.id);
init(page);
};
const handleDeleteMemory = async () => {
if (!current) {
return;
}
await memoryManager.deleteMemory(current.memory.id);
toastManager.primary($t('removed_memory'));
init(page);
};
const handleSaveMemory = async () => {
if (!current) {
return;
}
const newSavedState = !current.memory.isSaved;
await memoryManager.updateMemorySaved(current.memory.id, newSavedState);
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
init(page);
};
const handleHideAssets = (ids: string[]) => handlePromiseError(memoryManager.hideAssets(ids));
const handleGalleryScrollsIntoView = () => {
galleryInView = true;
@@ -242,22 +194,13 @@
return () => observer.disconnect();
};
const loadFromParams = (page: Page | NavigationTarget | null) => {
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
return memoryManager.getMemoryAsset(assetId);
};
const init = (target: Page | NavigationTarget | null) => {
if (memoryManager.memories.length === 0) {
return handlePromiseError(goto(Route.photos()));
}
current = loadFromParams(target);
const initCurrent = () => {
// Adjust the progress bar duration to the video length
if (current) {
setProgressDuration(current.asset);
}
playerInitialized = false;
initPlayer();
};
const resetAndPlay = () => {
@@ -280,25 +223,18 @@
playerInitialized = true;
};
afterNavigate(({ from, to }) => {
memoryManager
.ready()
.then(() => {
let target;
if (to?.params?.assetId) {
target = to;
} else if (from?.params?.assetId) {
target = from;
} else {
target = page;
}
$effect(() => {
// restart playback whenever the current asset changes
void currentAssetId;
untrack(initCurrent);
});
init(target);
initPlayer();
})
.catch((error) => {
console.error(`Error loading memories: ${error}`);
});
$effect(() => {
if (current || memoryManager.loading !== undefined) {
return;
}
handlePromiseError(goto(memoryManager.memoriesHref, { replaceState: true, noScroll: true }));
});
$effect(() => {
@@ -318,10 +254,10 @@
use:shortcuts={assetViewerManager.isViewing
? []
: [
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
{ shortcut: { key: 'd' }, onShortcut: () => handleNextAsset() },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => handlePreviousAsset() },
{ shortcut: { key: 'a' }, onShortcut: () => handlePreviousAsset() },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNavigate(current?.nextHref) },
{ shortcut: { key: 'd' }, onShortcut: () => handleNavigate(current?.nextHref) },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => handleNavigate(current?.previousHref) },
{ shortcut: { key: 'a' }, onShortcut: () => handleNavigate(current?.previousHref) },
{ shortcut: { key: 'Escape' }, onShortcut: () => handleEscape() },
]}
/>
@@ -349,15 +285,11 @@
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
unarchive={assetMultiSelectManager.isAllArchived}
onArchive={handleDeleteOrArchiveAssets}
/>
<ArchiveAction menuItem unarchive={assetMultiSelectManager.isAllArchived} onArchive={handleHideAssets} />
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem onAssetDelete={handleDeleteOrArchiveAssets} />
<DeleteAssets menuItem onAssetDelete={handleHideAssets} />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
@@ -365,6 +297,7 @@
<section
id="memory-viewer"
data-sveltekit-noscroll
class="dark w-full bg-immich-dark-gray text-white"
bind:this={memoryWrapper}
bind:clientHeight={viewport.height}
@@ -381,7 +314,7 @@
icon={mdiClose}
aria-label={$t('close')}
size="large"
onclick={() => goto(Route.photos())}
onclick={() => goto(memoryManager.memoriesHref)}
/>
<p class="text-lg">
{$memoryLaneTitle(current.memory)}
@@ -400,7 +333,7 @@
/>
{#each current.memory.assets as asset, index (asset.id)}
<a class="relative grow py-2" href={asHref(asset)} aria-label={$t('view')}>
<a class="relative grow py-2" href={current.getAssetHref(asset.id)} aria-label={$t('view')}>
<span class="absolute inset-s-0 h-0.5 w-full bg-gray-500"></span>
<span class="absolute inset-s-0 h-0.5 bg-white" style:width={`${toProgressPercentage(index)}%`}></span>
</a>
@@ -409,7 +342,7 @@
<Text size="small">
{$t('x_of_total', {
values: {
x: (current.assetIndex + 1).toLocaleString($locale),
x: (assetIndex + 1).toLocaleString($locale),
total: current.memory.assets.length.toLocaleString($locale),
},
})}
@@ -477,36 +410,21 @@
>
<!-- 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 size-full rounded-2xl"
disabled={!current.previousMemory}
onclick={handlePreviousMemory}
>
{#if current.previousMemory && current.previousMemory.assets.length > 0}
{#if current.previousMemory}
<a class="relative block size-full rounded-2xl" href={current.previousMemory.href}>
<img
class="size-full rounded-2xl object-cover"
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
src={getAssetMediaUrl({ id: current.previousMemory.assetId, size: AssetMediaSize.Preview })}
alt={$t('previous_memory')}
draggable="false"
/>
{:else}
<enhanced:img
class="size-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 inset-e-4 bottom-4 text-start text-white">
<p class="text-xs font-semibold text-gray-200 uppercase">{$t('previous')}</p>
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
<p class="text-xl">{current.previousMemory.title}</p>
</div>
{/if}
</button>
</a>
{/if}
</div>
<!-- CURRENT MEMORY -->
@@ -529,12 +447,12 @@
>
<div class="flex items-center">
<IconButton
icon={isSaved ? mdiHeart : mdiHeartOutline}
icon={current.memory.isSaved ? mdiHeart : mdiHeartOutline}
shape="round"
variant="ghost"
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory()}
aria-label={current.memory.isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => memoryManager.toggleCurrentMemorySaved()}
/>
<!-- <IconButton
icon={mdiShareVariantOutline}
@@ -552,9 +470,13 @@
size="medium"
align="bottom-right"
>
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
<MenuOption
onClick={() => handleDeleteMemoryAsset()}
onClick={() => memoryManager.deleteCurrentMemory()}
text={$t('remove_memory')}
icon={mdiCardsOutline}
/>
<MenuOption
onClick={() => memoryManager.deleteCurrentAsset()}
text={$t('remove_photo_from_memory')}
icon={mdiImageMinusOutline}
/>
@@ -578,7 +500,7 @@
</div>
</div>
<!-- CONTROL BUTTONS -->
{#if current.previous}
{#if current.previousHref}
<div class="dark absolute inset-s-0 top-1/2 ms-4">
<IconButton
shape="round"
@@ -587,12 +509,12 @@
variant="ghost"
color="secondary"
size="giant"
onclick={handlePreviousAsset}
href={current.previousHref}
/>
</div>
{/if}
{#if current.next}
{#if current.nextHref}
<div class="dark absolute inset-e-0 top-1/2 me-4">
<IconButton
shape="round"
@@ -601,7 +523,7 @@
variant="ghost"
color="secondary"
size="giant"
onclick={handleNextAsset}
href={current.nextHref}
/>
</div>
{/if}
@@ -624,36 +546,21 @@
<!-- 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 size-full rounded-2xl"
onclick={handleNextMemory}
disabled={!current.nextMemory}
>
{#if current.nextMemory && current.nextMemory.assets.length > 0}
{#if current.nextMemory}
<a class="relative block size-full rounded-2xl" href={current.nextMemory.href}>
<img
class="size-full rounded-2xl object-cover"
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
src={getAssetMediaUrl({ id: current.nextMemory.assetId, size: AssetMediaSize.Preview })}
alt={$t('next_memory')}
draggable="false"
/>
{:else}
<enhanced:img
class="size-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 inset-s-4 bottom-4 text-start text-white">
<p class="text-xs font-semibold text-gray-200 uppercase">{$t('up_next')}</p>
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
<p class="text-xl">{current.nextMemory.title}</p>
</div>
{/if}
</button>
</a>
{/if}
</div>
</div>
</section>
@@ -680,7 +587,7 @@
<div id="gallery-memory" {@attach galleryObserver} bind:this={memoryGallery}>
<GalleryViewer
assets={currentTimelineAssets}
{viewerAssets}
viewerAssets={current.viewerAssets}
viewport={galleryViewport}
assetInteraction={assetMultiSelectManager}
slidingWindowOffset={viewerHeight}
@@ -1,15 +1,5 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { redirect } from '@sveltejs/kit';
import { Route } from '$lib/route';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
const user = await authenticate(url);
const $t = await getFormatter();
return {
user,
meta: {
title: $t('memory'),
},
};
}) satisfies PageLoad;
export const load = (() => redirect(307, Route.memories())) satisfies PageLoad;
@@ -39,6 +39,7 @@
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -85,11 +86,13 @@
memoryManager.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
href: Route.memories({ id: memory.assets[0].id }),
href: Route.viewMemory({ id: memory.id, assetId: memory.assets[0].id }),
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
memoryManager.setFilters({ $for: DateTime.now().toISODate() });
</script>
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
@@ -18,6 +18,7 @@
// Memories
let memoriesEnabled = $state(authManager.preferences.memories?.enabled ?? true);
let memoriesDuration = $state(authManager.preferences.memories?.duration ?? 5);
let memoriesSidebar = $state(authManager.preferences.memories?.sidebarWeb ?? false);
// People
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
@@ -47,7 +48,7 @@
userPreferencesUpdateDto: {
albums: { defaultAssetOrder },
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
memories: { enabled: memoriesEnabled, duration: memoriesDuration, sidebarWeb: memoriesSidebar },
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
ratings: { enabled: ratingsEnabled },
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
@@ -107,6 +108,12 @@
<Switch bind:checked={memoriesEnabled} />
</Field>
{#if memoriesEnabled}
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
<Switch bind:checked={memoriesSidebar} />
</Field>
{/if}
<Field label={$t('duration')} description={$t('time_based_memories_duration')}>
<NumberInput bind:value={memoriesDuration} />
</Field>
@@ -24,6 +24,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
memories: {
enabled: false,
duration: 5,
sidebarWeb: false,
},
people: {
enabled: false,