From aeaf84648210dfe16547de4467f7d05758bf2142 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 14 May 2026 04:33:57 +0530 Subject: [PATCH 01/18] chore: cleanup unused store keys (#28415) cleanup unused store keys Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/store.model.dart | 11 ----- mobile/lib/providers/auth.provider.dart | 4 +- mobile/lib/services/app_settings.service.dart | 6 --- mobile/lib/services/auth.service.dart | 1 - .../settings/notification_setting.dart | 32 ------------ .../domain/services/store_service_test.dart | 15 ++---- .../repositories/store_repository_test.dart | 49 ++++++------------- 7 files changed, 21 insertions(+), 97 deletions(-) diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e52e8a0a92..63281e49da 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -4,25 +4,15 @@ import 'package:immich_mobile/domain/models/user.model.dart'; /// Defines the data type for each value enum StoreKey { version._(0), - assetETag._(1), currentUser._(2), - deviceIdHash._(3), deviceId._(4), - backupFailedSince._(5), - backupRequireWifi._(6), backupRequireCharging._(7), backupTriggerDelay._(8), serverUrl._(10), accessToken._(11), serverEndpoint._(12), - autoBackup._(13), - backgroundBackup._(14), - sslClientCertData._(15), - sslClientPasswd._(16), - uploadErrorNotificationGracePeriod._(106), selectedAlbumSortOrder._(113), advancedTroubleshooting._(114), - selfSignedCert._(120), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), customHeaders._(127), @@ -38,7 +28,6 @@ enum StoreKey { // Read-only Mode settings readonlyModeEnabled._(138), albumGridView._(140), - loadOriginal._(101), // Experimental stuff enableBackup._(1003), diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 825d9e7bc8..4c2a110fde 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -11,12 +11,11 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; -import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/hash.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -144,7 +143,6 @@ class AuthNotifier extends StateNotifier { // Due to the flow of the code, this will always happen on first login user = serverUser; await Store.put(StoreKey.deviceId, deviceId); - await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); } } on ApiException catch (error, stackTrace) { if (error.code == 401) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 38d3e028cb..1b9a38bc19 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -2,15 +2,9 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { - uploadErrorNotificationGracePeriod( - StoreKey.uploadErrorNotificationGracePeriod, - "uploadErrorNotificationGracePeriod", - 2, - ), selectedAlbumSortOrder(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), - allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 667681e579..1b5eaab715 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -123,7 +123,6 @@ class AuthService { _authRepository.clearLocalData(), Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), - Store.delete(StoreKey.assetETag), Store.delete(StoreKey.autoEndpointSwitching), Store.delete(StoreKey.preferredWifiName), Store.delete(StoreKey.localEndpoint), diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index 18a9749a71..46120bb218 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -3,10 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -16,9 +13,6 @@ class NotificationSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final permissionService = ref.watch(notificationPermissionProvider); - - final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod); - final hasPermission = permissionService == PermissionStatus.granted; openAppNotificationSettings(BuildContext ctx) { @@ -41,8 +35,6 @@ class NotificationSetting extends HookConsumerWidget { ); } - final String formattedValue = _formatSliderValue(sliderValue.value.toDouble()); - final notificationSettings = [ if (!hasPermission) SettingsButtonListTile( @@ -57,32 +49,8 @@ class NotificationSetting extends HookConsumerWidget { } }), ), - SettingsSliderListTile( - enabled: hasPermission, - valueNotifier: sliderValue, - text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}), - maxValue: 5.0, - noDivisons: 5, - label: formattedValue, - ), ]; return SettingsSubPageScaffold(settings: notificationSettings); } } - -String _formatSliderValue(double v) { - if (v == 0.0) { - return 'setting_notifications_notify_immediately'.tr(); - } else if (v == 1.0) { - return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'}); - } else if (v == 2.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'}); - } else if (v == 3.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'}); - } else if (v == 4.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'}); - } else { - return 'setting_notifications_notify_never'.tr(); - } -} diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index 9f6a30eefe..0a55f8d5c7 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -9,9 +9,8 @@ import 'package:mocktail/mocktail.dart'; import '../../infrastructure/repository.mock.dart'; const _kAccessToken = '#ThisIsAToken'; -const _kBackgroundBackup = false; +const _kEnableBackup = false; const _kVersion = 2; -final _kBackupFailedSince = DateTime.utc(2023); void main() { late StoreService sut; @@ -24,15 +23,13 @@ void main() { // For generics, we need to provide fallback to each concrete type to avoid runtime errors registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.backupTriggerDelay); - registerFallbackValue(StoreKey.backgroundBackup); - registerFallbackValue(StoreKey.backupFailedSince); + registerFallbackValue(StoreKey.enableBackup); when(() => mockDriftStoreRepo.getAll()).thenAnswer( (_) async => [ const StoreDto(StoreKey.accessToken, _kAccessToken), - const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup), + const StoreDto(StoreKey.enableBackup, _kEnableBackup), const StoreDto(StoreKey.version, _kVersion), - StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince), ], ); when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream); @@ -49,9 +46,8 @@ void main() { test('Populates the internal cache on init', () { verify(() => mockDriftStoreRepo.getAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); - expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); + expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup); expect(sut.tryGet(StoreKey.version), _kVersion); - expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); // Other keys should be null expect(sut.tryGet(StoreKey.currentUser), isNull); }); @@ -151,9 +147,8 @@ void main() { await sut.clear(); verify(() => mockDriftStoreRepo.deleteAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), isNull); - expect(sut.tryGet(StoreKey.backgroundBackup), isNull); + expect(sut.tryGet(StoreKey.enableBackup), isNull); expect(sut.tryGet(StoreKey.version), isNull); - expect(sut.tryGet(StoreKey.backupFailedSince), isNull); }); }); } diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 806cde9b75..672776b226 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -12,9 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' import '../../fixtures/user.stub.dart'; const _kTestAccessToken = "#TestToken"; -final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45); const _kTestVersion = 10; -const _kTestBackupRequireWifi = false; +const _kTestBackupRequireCharging = false; final _kTestUser = UserStub.admin; Future _populateStore(Drift db) async { @@ -22,16 +21,8 @@ Future _populateStore(Drift db) async { batch.insert( db.storeEntity, StoreEntityCompanion( - id: Value(StoreKey.backupRequireWifi.id), - intValue: const Value(_kTestBackupRequireWifi ? 1 : 0), - stringValue: const Value(null), - ), - ); - batch.insert( - db.storeEntity, - StoreEntityCompanion( - id: Value(StoreKey.backupFailedSince.id), - intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch), + id: Value(StoreKey.backupRequireCharging.id), + intValue: const Value(_kTestBackupRequireCharging ? 1 : 0), stringValue: const Value(null), ), ); @@ -84,20 +75,12 @@ void main() { expect(accessToken, _kTestAccessToken); }); - test('converts datetime', () async { - DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); - expect(backupFailedSince, isNull); - await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed); - backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); - expect(backupFailedSince, _kTestBackupFailed); - }); - test('converts bool', () async { - bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isNull); - await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi); - backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, _kTestBackupRequireWifi); + bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isNull); + await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging); + backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, _kTestBackupRequireCharging); }); test('converts user', () async { @@ -115,11 +98,11 @@ void main() { }); test('delete()', () async { - bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isFalse); - await sut.delete(StoreKey.backupRequireWifi); - backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isNull); + bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isFalse); + await sut.delete(StoreKey.backupRequireCharging); + backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isNull); }); test('deleteAll()', () async { @@ -164,14 +147,12 @@ void main() { emitsInOrder([ [ const StoreDto(StoreKey.version, _kTestVersion), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.backupRequireWifi, _kTestBackupRequireWifi), + const StoreDto(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), const StoreDto(StoreKey.accessToken, _kTestAccessToken), ], [ const StoreDto(StoreKey.version, _kTestVersion + 10), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.backupRequireWifi, _kTestBackupRequireWifi), + const StoreDto(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), const StoreDto(StoreKey.accessToken, _kTestAccessToken), ], ]), From 3ff0d47ee37cefaaa50e4dbf5a46e5613570a71e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 14 May 2026 05:16:24 +0530 Subject: [PATCH 02/18] chore: do not cache dart_tool (#28409) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/build-mobile.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index f3f254e4be..bfbc7bd2e2 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -116,7 +116,6 @@ jobs: ~/.gradle/wrapper ~/.android/sdk mobile/android/.gradle - mobile/.dart_tool key: build-mobile-gradle-${{ runner.os }}-main - name: Setup Android SDK @@ -189,7 +188,6 @@ jobs: ~/.gradle/wrapper ~/.android/sdk mobile/android/.gradle - mobile/.dart_tool key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }} build-sign-ios: From 89b3433346d9f3797385125ee4149f2cbaeb42f5 Mon Sep 17 00:00:00 2001 From: racehd <22131782+racehd@users.noreply.github.com> Date: Wed, 13 May 2026 19:54:13 -0400 Subject: [PATCH 03/18] feat(docs): add fixed subnet guide for Synology to prevent firewall issues (#26554) * - Add Set Fixed Subnet section - Add newline after details summary to properly render summary with mdx * pnpm run format --write --------- Co-authored-by: Jason Rasmussen --- docs/docs/install/synology.md | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md index b86561dbbf..de96886caa 100644 --- a/docs/docs/install/synology.md +++ b/docs/docs/install/synology.md @@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list ## Step 4 - Configure Firewall Settings -Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS. +Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers. Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**" @@ -74,6 +74,7 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
Updating Immich using Container Manager + Check the post installation and upgrade instructions at the links above before proceeding with this section. ## Step 1. Backup @@ -110,7 +111,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack, ## Step 5. Update firewall rule -The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address. +Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address. Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. ![Container IP](../../static/img/synology-container-ip.png) @@ -123,4 +124,67 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi ![Edit IP](../../static/img/synology-fw-ipedit.png) +To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions. + +
+ +
+ Set Fixed Subnet + +Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`: + +## Step 1. Determine current subnet + +Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. +![Container IP](../../static/img/synology-container-ip.png) + +## Step 2. Add network configuration + +Add the following network configuration at the end of your `docker-compose.yml` file: + +```yaml +networks: + immich-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + gateway: 172.20.0.1 +``` + +If your docker container is running on a different subnet then update accordingly. + +## Step 3. Add network to each service + +Add the network to each service (immich-server, immich-machine-learning, redis, database): + +```yaml +services: + immich-server: + # other config options + networks: + - immich-network + + immich-machine-learning: + # other config options + networks: + - immich-network + + redis: + # other config options + networks: + - immich-network + + database: + # other config options + networks: + - immich-network +``` + +Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers. + +## Step 4. Update Firewall Rules, if necessary + +If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings). +
From 84a2b7a3c82fc39185dc56d6c25c08641e9c2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?In=C3=AAs=20Costa?= Date: Thu, 14 May 2026 08:19:00 +0100 Subject: [PATCH 04/18] fix(mobile): add restore option to trashed assets (#27442) --- .../restore_action_button.widget.dart | 55 +++++++++++++++++++ .../asset_viewer/bottom_bar.widget.dart | 22 +++++--- mobile/lib/utils/action_button.utils.dart | 17 +++++- .../action_button_utils_test.dart | 39 +++++++++++++ 4 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart new file mode 100644 index 0000000000..1713718967 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/restore_action_button.widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class RestoreActionButton extends ConsumerWidget { + final ActionSource source; + final bool iconOnly; + final bool menuItem; + + const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).restoreTrash(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (source == ActionSource.viewer) { + EventStream.shared.emit(const ViewerReloadAssetEvent()); + } + + final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()}); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.history_rounded, + label: 'restore'.t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100.0, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index ff09d15496..21401f37e5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -2,15 +2,18 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -33,19 +36,24 @@ class ViewerBottomBar extends ConsumerWidget { final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); final serverInfo = ref.watch(serverInfoProvider); + final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; final originalTheme = context.themeData; final actions = [ - const ShareActionButton(source: ActionSource.viewer), + if (isInTrash && isOwner && asset.hasRemote) + const RestoreActionButton(source: ActionSource.viewer) + else + const ShareActionButton(source: ActionSource.viewer), if (!isInLockedView) ...[ - if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - // edit sync was added in 2.6.0 - if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) - const EditImageActionButton(), - if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), - + if (!isInTrash) ...[ + if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), + // edit sync was added in 2.6.0 + if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) + const EditImageActionButton(), + if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), + ], if (isOwner) ...[ asset.isLocalOnly ? const DeleteLocalActionButton(source: ActionSource.viewer) diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index d527f3a59e..c38c536136 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -81,6 +82,7 @@ enum ActionButtonType { moveToLockFolder, removeFromLockFolder, removeFromAlbum, + restoreTrash, trash, deleteLocal, deletePermanent, @@ -112,7 +114,13 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.asset.hasRemote && // - context.isTrashEnabled, + context.isTrashEnabled && // + context.timelineOrigin != TimelineOrigin.trash, + ActionButtonType.restoreTrash => + context.isOwner && // + !context.isInLockedView && // + context.asset.hasRemote && // + context.timelineOrigin == TimelineOrigin.trash, ActionButtonType.deletePermanent => context.isOwner && // context.asset.hasRemote && // @@ -201,6 +209,11 @@ enum ActionButtonType { ), ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.restoreTrash => RestoreActionButton( + source: context.source, + iconOnly: iconOnly, + menuItem: menuItem, + ), ActionButtonType.deletePermanent => DeletePermanentActionButton( source: context.source, iconOnly: iconOnly, @@ -292,6 +305,7 @@ enum ActionButtonType { ActionButtonType.moveToLockFolder => 10, ActionButtonType.deleteLocal => 10, ActionButtonType.delete => 10, + ActionButtonType.restoreTrash => 10, // 90: advancedInfo ActionButtonType.advancedInfo => 90, // 1: others @@ -309,6 +323,7 @@ class ActionButtonBuilder { ActionButtonType.delete, ActionButtonType.archive, ActionButtonType.unarchive, + ActionButtonType.restoreTrash, }; static List build(ActionButtonContext context) { diff --git a/mobile/test/utils_legacy/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart index 79f4e04b52..8bd078a433 100644 --- a/mobile/test/utils_legacy/action_button_utils_test.dart +++ b/mobile/test/utils_legacy/action_button_utils_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/utils/action_button.utils.dart'; LocalAsset createLocalAsset({ @@ -460,6 +461,44 @@ void main() { }); }); + group('restoreTrash button', () { + test('should show when owner, not locked, has remote, and is in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue); + }); + + test('should not show when not in trash timeline', () { + final remoteAsset = createRemoteAsset(); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: false, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.timeline, + timelineOrigin: TimelineOrigin.main, + ); + + expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse); + }); + }); + group('deletePermanent button', () { test('should show when owner, not locked, has remote, and trash disabled', () { final remoteAsset = createRemoteAsset(); From 37cc0288688d17125253c61f2e4a2468bb7d2aa0 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 14 May 2026 13:57:19 +0200 Subject: [PATCH 05/18] fix(mobile): use correct delete action (#26575) fix(mobile): use correct delete for trashed assets When viewing a trashed asset, the viewer bottom bar now shows the permanent delete button instead of the trash button, which had no effect on already-trashed assets. --- .../models/asset/remote_asset.model.dart | 15 +++++++- .../entities/remote_asset.entity.dart | 1 + ...delete_permanent_action_button.widget.dart | 11 +++++- .../asset_viewer/bottom_bar.widget.dart | 10 +++-- mobile/lib/utils/action_button.utils.dart | 6 +-- .../action_button_utils_test.dart | 38 +++++++++++++++++++ 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index a810877dcc..b370825fdd 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -11,6 +11,7 @@ class RemoteAsset extends BaseAsset { final String ownerId; final String? stackId; final DateTime? uploadedAt; + final DateTime? deletedAt; const RemoteAsset({ required this.id, @@ -31,6 +32,7 @@ class RemoteAsset extends BaseAsset { super.livePhotoVideoId, this.stackId, required super.isEdited, + this.deletedAt, }) : localAssetId = localId; @override @@ -48,6 +50,8 @@ class RemoteAsset extends BaseAsset { @override bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage; + bool get isTrashed => deletedAt != null; + @override String toString() { return '''Asset { @@ -86,7 +90,8 @@ class RemoteAsset extends BaseAsset { thumbHash == other.thumbHash && visibility == other.visibility && stackId == other.stackId && - uploadedAt == other.uploadedAt; + uploadedAt == other.uploadedAt && + deletedAt == other.deletedAt; } @override @@ -98,7 +103,8 @@ class RemoteAsset extends BaseAsset { thumbHash.hashCode ^ visibility.hashCode ^ stackId.hashCode ^ - uploadedAt.hashCode; + uploadedAt.hashCode ^ + deletedAt.hashCode; RemoteAsset copyWith({ String? id, @@ -119,6 +125,7 @@ class RemoteAsset extends BaseAsset { String? livePhotoVideoId, String? stackId, bool? isEdited, + DateTime? deletedAt, }) { return RemoteAsset( id: id ?? this.id, @@ -139,6 +146,7 @@ class RemoteAsset extends BaseAsset { livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, stackId: stackId ?? this.stackId, isEdited: isEdited ?? this.isEdited, + deletedAt: deletedAt ?? this.deletedAt, ); } } @@ -156,6 +164,7 @@ class RemoteAssetExif extends RemoteAsset { required super.createdAt, required super.updatedAt, super.uploadedAt, + super.deletedAt, super.width, super.height, super.durationMs, @@ -193,6 +202,7 @@ class RemoteAssetExif extends RemoteAsset { DateTime? createdAt, DateTime? updatedAt, DateTime? uploadedAt, + DateTime? deletedAt, int? width, int? height, int? durationMs, @@ -214,6 +224,7 @@ class RemoteAssetExif extends RemoteAsset { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, uploadedAt: uploadedAt ?? this.uploadedAt, + deletedAt: deletedAt ?? this.deletedAt, width: width ?? this.width, height: height ?? this.height, durationMs: durationMs ?? this.durationMs, diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 8644667168..ad1cec5641 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -74,5 +74,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { localId: localId, stackId: stackId, isEdited: isEdited, + deletedAt: deletedAt, ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index d2df013369..267a9f55e6 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -18,8 +18,15 @@ class DeletePermanentActionButton extends ConsumerWidget { final ActionSource source; final bool iconOnly; final bool menuItem; + final bool useShortLabel; - const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); + const DeletePermanentActionButton({ + super.key, + required this.source, + this.iconOnly = false, + this.menuItem = false, + this.useShortLabel = false, + }); void _onTap(BuildContext context, WidgetRef ref) async { if (!context.mounted) { @@ -64,7 +71,7 @@ class DeletePermanentActionButton extends ConsumerWidget { return BaseActionButton( maxWidth: 110.0, iconData: Icons.delete_forever, - label: "delete_permanently".t(context: context), + label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context), iconOnly: iconOnly, menuItem: menuItem, onPressed: () => _onTap(context, ref), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 21401f37e5..e317c598f5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -55,9 +56,12 @@ class ViewerBottomBar extends ConsumerWidget { if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), ], if (isOwner) ...[ - asset.isLocalOnly - ? const DeleteLocalActionButton(source: ActionSource.viewer) - : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), + if (asset.isLocalOnly) + const DeleteLocalActionButton(source: ActionSource.viewer) + else if (asset.isTrashed) + const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true) + else + const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), ], ], ]; diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index c38c536136..87588efe65 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -123,9 +123,8 @@ enum ActionButtonType { context.timelineOrigin == TimelineOrigin.trash, ActionButtonType.deletePermanent => context.isOwner && // - context.asset.hasRemote && // - !context.isTrashEnabled || - context.isInLockedView, + context.asset.hasRemote && // + (!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView), ActionButtonType.delete => context.isOwner && // !context.isInLockedView && // @@ -324,6 +323,7 @@ class ActionButtonBuilder { ActionButtonType.archive, ActionButtonType.unarchive, ActionButtonType.restoreTrash, + ActionButtonType.deletePermanent, }; static List build(ActionButtonContext context) { diff --git a/mobile/test/utils_legacy/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart index 8bd078a433..0a6020762a 100644 --- a/mobile/test/utils_legacy/action_button_utils_test.dart +++ b/mobile/test/utils_legacy/action_button_utils_test.dart @@ -38,6 +38,7 @@ RemoteAsset createRemoteAsset({ DateTime? updatedAt, DateTime? uploadedAt, bool isFavorite = false, + DateTime? deletedAt, }) { return RemoteAsset( id: 'remote-id', @@ -51,6 +52,7 @@ RemoteAsset createRemoteAsset({ uploadedAt: uploadedAt ?? DateTime.now(), isFavorite: isFavorite, isEdited: false, + deletedAt: deletedAt, ); } @@ -459,6 +461,24 @@ void main() { expect(ActionButtonType.trash.shouldShow(context), isFalse); }); + + test('should not show when asset is already trashed', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024)); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.trash.shouldShow(context), isFalse); + }); }); group('restoreTrash button', () { @@ -533,6 +553,24 @@ void main() { expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); }); + + test('should show when asset is trashed even with trash enabled', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024)); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue); + }); }); group('delete button', () { From b0c9743d9ad03c08edc76f0ec2371cdc3e9e9d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nojus=20Gudinavi=C4=8Dius?= <46261165+gnojus@users.noreply.github.com> Date: Thu, 14 May 2026 15:46:31 +0300 Subject: [PATCH 06/18] feat(server): allow subpaths for machine learning URL (#28427) This allows to use a machine learning server URL under a subpath, such as "http://example.com/ml-server/". --- server/src/repositories/machine-learning.repository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 49778b5193..6792e8ecf4 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -132,7 +132,7 @@ export class MachineLearningRepository { private async check(url: string) { let healthy = false; try { - const response = await fetch(new URL('/ping', url), { + const response = await fetch(new URL('ping', url), { signal: AbortSignal.timeout(this.config.availabilityChecks.timeout), }); if (response.ok) { @@ -170,7 +170,7 @@ export class MachineLearningRepository { ...this.config.urls.filter((url) => !this.isHealthy(url)), ]) { try { - const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); + const response = await fetch(new URL('predict', url), { method: 'POST', body: formData }); if (response.ok) { this.setHealthy(url, true); return response.json(); From 06729ee5a59920a31009d0371f3768d726aa7566 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 15 May 2026 02:51:06 +0530 Subject: [PATCH 07/18] chore: cleanup unused store keys (#28415) cleanup unused store keys Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> From 43687cd8b4bf9139c1e2a5f6ffce26a497cf9017 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 14 May 2026 17:23:50 -0500 Subject: [PATCH 08/18] fix: kebab menu icon colors and actions (#28433) --- .../action_buttons/base_action_button.widget.dart | 12 +++++++----- .../asset_viewer/viewer_kebab_menu.widget.dart | 2 +- mobile/lib/utils/action_button.utils.dart | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 1ca875e483..6599ff0ffd 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -35,10 +35,11 @@ class BaseActionButton extends ConsumerWidget { final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; - final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; final textColor = context.themeData.textTheme.labelLarge?.color; if (iconOnly) { + final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; + return IconButton( onPressed: onPressed, icon: Icon(iconData, size: iconSize, color: iconColor), @@ -46,17 +47,18 @@ class BaseActionButton extends ConsumerWidget { } if (menuItem) { - final theme = context.themeData; - final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant; + final iconColor = this.iconColor; return MenuItemButton( style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), - leadingIcon: Icon(iconData, color: effectiveIconColor), + leadingIcon: Icon(iconData, color: iconColor), onPressed: onPressed, - child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)), + child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)), ); } + final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; + return ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: MaterialButton( diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 308f6a72a3..5a79485daf 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); + final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); return MenuAnchor( consumeOutsideTap: true, diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 87588efe65..a048e245cb 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -330,7 +330,7 @@ class ActionButtonBuilder { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { final visibleButtons = defaultViewerKebabMenuOrder .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .toList(); @@ -346,7 +346,7 @@ class ActionButtonBuilder { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } - result.add(type.buildButton(context, buildContext, false, true)); + result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); lastGroup = type.kebabMenuGroup; } From e91c017dd02d9eff2ab8d005f7e6a7210dd6d1c5 Mon Sep 17 00:00:00 2001 From: Robert Deaton Date: Thu, 14 May 2026 17:59:15 -0700 Subject: [PATCH 09/18] fix(server): dedupe database backup jobs (#28341) * fix(server): dedupe database backup jobs via jobId #27268 shows backup jobs piling up in the queue across upgrades; one pending backup is always enough. * fix(tests): Avoid stale backup files from previous test runs being erroneously returned from createBackup * fix(jobs): Use bullmq's deduplication over jobId to avoid failed jobs from blocking future executions. --------- Co-authored-by: Robert Deaton --- .../maintenance/server/database-backups.e2e-spec.ts | 5 ++++- e2e/src/utils.ts | 2 ++ server/src/repositories/job.repository.ts | 11 +++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts index b69bd099ed..e3bd98db28 100644 --- a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts @@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/admin/database-backups', () => { let cookie: string | undefined; @@ -13,6 +13,9 @@ describe('/admin/database-backups', () => { admin = await utils.adminSetup({ onboarding: false, }); + }); + + beforeEach(async () => { await utils.resetBackups(admin.accessToken); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 74c2832c3e..7e51b40f63 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -568,6 +568,8 @@ export const utils = { name: ManualJobName.BackupDatabase, }); + await utils.waitForQueueFinish(accessToken, 'backupDatabase'); + return utils.poll( () => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`), ({ status, body }) => status === 200 && body.backups.length === 1, diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index a94e5aa9f6..5bb5276db7 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -171,8 +171,8 @@ export class JobRepository { options: this.getJobOptions(item) || undefined, } as JobItem & { data: any; options: JobsOptions | undefined }; - if (job.options?.jobId) { - // need to use add() instead of addBulk() for jobId deduplication + if (job.options?.jobId || job.options?.deduplication) { + // need to use add() instead of addBulk() for jobId/deduplication to take effect promises.push(this.getQueue(queueName).add(item.name, item.data, job.options)); } else { itemsByQueue[queueName] = itemsByQueue[queueName] || []; @@ -230,10 +230,13 @@ export class JobRepository { return { priority: 1 }; } case JobName.FacialRecognitionQueueAll: { - return { jobId: JobName.FacialRecognitionQueueAll }; + return { deduplication: { id: JobName.FacialRecognitionQueueAll } }; } case JobName.VersionCheck: { - return { jobId: JobName.VersionCheck }; + return { deduplication: { id: JobName.VersionCheck } }; + } + case JobName.DatabaseBackup: { + return { deduplication: { id: JobName.DatabaseBackup } }; } default: { return null; From 21d6755f3975ac319ef3ae2578bde5082fd6857f Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 14 May 2026 20:22:23 -0700 Subject: [PATCH 10/18] fix(web): recently added ux (#28435) --- web/src/routes/(user)/explore/+page.svelte | 37 +++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index df4c9a9eb5..fa688d7e8b 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -4,15 +4,18 @@ import OnEvents from '$lib/components/OnEvents.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte'; 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 { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; + import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { Icon } from '@immich/ui'; import { mdiHeart } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { getAltText } from '$lib/utils/thumbnail-util'; + import Portal from '$lib/elements/Portal.svelte'; interface Props { data: PageData; @@ -40,6 +43,15 @@ } } }; + + const onViewAsset = async (id: string) => { + const asset = await getAssetInfo({ ...authManager.params, id }); + assetViewerManager.setAsset(asset); + }; + + const assetCursor = $derived({ + current: assetViewerManager.asset!, + }); @@ -122,15 +134,20 @@ draggable="false">{$t('view_all')} -
+
{#each recents as item (item.data.id)} - + {/each}
@@ -140,3 +157,15 @@ {/if} + +{#if assetViewerManager.isViewing} + {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} + + assetViewerManager.showAssetViewer(false)} + /> + + {/await} +{/if} From 01d6a244d86be4ccb5835d2edce66de4e10684b5 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 00:48:23 +0600 Subject: [PATCH 11/18] fix(mobile): cronet buffer overflow on compressed thumbnails (#28439) CronetImageFetcher sized the response buffer from Content-Length, which is the compressed wire size. Cronet auto-decompresses gzip/br responses and writes decompressed bytes into the buffer, exceeding it and throwing IllegalArgumentException: ByteBuffer is already full on the next read. Use the growable path; Content-Length becomes an initial alloc hint only, capped at 128 MB so an untrusted server can't overflow Int.MAX_VALUE or OOM us upfront. Reuse Cronet's ByteBuffer between reads when no grow is needed. --- .../immich/images/RemoteImagesImpl.kt | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 9255eff44b..d1651b7960 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -23,6 +23,8 @@ import java.io.IOException import java.nio.ByteBuffer import java.util.concurrent.ConcurrentHashMap +private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024 + private class RemoteRequest(val cancellationSignal: CancellationSignal) class RemoteImagesImpl(context: Context) : RemoteImageApi { @@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher { private val onComplete: () -> Unit, ) : UrlRequest.Callback() { private var buffer: NativeByteBuffer? = null - private var wrapped: ByteBuffer? = null private var error: Exception? = null override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) { @@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher { } try { + // Content-Length is a size hint only. With Content-Encoding (gzip/br/...), + // Cronet auto-decompresses and writes decompressed bytes to our buffer, which + // may exceed the wire/compressed Content-Length. Always use the growable + // buffer path so we can't overflow. val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0 - if (contentLength > 0) { - buffer = NativeByteBuffer(contentLength + 1) - wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1) - request.read(wrapped) - } else { - buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE) - request.read(buffer!!.wrapRemaining()) - } + // Cap the up-front alloc: Content-Length is untrusted and can be huge or near + // Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over. + val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE + buffer = NativeByteBuffer(initialSize) + request.read(buffer!!.wrapRemaining()) } catch (e: Exception) { error = e return request.cancel() @@ -263,14 +265,18 @@ private class CronetImageFetcher : ImageFetcher { byteBuffer: ByteBuffer ) { try { - val buf = if (wrapped == null) { - buffer!!.run { - advance(byteBuffer.position()) - ensureHeadroom() - wrapRemaining() - } + val b = buffer!! + b.advance(byteBuffer.position()) + // Reuse the caller-supplied ByteBuffer as long as we don't need to grow. + // It already points at our native memory with position advanced past the + // written bytes — Cronet can keep writing into the remaining tail. + // Only when the buffer is full do we grow (which may realloc + move the + // native pointer) and need a fresh wrap. + val buf = if (b.offset == b.capacity) { + b.ensureHeadroom() + b.wrapRemaining() } else { - wrapped + byteBuffer } request.read(buf) } catch (e: Exception) { @@ -280,7 +286,6 @@ private class CronetImageFetcher : ImageFetcher { } override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { - wrapped?.let { buffer!!.advance(it.position()) } onSuccess(buffer!!) onComplete() } From 17779c1e7412f400ffa64f68e405724b06e3fd09 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 03:25:31 +0600 Subject: [PATCH 12/18] fix(mobile): cronet thumbnail buffer overflow regression from #28439 (#28450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hybrid added in onReadCompleted reuses Cronet's ByteBuffer between reads to save a JNI wrap call when no grow is needed. That reuse breaks advance() — Cronet's position() is cumulative across reads, so the same K bytes get counted on every subsequent iteration. b.offset overshoots b.capacity, the reuse branch keeps firing on a now-empty buffer, and request.read() throws the original IllegalArgumentException again. Always pass a fresh wrap from wrapRemaining() so byteBuffer.position() reflects only this iteration's bytes. Same shape as the original PR had before the broken optimization was layered on top. --- .../immich/images/RemoteImagesImpl.kt | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index d1651b7960..f7ebc349f6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -265,18 +265,14 @@ private class CronetImageFetcher : ImageFetcher { byteBuffer: ByteBuffer ) { try { - val b = buffer!! - b.advance(byteBuffer.position()) - // Reuse the caller-supplied ByteBuffer as long as we don't need to grow. - // It already points at our native memory with position advanced past the - // written bytes — Cronet can keep writing into the remaining tail. - // Only when the buffer is full do we grow (which may realloc + move the - // native pointer) and need a fresh wrap. - val buf = if (b.offset == b.capacity) { - b.ensureHeadroom() - b.wrapRemaining() - } else { - byteBuffer + // Always pass a fresh wrap so byteBuffer.position() represents only the + // bytes Cronet wrote in this iteration. Reusing the caller-supplied + // ByteBuffer breaks advance(): Cronet's position keeps accumulating + // across reads, which would double-count previous iterations' bytes. + val buf = buffer!!.run { + advance(byteBuffer.position()) + ensureHeadroom() + wrapRemaining() } request.read(buf) } catch (e: Exception) { From df016f92282eba58e48b2d4bf9c6f1c6282aaeb9 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 03:41:04 +0600 Subject: [PATCH 13/18] fix(mobile): mounted check in ThumbnailTile hero flight listener (#28451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user pops back from the asset viewer mid-flight, the hero animation can fire its status listener after _ThumbnailTileState has been disposed. setState then throws a null check on State._element. Guard the listener with `if (!mounted) return;` — same pattern as #28300 in the album sync action. --- .../lib/presentation/widgets/images/thumbnail_tile.widget.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 8720cc4253..286e874e1b 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -120,6 +120,9 @@ class _ThumbnailTileState extends ConsumerState { }, flightShuttleBuilder: (context, animation, direction, from, to) { void animationStatusListener(AnimationStatus status) { + if (!mounted) { + return; + } final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse; if (_hideIndicators != heroInFlight) { setState(() => _hideIndicators = heroInFlight); From 0ef04d9baa6917a56dfedff7e45e452226fb0b63 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Fri, 15 May 2026 16:12:04 -0700 Subject: [PATCH 14/18] feat(mobile): slideshow view (#28421) * feat(mobile): slideshow view * move slideshow settings to metadata store * remove watch in initState * wrap progress bar in safearea * show slideshow button on remote albums * fix crash on unknown assets * always show slideshow option * add zoom effect * add padding to slideshow settings * chore: styling tweak --------- Co-authored-by: Alex --- mobile/lib/constants/enums.dart | 4 + .../lib/domain/models/config/app_config.dart | 12 +- .../models/config/slideshow_config.dart | 48 +++ mobile/lib/domain/models/metadata_key.dart | 14 +- mobile/lib/domain/models/store.model.dart | 3 + .../repositories/metadata.repository.dart | 7 + .../pages/drift_slideshow.page.dart | 350 ++++++++++++++++++ .../base_action_button.widget.dart | 9 +- .../slideshow_action_button.widget.dart | 34 ++ mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 47 +++ mobile/lib/utils/action_button.utils.dart | 4 + .../common/remote_album_sliver_app_bar.dart | 5 + .../asset_viewer_settings.dart | 2 + .../slideshow_settings.dart | 123 ++++++ 15 files changed, 657 insertions(+), 7 deletions(-) create mode 100644 mobile/lib/domain/models/config/slideshow_config.dart create mode 100644 mobile/lib/presentation/pages/drift_slideshow.page.dart create mode 100644 mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart create mode 100644 mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 877145c322..473bd52b03 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -18,3 +18,7 @@ enum CleanupStep { selectDate, scan, delete } enum AssetKeepType { none, photosOnly, videosOnly } enum AssetDateAggregation { start, end } + +enum SlideshowLook { contain, cover, blurredBackground } + +enum SlideshowDirection { forward, backward, shuffle } diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index beca1c21e7..e639b7b7e4 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart'; import 'package:immich_mobile/domain/models/config/viewer_config.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; class AppConfig { final ThemeConfig theme; @@ -12,6 +13,7 @@ class AppConfig { final TimelineConfig timeline; final ImageConfig image; final ViewerConfig viewer; + final SlideshowConfig slideshow; const AppConfig({ this.theme = const .new(), @@ -20,6 +22,7 @@ class AppConfig { this.timeline = const .new(), this.image = const .new(), this.viewer = const .new(), + this.slideshow = const .new(), }); AppConfig copyWith({ @@ -29,6 +32,7 @@ class AppConfig { TimelineConfig? timeline, ImageConfig? image, ViewerConfig? viewer, + SlideshowConfig? slideshow, }) => .new( theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, @@ -36,6 +40,7 @@ class AppConfig { timeline: timeline ?? this.timeline, image: image ?? this.image, viewer: viewer ?? this.viewer, + slideshow: slideshow ?? this.slideshow, ); @override @@ -47,12 +52,13 @@ class AppConfig { other.map == map && other.timeline == timeline && other.image == image && - other.viewer == viewer); + other.viewer == viewer && + other.slideshow == slideshow); @override - int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer); + int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow); @override String toString() => - 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)'; + 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow)'; } diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart new file mode 100644 index 0000000000..74c0ac9d38 --- /dev/null +++ b/mobile/lib/domain/models/config/slideshow_config.dart @@ -0,0 +1,48 @@ +import 'package:immich_mobile/constants/enums.dart'; + +class SlideshowConfig { + final bool transition; + final bool repeat; + final int duration; + final SlideshowLook look; + final SlideshowDirection direction; + + const SlideshowConfig({ + this.transition = true, + this.repeat = true, + this.duration = 5, + this.look = SlideshowLook.contain, + this.direction = SlideshowDirection.forward, + }); + + SlideshowConfig copyWith({ + bool? transition, + bool? repeat, + int? duration, + SlideshowLook? look, + SlideshowDirection? direction, + }) => SlideshowConfig( + transition: transition ?? this.transition, + repeat: repeat ?? this.repeat, + duration: duration ?? this.duration, + look: look ?? this.look, + direction: direction ?? this.direction, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SlideshowConfig && + other.transition == transition && + other.repeat == repeat && + other.duration == duration && + other.look == look && + other.direction == direction); + + @override + int get hashCode => Object.hash(transition, repeat, duration, look, direction); + + @override + String toString() => + 'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 61a3cebc8a..04ef506f89 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -64,7 +64,19 @@ enum MetadataKey { ), cleanupKeepAlbumIds>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)), cleanupCutoffDaysAgo(.appConfig, 'cleanup.cutoffDaysAgo', -1), - cleanupDefaultsInitialized(.appConfig, 'cleanup.defaultsInitialized', false); + cleanupDefaultsInitialized(.appConfig, 'cleanup.defaultsInitialized', false), + + // Slideshow + slideshowTransition(.appConfig, 'slideshow.transition', true), + slideshowRepeat(.appConfig, 'slideshow.repeat', true), + slideshowDuration(.appConfig, 'slideshow.duration', 5), + slideshowLook(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)), + slideshowDirection( + .appConfig, + 'slideshow.direction', + SlideshowDirection.forward, + _EnumCodec(SlideshowDirection.values), + ); final MetadataDomain domain; final String name; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 63281e49da..f2a3fcc2c0 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -29,6 +29,9 @@ enum StoreKey { readonlyModeEnabled._(138), albumGridView._(140), + // Image viewer navigation settings + tapToNavigate._(141), + // Experimental stuff enableBackup._(1003), useWifiForUploadVideos._(1004), diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index d8c8f55898..b5801b9b9c 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -139,6 +139,13 @@ extension on MetadataDomain { autoPlayVideo: repo._read(.viewerAutoPlayVideo), tapToNavigate: repo._read(.viewerTapToNavigate), ), + slideshow: .new( + transition: repo._read(.slideshowTransition), + repeat: repo._read(.slideshowRepeat), + duration: repo._read(.slideshowDuration), + look: repo._read(.slideshowLook), + direction: repo._read(.slideshowDirection), + ), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart new file mode 100644 index 0000000000..693a4d201f --- /dev/null +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -0,0 +1,350 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/settings.page.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +@RoutePage() +class DriftSlideshowPage extends ConsumerStatefulWidget { + final TimelineService timeline; + + const DriftSlideshowPage({super.key, required this.timeline}); + + @override + ConsumerState createState() => _DriftSlideshowPageState(); +} + +class _DriftSlideshowPageState extends ConsumerState { + late final SlideshowConfig _config; + late final PageController _pageController; + late final Stopwatch _stopwatch; + late Timer _timer; + late int _index; + late int _nextIndex; + bool _paused = false; + bool _showAppBar = false; + + @override + initState() { + super.initState(); + _config = ref.read(appConfigProvider.select((s) => s.slideshow)); + final asset = ref.read(assetViewerProvider).currentAsset; + _index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0; + _pageController = PageController(initialPage: _index); + _stopwatch = Stopwatch(); + _createTimer(); + _updateNextIndex(); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + + @override + dispose() { + _timer.cancel(); + _stopwatch.stop(); + _pageController.dispose(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + void _play() { + final asset = widget.timeline.getAssetSafe(_index)!; + + if (asset.isImage) { + _createTimer(); + } else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).play(); + } else { + _nextPage(); + } + + _updateNextIndex(); + + setState(() { + _paused = false; + }); + } + + void _pause() { + _timer.cancel(); + _stopwatch.stop(); + + final asset = widget.timeline.getAssetSafe(_index)!; + + if (!asset.isImage) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).pause(); + } + + setState(() { + _paused = true; + }); + } + + void _updateNextIndex() { + _nextIndex = switch (_config.direction) { + SlideshowDirection.forward => _index + 1, + SlideshowDirection.backward => _index - 1, + SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!, + }; + + if (!widget.timeline.hasRange(_nextIndex, 1)) { + widget.timeline.preloadAssets(_nextIndex); + } + } + + void _nextPage() async { + if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) { + if (_config.repeat) { + final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1; + await widget.timeline.preloadAssets(wrapped); + _pageController.jumpToPage(wrapped); + } + return; + } + + if (!widget.timeline.hasRange(_nextIndex, 1)) { + await widget.timeline.preloadAssets(_nextIndex); + } + + if (_config.direction == SlideshowDirection.shuffle || !_config.transition) { + _pageController.jumpToPage(_nextIndex); + } else { + unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn)); + } + } + + void _createTimer() { + _timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () { + _stopwatch.stop(); + _stopwatch.reset(); + _nextPage(); + }); + + _stopwatch.start(); + } + + void _pageChanged(int page) { + final asset = widget.timeline.getAssetSafe(page)!; + + setState(() { + _index = page; + + if (!asset.isImage) { + _paused = false; + } + }); + + _timer.cancel(); + _stopwatch.stop(); + _stopwatch.reset(); + + if (!_paused && asset.isImage) { + _createTimer(); + } + + _updateNextIndex(); + } + + void _onTapUp() async { + await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge); + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _showAppBar = !_showAppBar; + }); + }); + } + + Widget _getProgressBar(BuildContext context) { + final asset = widget.timeline.getAssetSafe(_index); + + if (asset == null) { + return Container(); + } + + if (asset.isImage) { + final elapsed = _stopwatch.elapsedMilliseconds; + final duration = _config.duration * 1000; + + return TweenAnimationBuilder( + key: Key(_index.toString()), + tween: Tween(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0), + duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), + builder: (context, value, _) => LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.zero), + minHeight: 5, + value: value, + ), + ); + } else { + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.zero), + minHeight: 5, + value: + ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds / + asset.duration.inMilliseconds, + ); + } + } + + Widget _getBlur(BuildContext context, int index) { + final asset = widget.timeline.getAssetSafe(index); + + if (asset == null) { + return Container(); + } + + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: getFullImageProvider(asset, size: Size(context.width, context.height)), + fit: BoxFit.cover, + ), + ), + child: Container(color: Colors.black.withValues(alpha: 0.2)), + ), + ); + } + + Widget _getPhotoView(BuildContext context, int index) { + final asset = widget.timeline.getAssetSafe(index); + + if (asset == null) { + return const Center(child: ImmichLoadingIndicator()); + } + + final scale = _config.look == SlideshowLook.cover + ? PhotoViewComputedScale.covered + : PhotoViewComputedScale.contained; + final isCurrent = _index == index; + final imageProvider = getFullImageProvider(asset, size: context.sizeData); + + if (asset.isImage) { + final zoomOut = index % 2 == 1; + final elapsed = _stopwatch.elapsedMilliseconds; + final duration = _config.duration * 1000; + final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble(); + + return TweenAnimationBuilder( + tween: Tween( + begin: progress, + end: _paused + ? progress + : zoomOut + ? 0.0 + : 1.0, + ), + duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), + builder: (context, value, _) => PhotoView( + imageProvider: imageProvider, + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: scale * (1.0 + value / 10.0), + controller: PhotoViewController(), + onTapUp: (_, _, _) => _onTapUp(), + ), + ); + } else { + final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status)); + final position = ref.read(videoPlayerProvider(asset.heroTag)).position; + + if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { + _nextPage(); + } else if (status == VideoPlaybackStatus.playing) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false); + } + + return PhotoView.customChild( + onTapUp: (_, _, _) => _onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: PreferredSize( + preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5), + child: IgnorePointer( + ignoring: !_showAppBar, + child: AnimatedOpacity( + opacity: _showAppBar ? 1.0 : 0.0, + duration: Durations.short2, + child: Column( + children: [ + AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("slideshow".t(context: context)), + actions: [ + IconButton( + onPressed: _paused ? _play : _pause, + icon: Icon(_paused ? Icons.play_arrow : Icons.pause), + ), + IconButton( + onPressed: () { + _pause(); + context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer)); + }, + icon: const Icon(Icons.settings), + ), + ], + ), + _getProgressBar(context), + ], + ), + ), + ), + ), + extendBody: true, + extendBodyBehindAppBar: true, + backgroundColor: Colors.black, + body: PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: _pageController, + physics: const FastClampingScrollPhysics(), + itemCount: widget.timeline.totalAssets, + onPageChanged: _pageChanged, + itemBuilder: (context, index) => Stack( + children: [ + if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), + _getPhotoView(context, index), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 6599ff0ffd..5ed61c3bbe 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -50,10 +50,13 @@ class BaseActionButton extends ConsumerWidget { final iconColor = this.iconColor; return MenuItemButton( - style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), - leadingIcon: Icon(iconData, color: iconColor), + style: MenuItemButton.styleFrom( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + leadingIcon: Icon(iconData, color: iconColor, size: 20), onPressed: onPressed, - child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)), + child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)), ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart new file mode 100644 index 0000000000..479cf2dfe9 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart @@ -0,0 +1,34 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SlideshowActionButton extends ConsumerWidget { + final bool iconOnly; + final bool menuItem; + + const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context, WidgetRef ref) { + if (!context.mounted) { + return; + } + + context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.slideshow, + label: "slideshow".t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 1cc5faa733..b39a568e26 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -60,6 +60,7 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; @@ -189,6 +190,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 72054cf194..a4b538d789 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1095,6 +1095,53 @@ class DriftSearchRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftSlideshowPage] +class DriftSlideshowRoute extends PageRouteInfo { + DriftSlideshowRoute({ + Key? key, + required TimelineService timeline, + List? children, + }) : super( + DriftSlideshowRoute.name, + args: DriftSlideshowRouteArgs(key: key, timeline: timeline), + initialChildren: children, + ); + + static const String name = 'DriftSlideshowRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftSlideshowPage(key: args.key, timeline: args.timeline); + }, + ); +} + +class DriftSlideshowRouteArgs { + const DriftSlideshowRouteArgs({this.key, required this.timeline}); + + final Key? key; + + final TimelineService timeline; + + @override + String toString() { + return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! DriftSlideshowRouteArgs) return false; + return key == other.key && timeline == other.timeline; + } + + @override + int get hashCode => key.hashCode ^ timeline.hashCode; +} + /// generated route for /// [DriftTrashPage] class DriftTrashRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index a048e245cb..b9cff613fd 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -27,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_pi import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -73,6 +74,7 @@ enum ActionButtonType { similarPhotos, setProfilePicture, viewInTimeline, + slideshow, download, upload, openInBrowser, @@ -179,6 +181,7 @@ enum ActionButtonType { context.timelineOrigin != TimelineOrigin.localAlbum && context.isOwner, ActionButtonType.cast => context.isCasting || context.asset.hasRemote, + ActionButtonType.slideshow => true, }; } @@ -200,6 +203,7 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unarchive => UnArchiveActionButton( source: context.source, diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 50746f5cbd..2fc136302d 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { @@ -89,6 +90,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState context.maybePop(), ), actions: [ + IconButton( + onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))), + icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows), + ), if (currentAlbum.isActivityEnabled && currentAlbum.isShared) IconButton( icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows), diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index a2bca2745f..f3b9039b2b 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; class AssetViewerSettings extends StatelessWidget { @@ -13,6 +14,7 @@ class AssetViewerSettings extends StatelessWidget { const ImageViewerQualitySetting(), const ImageViewerTapToNavigateSetting(), const VideoViewerSettings(), + const SlideshowSettings(), ]; return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart new file mode 100644 index 0000000000..4e566e6065 --- /dev/null +++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; +import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +class SlideshowSettings extends HookConsumerWidget { + const SlideshowSettings({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final slideshow = ref.read(appConfigProvider).slideshow; + final useTransition = useState(slideshow.transition); + final useRepeat = useState(slideshow.repeat); + final useDuration = useState(slideshow.duration); + final useLook = useState(slideshow.look); + final useDirection = useState(slideshow.direction); + + useValueChanged(useTransition.value, (_, __) { + ref.read(metadataProvider).write(.slideshowTransition, useTransition.value); + }); + useValueChanged(useRepeat.value, (_, __) { + ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value); + }); + useValueChanged(useDuration.value, (_, __) { + ref.read(metadataProvider).write(.slideshowDuration, useDuration.value); + }); + useValueChanged(useLook.value, (_, __) { + ref.read(metadataProvider).write(.slideshowLook, useLook.value); + }); + useValueChanged(useDirection.value, (_, __) { + ref.read(metadataProvider).write(.slideshowDirection, useDirection.value); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingGroupTitle( + title: 'slideshow'.t(context: context), + icon: Icons.slideshow_outlined, + ), + SettingsSwitchListTile( + valueNotifier: useTransition, + title: "show_slideshow_transition".t(context: context), + enabled: useDirection.value != SlideshowDirection.shuffle, + ), + SettingsSwitchListTile( + valueNotifier: useRepeat, + title: "slideshow_repeat".t(context: context), + subtitle: "slideshow_repeat_description".t(context: context), + ), + SettingsSliderListTile( + valueNotifier: useDuration, + text: "duration".t(context: context), + minValue: 5, + noDivisons: 5, + maxValue: 30, + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: SettingsSubTitle(title: 'look'.t(context: context)), + ), + SettingsRadioListTile( + groups: [ + SettingsRadioGroup( + title: 'contain'.t(context: context), + value: SlideshowLook.contain, + ), + SettingsRadioGroup( + title: 'cover'.t(context: context), + value: SlideshowLook.cover, + ), + SettingsRadioGroup( + title: 'blurred_background'.t(context: context), + value: SlideshowLook.blurredBackground, + ), + ], + groupBy: useLook.value, + onRadioChanged: (value) { + if (value != null) { + useLook.value = value; + } + }, + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: SettingsSubTitle(title: 'direction'.t(context: context)), + ), + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: SettingsRadioListTile( + groups: [ + SettingsRadioGroup( + title: 'forward'.t(context: context), + value: SlideshowDirection.forward, + ), + SettingsRadioGroup( + title: 'backward'.t(context: context), + value: SlideshowDirection.backward, + ), + SettingsRadioGroup( + title: 'shuffle'.t(context: context), + value: SlideshowDirection.shuffle, + ), + ], + groupBy: useDirection.value, + onRadioChanged: (value) { + if (value != null) { + useDirection.value = value; + } + }, + ), + ), + ], + ); + } +} From 3ab3d5cf43b983c32b64a1245d653973fc345900 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 05:12:28 +0600 Subject: [PATCH 15/18] fix(mobile): don't force-unwrap nil localizedTitle in ios getAlbums (#28452) crashes on ios 26 when a PHAssetCollection returns nil for localizedTitle. fall back to localIdentifier. ref #28428 --- mobile/ios/Runner/Sync/MessagesImpl.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index ec96729d8f..40b71bd6c2 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { var domainAlbum = PlatformAlbum( id: album.localIdentifier, - name: album.localizedTitle!, + name: album.localizedTitle ?? album.localIdentifier, updatedAt: nil, isCloud: isCloud, assetCount: Int64(assets.count) From 02581e81a7f01cfda6ed0a7b6f0f9ac64286eb43 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 15 May 2026 22:15:24 -0400 Subject: [PATCH 16/18] fix(web): work around Chrome HDR image seam lines during zoom (#27715) Change-Id: Ic5a5b1a476c2af93b465ef23dabc601a6a6a6964 Co-authored-by: Alex --- web/src/lib/components/AdaptiveImage.svelte | 91 ++++++++++++++++++--- web/src/lib/utils/tunables.ts | 3 + 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 39bc4516b7..4f3522887c 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -1,3 +1,54 @@ + +