diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 0ed69dea75f5..f039c81a5914 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -35,7 +35,8 @@ class Asset { isReadOnly = remote.isReadOnly, isOffline = remote.isOffline, stackParentId = remote.stackParentId, - stackCount = remote.stackCount; + stackCount = remote.stackCount, + thumbhash = _decodeThumbhash(remote.thumbhash); Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -88,6 +89,7 @@ class Asset { this.stackCount = 0, this.isReadOnly = false, this.isOffline = false, + this.thumbhash, }); @ignore @@ -116,6 +118,8 @@ class Asset { /// because Isar cannot sort lists of byte arrays String checksum; + List? thumbhash; + @Index(unique: false, replace: false, type: IndexType.hash) String? remoteId; @@ -271,6 +275,7 @@ class Asset { a.exifInfo?.latitude != exifInfo?.latitude || a.exifInfo?.longitude != exifInfo?.longitude || // no local stack count or different count from remote + a.thumbhash != thumbhash || ((stackCount == null && a.stackCount != null) || (stackCount != null && a.stackCount != null && @@ -332,6 +337,7 @@ class Asset { isReadOnly: a.isReadOnly, isOffline: a.isOffline, exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo, + thumbhash: a.thumbhash, ); } else { // add only missing values (and set isLocal to true) @@ -368,6 +374,7 @@ class Asset { ExifInfo? exifInfo, String? stackParentId, int? stackCount, + List? thumbhash, }) => Asset( id: id ?? this.id, @@ -392,6 +399,7 @@ class Asset { exifInfo: exifInfo ?? this.exifInfo, stackParentId: stackParentId ?? this.stackParentId, stackCount: stackCount ?? this.stackCount, + thumbhash: thumbhash ?? this.thumbhash, ); Future put(Isar db) async { @@ -504,3 +512,10 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } + +List? _decodeThumbhash(String? hash) { + if (hash == null) { + return null; + } + return base64.decode(base64.normalize(hash)).toList(); +} diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index d845b5353a9f..ce086d288f65 100644 --- a/mobile/lib/shared/models/asset.g.dart +++ b/mobile/lib/shared/models/asset.g.dart @@ -102,19 +102,24 @@ const AssetSchema = CollectionSchema( name: r'stackParentId', type: IsarType.string, ), - r'type': PropertySchema( + r'thumbhash': PropertySchema( id: 17, + name: r'thumbhash', + type: IsarType.byteList, + ), + r'type': PropertySchema( + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -210,6 +215,12 @@ int _assetEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.thumbhash; + if (value != null) { + bytesCount += 3 + value.length; + } + } return bytesCount; } @@ -236,9 +247,10 @@ void _assetSerialize( writer.writeString(offsets[14], object.remoteId); writer.writeLong(offsets[15], object.stackCount); writer.writeString(offsets[16], object.stackParentId); - writer.writeByte(offsets[17], object.type.index); - writer.writeDateTime(offsets[18], object.updatedAt); - writer.writeInt(offsets[19], object.width); + writer.writeByteList(offsets[17], object.thumbhash); + writer.writeByte(offsets[18], object.type.index); + writer.writeDateTime(offsets[19], object.updatedAt); + writer.writeInt(offsets[20], object.width); } Asset _assetDeserialize( @@ -266,10 +278,11 @@ Asset _assetDeserialize( remoteId: reader.readStringOrNull(offsets[14]), stackCount: reader.readLongOrNull(offsets[15]), stackParentId: reader.readStringOrNull(offsets[16]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? + thumbhash: reader.readByteList(offsets[17]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -316,11 +329,13 @@ P _assetDeserializeProp

( case 16: return (reader.readStringOrNull(offset)) as P; case 17: + return (reader.readByteList(offset)) as P; + case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -2078,6 +2093,159 @@ extension AssetQueryFilter on QueryBuilder { }); } + QueryBuilder thumbhashIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'thumbhash', + )); + }); + } + + QueryBuilder thumbhashIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'thumbhash', + )); + }); + } + + QueryBuilder thumbhashElementEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'thumbhash', + value: value, + )); + }); + } + + QueryBuilder thumbhashElementGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'thumbhash', + value: value, + )); + }); + } + + QueryBuilder thumbhashElementLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'thumbhash', + value: value, + )); + }); + } + + QueryBuilder thumbhashElementBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'thumbhash', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder thumbhashLengthEqualTo( + int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder thumbhashIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder thumbhashIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder thumbhashLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder thumbhashLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder thumbhashLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'thumbhash', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + QueryBuilder typeEqualTo( AssetType value) { return QueryBuilder.apply(this, (query) { @@ -2864,6 +3032,12 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByThumbhash() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'thumbhash'); + }); + } + QueryBuilder distinctByType() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'type'); @@ -2992,6 +3166,12 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder?, QQueryOperations> thumbhashProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'thumbhash'); + }); + } + QueryBuilder typeProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'type'); diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 5237815266d7..be5d0569cb51 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -7,9 +7,10 @@ import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; +import 'package:thumbhash/thumbhash.dart' as thumbhash; /// Renders an Asset using local data if available, else remote data -class ImmichImage extends StatelessWidget { +class ImmichImage extends StatefulWidget { const ImmichImage( this.asset, { this.width, @@ -162,3 +163,103 @@ class ImmichImage extends StatelessWidget { } } } + +class _ImmichImageState extends State { + // Creating the Uint8List from the List during each build results in flickers during + // the fade transition. Calculate the hash in the initState and cache it for further builds + Uint8List? thumbHashBytes; + static const _placeholderDimension = 300.0; + + @override + void initState() { + super.initState(); + if (widget.asset?.thumbhash != null) { + final bytes = Uint8List.fromList(widget.asset!.thumbhash!); + final rgbaImage = thumbhash.thumbHashToRGBA(bytes); + thumbHashBytes = thumbhash.rgbaToBmp(rgbaImage); + } + } + + @override + Widget build(BuildContext context) { + if (widget.asset == null) { + return Container( + decoration: const BoxDecoration( + color: Colors.grey, + ), + child: SizedBox( + width: widget.width, + height: widget.height, + child: const Center( + child: Icon(Icons.no_photography), + ), + ), + ); + } + + final Asset asset = widget.asset!; + + return Image( + image: ImmichImage.imageProvider( + asset: asset, + isThumbnail: widget.isThumbnail, + ), + width: widget.width, + height: widget.height, + fit: widget.fit, + loadingBuilder: (_, child, loadingProgress) { + return AnimatedOpacity( + opacity: loadingProgress != null ? 0 : 1, + duration: const Duration(seconds: 1), + curve: Curves.easeOut, + child: child, + ); + }, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + + return Stack( + alignment: Alignment.center, + children: [ + if (widget.useGrayBoxPlaceholder) + const SizedBox.square( + dimension: _placeholderDimension, + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.grey), + ), + ), + if (thumbHashBytes != null) + Image.memory( + thumbHashBytes!, + width: _placeholderDimension, + height: _placeholderDimension, + fit: BoxFit.cover, + ), + if (widget.useProgressIndicator) + const Center( + child: CircularProgressIndicator(), + ), + ], + ); + }, + errorBuilder: (context, error, stackTrace) { + if (error is PlatformException && + error.code == "The asset not found!") { + debugPrint( + "Asset ${asset.localId} does not exist anymore on device!", + ); + } else { + debugPrint( + "Error getting thumb for assetId=${asset.localId}: $error", + ); + } + return Icon( + Icons.image_not_supported_outlined, + color: context.primaryColor, + ); + }, + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index de7bbea5ca2e..947fb9de8d50 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1467,6 +1467,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" + thumbhash: + dependency: "direct main" + description: + name: thumbhash + sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37" + url: "https://pub.dev" + source: hosted + version: "0.1.0+1" time: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 095566cf46e2..71e48a1562ef 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: wakelock_plus: ^1.1.4 flutter_local_notifications: ^16.3.2 timezone: ^0.9.2 + thumbhash: 0.1.0+1 openapi: path: openapi