diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index afd49adc6a..f2183602c1 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -38,7 +38,8 @@ class Asset { // stack handling to properly handle it stackParentId = remote.stackParentId == remote.id ? null : remote.stackParentId, - stackCount = remote.stackCount; + stackCount = remote.stackCount, + thumbhash = remote.thumbhash; Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -91,6 +92,7 @@ class Asset { this.stackCount = 0, this.isReadOnly = false, this.isOffline = false, + this.thumbhash, }); @ignore @@ -119,6 +121,8 @@ class Asset { /// because Isar cannot sort lists of byte arrays String checksum; + String? thumbhash; + @Index(unique: false, replace: false, type: IndexType.hash) String? remoteId; @@ -274,6 +278,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 && @@ -338,6 +343,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) @@ -374,6 +380,7 @@ class Asset { ExifInfo? exifInfo, String? stackParentId, int? stackCount, + String? thumbhash, }) => Asset( id: id ?? this.id, @@ -398,6 +405,7 @@ class Asset { exifInfo: exifInfo ?? this.exifInfo, stackParentId: stackParentId ?? this.stackParentId, stackCount: stackCount ?? this.stackCount, + thumbhash: thumbhash ?? this.thumbhash, ); Future put(Isar db) async { diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index d845b5353a..5912f291b5 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.string, + ), + 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 * 3; + } + } 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.writeString(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.readStringOrNull(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.readStringOrNull(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,152 @@ 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 thumbhashEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'thumbhash', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'thumbhash', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'thumbhash', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'thumbhash', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'thumbhash', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'thumbhash', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'thumbhash', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'thumbhash', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder thumbhashIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'thumbhash', + value: '', + )); + }); + } + + QueryBuilder thumbhashIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'thumbhash', + value: '', + )); + }); + } + QueryBuilder typeEqualTo( AssetType value) { return QueryBuilder.apply(this, (query) { @@ -2462,6 +2623,18 @@ extension AssetQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByThumbhash() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'thumbhash', Sort.asc); + }); + } + + QueryBuilder sortByThumbhashDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'thumbhash', Sort.desc); + }); + } + QueryBuilder sortByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -2716,6 +2889,18 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByThumbhash() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'thumbhash', Sort.asc); + }); + } + + QueryBuilder thenByThumbhashDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'thumbhash', Sort.desc); + }); + } + QueryBuilder thenByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -2864,6 +3049,13 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByThumbhash( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'thumbhash', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByType() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'type'); @@ -2992,6 +3184,12 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder 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/fade_in_placeholder_image.dart b/mobile/lib/shared/ui/fade_in_placeholder_image.dart new file mode 100644 index 0000000000..8aea8d068e --- /dev/null +++ b/mobile/lib/shared/ui/fade_in_placeholder_image.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/shared/ui/transparent_image.dart'; + +class FadeInPlaceholderImage extends StatelessWidget { + final Widget placeholder; + final ImageProvider image; + final Duration duration; + final BoxFit fit; + + const FadeInPlaceholderImage({ + super.key, + required this.placeholder, + required this.image, + this.duration = const Duration(milliseconds: 100), + this.fit = BoxFit.cover, + }); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: Stack( + fit: StackFit.expand, + children: [ + placeholder, + FadeInImage( + fadeInDuration: const Duration(milliseconds: 100), + image: image, + fit: fit, + placeholder: MemoryImage(kTransparentImage), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 9dab0a2a6e..3137f63014 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -41,7 +40,6 @@ class ImmichImage extends StatelessWidget { } if (asset == null) { - print('using remote for $assetId'); return ImmichRemoteImageProvider( assetId: assetId!, isThumbnail: false, @@ -49,12 +47,10 @@ class ImmichImage extends StatelessWidget { } if (useLocal(asset)) { - print('using local for ${asset.localId}'); return ImmichLocalImageProvider( asset: asset, ); } else { - print('using remote for ${asset.localId}'); return ImmichRemoteImageProvider( assetId: asset.remoteId!, isThumbnail: false, diff --git a/mobile/lib/shared/ui/immich_thumbnail.dart b/mobile/lib/shared/ui/immich_thumbnail.dart index ef2ccb62af..e9621fb3ec 100644 --- a/mobile/lib/shared/ui/immich_thumbnail.dart +++ b/mobile/lib/shared/ui/immich_thumbnail.dart @@ -1,25 +1,24 @@ +import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/thumbhash_placeholder.dart'; import 'package:octo_image/octo_image.dart'; +import 'package:thumbhash/thumbhash.dart' as thumbhash; -class ImmichThumbnail extends StatelessWidget { +class ImmichThumbnail extends StatefulWidget { const ImmichThumbnail({ this.asset, this.width = 250, this.height = 250, this.fit = BoxFit.cover, - this.placeholder, super.key, }); final Asset? asset; - final Widget? placeholder; final double width; final double height; final BoxFit fit; @@ -63,51 +62,47 @@ class ImmichThumbnail extends StatelessWidget { static bool useLocal(Asset asset) => !asset.isRemote || asset.isLocal; + @override + State createState() => _ImmichThumbnailState(); +} + +class _ImmichThumbnailState extends State { + Uint8List? _decodedBlurHash; + + @override + void initState() { + if (widget.asset?.thumbhash != null) { + final rgbaImage = + thumbhash.thumbHashToRGBA(base64Decode(widget.asset!.thumbhash!)); + _decodedBlurHash = thumbhash.rgbaToBmp(rgbaImage); + } + super.initState(); + } + @override Widget build(BuildContext context) { - if (asset == null) { + if (widget.asset == null) { return Container( color: Colors.grey, - width: width, - height: height, + width: widget.width, + height: widget.height, child: const Center( child: Icon(Icons.no_photography), ), ); } - return OctoImage( - fadeInDuration: const Duration(milliseconds: 0), + return OctoImage.fromSet( + placeholderFadeInDuration: Duration.zero, + fadeInDuration: Duration.zero, fadeOutDuration: const Duration(milliseconds: 100), - placeholderBuilder: (context) { - return placeholder ?? - ThumbnailPlaceholder( - height: height, - width: width, - ); - }, + octoSet: blurHashOrPlaceholder(_decodedBlurHash), image: ImmichThumbnail.imageProvider( - asset: asset, + asset: widget.asset, ), - width: width, - height: height, - fit: fit, - 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, - ); - }, + width: widget.width, + height: widget.height, + fit: widget.fit, ); } } diff --git a/mobile/lib/shared/ui/thumbhash_placeholder.dart b/mobile/lib/shared/ui/thumbhash_placeholder.dart new file mode 100644 index 0000000000..0ec64d3760 --- /dev/null +++ b/mobile/lib/shared/ui/thumbhash_placeholder.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/shared/ui/fade_in_placeholder_image.dart'; +import 'package:octo_image/octo_image.dart'; + +/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as +/// placeholder and [OctoError.icon] as error. +OctoSet blurHashOrPlaceholder( + Uint8List? blurhash, { + BoxFit? fit, + Text? errorMessage, +}) { + return OctoSet( + placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), + errorBuilder: blurHashErrorBuilder(blurhash, fit: fit), + ); +} + +OctoPlaceholderBuilder blurHashPlaceholderBuilder( + Uint8List? blurhash, { + BoxFit? fit, +}) { + return (context) => blurhash == null + ? const ThumbnailPlaceholder() + : FadeInPlaceholderImage( + placeholder: const ThumbnailPlaceholder(), + image: MemoryImage(blurhash), + fit: fit ?? BoxFit.cover, + ); +} + +OctoErrorBuilder blurHashErrorBuilder( + Uint8List? blurhash, { + BoxFit? fit, + Text? message, + IconData? icon, + Color? iconColor, + double? iconSize, +}) { + return OctoError.placeholderWithErrorIcon( + blurHashPlaceholderBuilder(blurhash, fit: fit), + message: message, + icon: icon, + iconColor: iconColor, + iconSize: iconSize, + ); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index ffa57f826b..7608a3ab6c 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 9788077650..2e1e0dd07e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: flutter_local_notifications: ^16.3.2 timezone: ^0.9.2 octo_image: ^2.0.0 + thumbhash: 0.1.0+1 openapi: path: openapi