Compare commits

..

2 Commits

Author SHA1 Message Date
Thomas 412633d0aa Merge branch 'main' into feat-no-thumbhash-cache 2025-09-15 15:30:09 +01:00
Thomas Way 2bba33f834 feat(web): don't animate cached thumbnails
Thumbnails for assets always are displayed with a thumbhash which fades out
over 100ms, even if the thumbnail is cached and ready immediately. This can be
a bit distracting and make Immich feel 'slow', or inefficient as it feels like
the thumbnails are always being reloaded. Skipping the thumbhash and animation
for cached thumbnails makes it feel much more responsive.
2025-08-10 20:59:02 +01:00
28 changed files with 147 additions and 150 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.90", "version": "2.2.89",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
-4
View File
@@ -1,8 +1,4 @@
[ [
{
"label": "v1.142.1",
"url": "https://v1.142.1.archive.immich.app"
},
{ {
"label": "v1.142.0", "label": "v1.142.0",
"url": "https://v1.142.0.archive.immich.app" "url": "https://v1.142.0.archive.immich.app"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.142.1", "version": "1.142.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -54,6 +54,12 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
private var foregroundFuture: ListenableFuture<Void>? = null private var foregroundFuture: ListenableFuture<Void>? = null
init {
if (!loader.initialized()) {
loader.startInitialization(ctx)
}
}
companion object { companion object {
private const val NOTIFICATION_CHANNEL_ID = "immich::background_worker::notif" private const val NOTIFICATION_CHANNEL_ID = "immich::background_worker::notif"
private const val NOTIFICATION_ID = 100 private const val NOTIFICATION_ID = 100
@@ -62,10 +68,6 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
override fun startWork(): ListenableFuture<Result> { override fun startWork(): ListenableFuture<Result> {
Log.i(TAG, "Starting background upload worker") Log.i(TAG, "Starting background upload worker")
if (!loader.initialized()) {
loader.startInitialization(ctx)
}
val notificationChannel = NotificationChannel( val notificationChannel = NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID,
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3015, "android.injected.version.code" => 3014,
"android.injected.version.name" => "1.142.1", "android.injected.version.name" => "1.142.0",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
) )
increment_version_number( increment_version_number(
version_number: "1.142.1" version_number: "1.142.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
@@ -1,20 +1,22 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:platform/platform.dart';
class AssetService { class AssetService {
final RemoteAssetRepository _remoteAssetRepository; final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final Platform _platform;
const AssetService({ const AssetService({
required RemoteAssetRepository remoteAssetRepository, required RemoteAssetRepository remoteAssetRepository,
required DriftLocalAssetRepository localAssetRepository, required DriftLocalAssetRepository localAssetRepository,
}) : _remoteAssetRepository = remoteAssetRepository, }) : _remoteAssetRepository = remoteAssetRepository,
_localAssetRepository = localAssetRepository; _localAssetRepository = localAssetRepository,
_platform = const LocalPlatform();
Future<BaseAsset?> getAsset(BaseAsset asset) { Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
@@ -69,7 +71,7 @@ class AssetService {
width = exif?.width ?? asset.width?.toDouble(); width = exif?.width ?? asset.width?.toDouble();
height = exif?.height ?? asset.height?.toDouble(); height = exif?.height ?? asset.height?.toDouble();
} else if (asset is LocalAsset) { } else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270); isFlipped = _platform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble(); width = asset.width?.toDouble();
height = asset.height?.toDouble(); height = asset.height?.toDouble();
} else { } else {
@@ -1,24 +1,29 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:platform/platform.dart';
class LocalSyncService { class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final Platform _platform;
final Logger _log = Logger("DeviceSyncService"); final Logger _log = Logger("DeviceSyncService");
LocalSyncService({required DriftLocalAlbumRepository localAlbumRepository, required NativeSyncApi nativeSyncApi}) LocalSyncService({
: _localAlbumRepository = localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
_nativeSyncApi = nativeSyncApi; required NativeSyncApi nativeSyncApi,
Platform? platform,
}) : _localAlbumRepository = localAlbumRepository,
_nativeSyncApi = nativeSyncApi,
_platform = platform ?? const LocalPlatform();
Future<void> sync({bool full = false}) async { Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
@@ -48,14 +53,14 @@ class LocalSyncService {
final dbAlbums = await _localAlbumRepository.getAll(); final dbAlbums = await _localAlbumRepository.getAll();
// On Android, we need to sync all albums since it is not possible to // On Android, we need to sync all albums since it is not possible to
// detect album deletions from the native side // detect album deletions from the native side
if (CurrentPlatform.isAndroid) { if (_platform.isAndroid) {
for (final album in dbAlbums) { for (final album in dbAlbums) {
final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id); final deviceIds = await _nativeSyncApi.getAssetIdsForAlbum(album.id);
await _localAlbumRepository.syncDeletes(album.id, deviceIds); await _localAlbumRepository.syncDeletes(album.id, deviceIds);
} }
} }
if (CurrentPlatform.isIOS) { if (_platform.isIOS) {
// On iOS, we need to full sync albums that are marked as cloud as the delta sync // On iOS, we need to full sync albums that are marked as cloud as the delta sync
// does not include changes for cloud albums. If ignoreIcloudAssets is enabled, // does not include changes for cloud albums. If ignoreIcloudAssets is enabled,
// remove the albums from the local database from the previous sync // remove the albums from the local database from the previous sync
@@ -1,9 +0,0 @@
import 'package:flutter/foundation.dart';
extension CurrentPlatform on TargetPlatform {
@pragma('vm:prefer-inline')
static bool get isIOS => defaultTargetPlatform == TargetPlatform.iOS;
@pragma('vm:prefer-inline')
static bool get isAndroid => defaultTargetPlatform == TargetPlatform.android;
}
@@ -1,20 +1,22 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
class DriftLocalAlbumRepository extends DriftDatabaseRepository { class DriftLocalAlbumRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
final Platform _platform;
const DriftLocalAlbumRepository(this._db) : super(_db); const DriftLocalAlbumRepository(this._db, {Platform? platform})
: _platform = platform ?? const LocalPlatform(),
super(_db);
Future<List<LocalAlbum>> getAll({Set<SortLocalAlbumsBy> sortBy = const {}}) { Future<List<LocalAlbum>> getAll({Set<SortLocalAlbumsBy> sortBy = const {}}) {
final assetCount = _db.localAlbumAssetEntity.assetId.count(); final assetCount = _db.localAlbumAssetEntity.assetId.count();
@@ -59,7 +61,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
// Remove all assets that are only in this particular album // Remove all assets that are only in this particular album
// We cannot remove all assets in the album because they might be in other albums in iOS // We cannot remove all assets in the album because they might be in other albums in iOS
// That is not the case on Android since asset <-> album has one:one mapping // That is not the case on Android since asset <-> album has one:one mapping
final assetsToDelete = CurrentPlatform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : await getAssetIds(albumId); final assetsToDelete = _platform.isIOS ? await _getUniqueAssetsInAlbum(albumId) : await getAssetIds(albumId);
await _deleteAssets(assetsToDelete); await _deleteAssets(assetsToDelete);
await _db.managers.localAlbumEntity await _db.managers.localAlbumEntity
@@ -142,7 +144,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
} }
}); });
if (CurrentPlatform.isAndroid) { if (_platform.isAndroid) {
// On Android, an asset can only be in one album // On Android, an asset can only be in one album
// So, get the albums that are marked for deletion // So, get the albums that are marked for deletion
// and delete all the assets that are in those albums // and delete all the assets that are in those albums
@@ -263,7 +265,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return Future.value(); return Future.value();
} }
if (CurrentPlatform.isAndroid) { if (_platform.isAndroid) {
return _deleteAssets(assetIds); return _deleteAssets(assetIds);
} }
+15 -20
View File
@@ -3,8 +3,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -36,6 +34,21 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
} }
Future<void> startBackup() async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref.read(backgroundSyncProvider).syncRemote();
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id);
}
Future<void> stopBackup() async {
await ref.read(driftBackupProvider.notifier).cancel();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectedAlbum = ref final selectedAlbum = ref
@@ -43,24 +56,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
.where((album) => album.backupSelection == BackupSelection.selected) .where((album) => album.backupSelection == BackupSelection.selected)
.toList(); .toList();
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
Future<void> startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser == null) {
return;
}
await backgroundManager.syncRemote();
await backupNotifier.getBackupStatus(currentUser.id);
await backupNotifier.startBackup(currentUser.id);
}
Future<void> stopBackup() async {
await backupNotifier.cancel();
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
@@ -6,7 +6,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -23,7 +22,6 @@ class SplashScreenPage extends StatefulHookConsumerWidget {
class SplashScreenPageState extends ConsumerState<SplashScreenPage> { class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final log = Logger("SplashScreenPage"); final log = Logger("SplashScreenPage");
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -51,7 +49,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final infoProvider = ref.read(serverInfoProvider.notifier); final infoProvider = ref.read(serverInfoProvider.notifier);
final wsProvider = ref.read(websocketProvider.notifier); final wsProvider = ref.read(websocketProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final backupProvider = ref.read(driftBackupProvider.notifier);
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then( ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
(_) async { (_) async {
@@ -60,17 +57,13 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
infoProvider.getServerInfo(); infoProvider.getServerInfo();
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
await Future.wait([backgroundManager.syncLocal(), backgroundManager.syncRemote()]); await backgroundManager.syncLocal();
await Future.wait([ await backgroundManager.syncRemote();
backgroundManager.hashAssets().then((_) { await backgroundManager.hashAssets();
_resumeBackup(backupProvider); }
}),
_resumeBackup(backupProvider),
]);
if (Store.get(StoreKey.syncAlbums, false)) { if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum(); await backgroundManager.syncLinkedAlbum();
}
} }
} catch (e) { } catch (e) {
log.severe('Failed establishing connection to the server: $e'); log.severe('Failed establishing connection to the server: $e');
@@ -113,17 +106,6 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
} }
} }
Future<void> _resumeBackup(DriftBackupNotifier notifier) async {
final isEnableBackup = Store.get(StoreKey.enableBackup, false);
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
notifier.handleBackupResume(currentUser.id);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Scaffold( return const Scaffold(
@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
@@ -31,6 +30,7 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:platform/platform.dart';
@RoutePage() @RoutePage()
class AssetViewerPage extends StatelessWidget { class AssetViewerPage extends StatelessWidget {
@@ -53,9 +53,10 @@ class AssetViewerPage extends StatelessWidget {
class AssetViewer extends ConsumerStatefulWidget { class AssetViewer extends ConsumerStatefulWidget {
final int initialIndex; final int initialIndex;
final Platform? platform;
final int? heroOffset; final int? heroOffset;
const AssetViewer({super.key, required this.initialIndex, this.heroOffset}); const AssetViewer({super.key, required this.initialIndex, this.platform, this.heroOffset});
@override @override
ConsumerState createState() => _AssetViewerState(); ConsumerState createState() => _AssetViewerState();
@@ -85,6 +86,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewControllerBase? viewController; PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription; StreamSubscription? reloadSubscription;
late Platform platform;
late final int heroOffset; late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState; late PhotoViewControllerValue initialPhotoViewState;
bool? hasDraggedDown; bool? hasDraggedDown;
@@ -112,6 +114,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.initState(); super.initState();
assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer");
pageController = PageController(initialPage: widget.initialIndex); pageController = PageController(initialPage: widget.initialIndex);
platform = widget.platform ?? const LocalPlatform();
totalAssets = ref.read(timelineServiceProvider).totalAssets; totalAssets = ref.read(timelineServiceProvider).totalAssets;
bottomSheetController = DraggableScrollableController(); bottomSheetController = DraggableScrollableController();
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
@@ -635,7 +638,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
gaplessPlayback: true, gaplessPlayback: true,
loadingBuilder: _placeholderBuilder, loadingBuilder: _placeholderBuilder,
pageController: pageController, pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS scrollPhysics: platform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS ? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android : const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets, itemCount: totalAssets,
@@ -98,16 +98,16 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ShareActionButton(source: ActionSource.timeline), const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[ if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline), const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline), const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const DeleteActionButton(source: ActionSource.timeline), const DeleteActionButton(source: ActionSource.timeline),
], ],
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline), if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
@@ -35,7 +35,7 @@ class Timeline extends StatelessWidget {
this.showStorageIndicator, this.showStorageIndicator,
this.withStack = false, this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18), this.bottomSheet = const GeneralBottomSheet(),
this.groupBy, this.groupBy,
this.withScrubber = true, this.withScrubber = true,
}); });
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart';
@@ -19,6 +18,7 @@ import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
@@ -144,42 +144,32 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final backgroundManager = _ref.read(backgroundSyncProvider); final backgroundManager = _ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
try { try {
await Future.wait([ // Run operations sequentially with state checks and error handling for each
_safeRun(backgroundManager.syncLocal(), "syncLocal"), await _safeRun(backgroundManager.syncLocal(), "syncLocal");
_safeRun(backgroundManager.syncRemote(), "syncRemote"), await _safeRun(backgroundManager.syncRemote(), "syncRemote");
]); await _safeRun(backgroundManager.hashAssets(), "hashAssets");
await Future.wait([
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
_resumeBackup();
}),
_resumeBackup(),
]);
if (isAlbumLinkedSyncEnable) { if (isAlbumLinkedSyncEnable) {
await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum"); await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
} }
// Handle backup resume only if still active
if (isEnableBackup) {
final currentUser = _ref.read(currentUserProvider);
if (currentUser != null) {
await _safeRun(
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
"handleBackupResume",
);
}
}
} catch (e, stackTrace) { } catch (e, stackTrace) {
_log.severe("Error during background sync", e, stackTrace); _log.severe("Error during background sync", e, stackTrace);
} }
} }
Future<void> _resumeBackup() async {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (isEnableBackup) {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
await _safeRun(
_ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id),
"handleBackupResume",
);
}
}
}
// Helper method to check if operations should continue // Helper method to check if operations should continue
bool _shouldContinueOperation() { bool _shouldContinueOperation() {
return [AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state) && return [AppLifeCycleEnum.resumed, AppLifeCycleEnum.active].contains(state) &&
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.142.1 - API version: 1.142.0
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -1429,7 +1429,7 @@ packages:
source: hosted source: hosted
version: "5.0.1" version: "5.0.1"
platform: platform:
dependency: transitive dependency: "direct main"
description: description:
name: platform name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+2 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.142.1+3015 version: 1.142.0+3014
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
@@ -57,6 +57,7 @@ dependencies:
photo_manager: ^3.6.4 photo_manager: ^3.6.4
photo_manager_image_provider: ^2.2.0 photo_manager_image_provider: ^2.2.0
pinput: ^5.0.1 pinput: ^5.0.1
platform: ^3.1.6
punycode: ^1.0.0 punycode: ^1.0.0
riverpod_annotation: ^2.6.1 riverpod_annotation: ^2.6.1
scrollable_positioned_list: ^0.3.8 scrollable_positioned_list: ^0.3.8
+1 -1
View File
@@ -9858,7 +9858,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.142.1", "version": "1.142.0",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.142.1", "version": "1.142.0",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.142.1 * 1.142.0
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.142.1", "version": "1.142.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.142.1", "version": "1.142.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -45,15 +45,4 @@ describe('Thumbnail component', () => {
const tabbables = getTabbable(container!); const tabbables = getTabbable(container!);
expect(tabbables.length).toBe(0); expect(tabbables.length).toBe(0);
}); });
it('shows thumbhash while image is loading', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const sut = render(Thumbnail, {
asset,
selected: true,
});
const thumbhash = sut.getByTestId('thumbhash');
expect(thumbhash).not.toBeFalsy();
});
}); });
@@ -20,6 +20,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isCached } from '$lib/utils/cache';
import { moveFocus } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
@@ -75,6 +76,12 @@
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES; } = TUNABLES;
const thumbnailURL = getAssetThumbnailUrl({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
let usingMobileDevice = $derived(mobileDevice.pointerCoarse); let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let element: HTMLElement | undefined = $state(); let element: HTMLElement | undefined = $state();
let mouseOver = $state(false); let mouseOver = $state(false);
@@ -313,7 +320,7 @@
<ImageThumbnail <ImageThumbnail
class={imageClass} class={imageClass}
{brokenAssetClass} {brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} url={thumbnailURL}
altText={$getAltText(asset)} altText={$getAltText(asset)}
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
@@ -344,17 +351,31 @@
</div> </div>
{/if} {/if}
{#if (!loaded || thumbError) && asset.thumbhash} {#if asset.thumbhash}
<canvas {#await isCached(new Request(thumbnailURL))}
use:thumbhash={{ base64ThumbHash: asset.thumbhash }} <canvas
data-testid="thumbhash" use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
class="absolute top-0 object-cover" data-testid="thumbhash"
style:width="{width}px" class="absolute top-0 object-cover"
style:height="{height}px" style:width="{width}px"
class:rounded-xl={selected} style:height="{height}px"
draggable="false" class:rounded-xl={selected}
out:fade={{ duration: THUMBHASH_FADE_DURATION }} draggable="false"
></canvas> ></canvas>
{:then cached}
{#if !cached && !loaded && !thumbError}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
draggable="false"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{/if}
{/await}
{/if} {/if}
</div> </div>
+18
View File
@@ -0,0 +1,18 @@
let cache: Cache | undefined;
const getCache = async () => {
cache ||= await openCache();
return cache;
};
const openCache = async () => {
const [key] = await caches.keys();
if (key) {
return caches.open(key);
}
};
export const isCached = async (req: Request) => {
const cache = await getCache();
return !!(await cache?.match(req));
};
+1 -1
View File
@@ -29,6 +29,6 @@ export const TUNABLES = {
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(storage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(storage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
}, },
IMAGE_THUMBNAIL: { IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100), THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 1000),
}, },
}; };