Browse Source

add thumbhash support to mobile app

Luke McCarthy 2 năm trước cách đây
mục cha
commit
000c2bf43f

+ 11 - 1
mobile/lib/shared/models/asset.dart

@@ -30,7 +30,8 @@ class Asset {
         exifInfo =
             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
         isFavorite = remote.isFavorite,
-        isArchived = remote.isArchived;
+        isArchived = remote.isArchived,
+        thumbhash = remote.thumbhash;
 
   Asset.local(AssetEntity local, List<int> hash)
       : localId = local.id,
@@ -74,6 +75,7 @@ class Asset {
     this.exifInfo,
     required this.isFavorite,
     required this.isArchived,
+    required this.thumbhash,
   });
 
   @ignore
@@ -108,6 +110,8 @@ class Asset {
   )
   String checksum;
 
+  String? thumbhash;
+
   @Index(unique: false, replace: false, type: IndexType.hash)
   String? remoteId;
 
@@ -180,6 +184,7 @@ class Asset {
     if (other is! Asset) return false;
     return id == other.id &&
         checksum == other.checksum &&
+        thumbhash == other.thumbhash &&
         remoteId == other.remoteId &&
         localId == other.localId &&
         ownerId == other.ownerId &&
@@ -202,6 +207,7 @@ class Asset {
   int get hashCode =>
       id.hashCode ^
       checksum.hashCode ^
+      thumbhash.hashCode ^
       remoteId.hashCode ^
       localId.hashCode ^
       ownerId.hashCode ^
@@ -222,6 +228,7 @@ class Asset {
   bool canUpdate(Asset a) {
     assert(isInDb);
     assert(checksum == a.checksum);
+    assert(thumbhash == a.thumbhash);
     assert(a.storage != AssetState.merged);
     return a.updatedAt.isAfter(updatedAt) ||
         a.isRemote && !isRemote ||
@@ -292,6 +299,7 @@ class Asset {
   Asset _copyWith({
     Id? id,
     String? checksum,
+    String? thumbhash,
     String? remoteId,
     String? localId,
     int? ownerId,
@@ -311,6 +319,7 @@ class Asset {
       Asset(
         id: id ?? this.id,
         checksum: checksum ?? this.checksum,
+        thumbhash: thumbhash ?? this.thumbhash,
         remoteId: remoteId ?? this.remoteId,
         localId: localId ?? this.localId,
         ownerId: ownerId ?? this.ownerId,
@@ -365,6 +374,7 @@ class Asset {
   "remoteId": "${remoteId ?? "N/A"}",
   "localId": "${localId ?? "N/A"}",
   "checksum": "$checksum",
+  "thumbhash": "$thumbhash",
   "ownerId": $ownerId, 
   "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
   "fileCreatedAt": "$fileCreatedAt",

+ 209 - 11
mobile/lib/shared/models/asset.g.dart

@@ -77,19 +77,24 @@ const AssetSchema = CollectionSchema(
       name: r'remoteId',
       type: IsarType.string,
     ),
-    r'type': PropertySchema(
+    r'thumbhash': PropertySchema(
       id: 12,
+      name: r'thumbhash',
+      type: IsarType.string,
+    ),
+    r'type': PropertySchema(
+      id: 13,
       name: r'type',
       type: IsarType.byte,
       enumMap: _AssettypeEnumValueMap,
     ),
     r'updatedAt': PropertySchema(
-      id: 13,
+      id: 14,
       name: r'updatedAt',
       type: IsarType.dateTime,
     ),
     r'width': PropertySchema(
-      id: 14,
+      id: 15,
       name: r'width',
       type: IsarType.int,
     )
@@ -179,6 +184,12 @@ int _assetEstimateSize(
       bytesCount += 3 + value.length * 3;
     }
   }
+  {
+    final value = object.thumbhash;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
   return bytesCount;
 }
 
@@ -200,9 +211,10 @@ void _assetSerialize(
   writer.writeString(offsets[9], object.localId);
   writer.writeLong(offsets[10], object.ownerId);
   writer.writeString(offsets[11], object.remoteId);
-  writer.writeByte(offsets[12], object.type.index);
-  writer.writeDateTime(offsets[13], object.updatedAt);
-  writer.writeInt(offsets[14], object.width);
+  writer.writeString(offsets[12], object.thumbhash);
+  writer.writeByte(offsets[13], object.type.index);
+  writer.writeDateTime(offsets[14], object.updatedAt);
+  writer.writeInt(offsets[15], object.width);
 }
 
 Asset _assetDeserialize(
@@ -225,10 +237,11 @@ Asset _assetDeserialize(
     localId: reader.readStringOrNull(offsets[9]),
     ownerId: reader.readLong(offsets[10]),
     remoteId: reader.readStringOrNull(offsets[11]),
-    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
+    thumbhash: reader.readStringOrNull(offsets[12]),
+    type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
         AssetType.other,
-    updatedAt: reader.readDateTime(offsets[13]),
-    width: reader.readIntOrNull(offsets[14]),
+    updatedAt: reader.readDateTime(offsets[14]),
+    width: reader.readIntOrNull(offsets[15]),
   );
   return object;
 }
@@ -265,11 +278,13 @@ P _assetDeserializeProp<P>(
     case 11:
       return (reader.readStringOrNull(offset)) as P;
     case 12:
+      return (reader.readStringOrNull(offset)) as P;
+    case 13:
       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
           AssetType.other) as P;
-    case 13:
-      return (reader.readDateTime(offset)) as P;
     case 14:
+      return (reader.readDateTime(offset)) as P;
+    case 15:
       return (reader.readIntOrNull(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
@@ -1786,6 +1801,152 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'thumbhash',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'thumbhash',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEqualTo(
+    String? value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'thumbhash',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> thumbhashStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'thumbhash',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'thumbhash',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashContains(
+      String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'thumbhash',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashMatches(
+      String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'thumbhash',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'thumbhash',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'thumbhash',
+        value: '',
+      ));
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
       AssetType value) {
     return QueryBuilder.apply(this, (query) {
@@ -2110,6 +2271,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhash() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'thumbhash', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhashDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'thumbhash', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'type', Sort.asc);
@@ -2304,6 +2477,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhash() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'thumbhash', Sort.asc);
+    });
+  }
+
+  QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhashDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'thumbhash', Sort.desc);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'type', Sort.asc);
@@ -2420,6 +2605,13 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
     });
   }
 
+  QueryBuilder<Asset, Asset, QDistinct> distinctByThumbhash(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'thumbhash', caseSensitive: caseSensitive);
+    });
+  }
+
   QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
     return QueryBuilder.apply(this, (query) {
       return query.addDistinctBy(r'type');
@@ -2518,6 +2710,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
     });
   }
 
+  QueryBuilder<Asset, String?, QQueryOperations> thumbhashProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'thumbhash');
+    });
+  }
+
   QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'type');

+ 10 - 1
mobile/lib/shared/ui/immich_image.dart

@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_cache_manager/flutter_cache_manager.dart';
+import 'package:flutter_thumbhash/flutter_thumbhash.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -86,6 +87,7 @@ class ImmichImage extends StatelessWidget {
     }
     final String? token = Store.get(StoreKey.accessToken);
     final String thumbnailRequestUrl = getThumbnailUrl(asset);
+
     return CachedNetworkImage(
       imageUrl: thumbnailRequestUrl,
       httpHeaders: {"Authorization": "Bearer $token"},
@@ -98,7 +100,14 @@ class ImmichImage extends StatelessWidget {
       fit: fit,
       fadeInDuration: const Duration(milliseconds: 250),
       progressIndicatorBuilder: (context, url, downloadProgress) {
-        if (useGrayBoxPlaceholder) {
+        if (asset.thumbhash != null) {
+          return FittedBox(
+            fit: BoxFit.fill,
+            child: Image(
+              image: ThumbHash.fromBase64(asset.thumbhash!).toImage(),
+            ),
+          );
+        } else if (useGrayBoxPlaceholder) {
           return const DecoratedBox(
             decoration: BoxDecoration(color: Colors.grey),
           );

+ 58 - 44
mobile/openapi/lib/model/user_response_dto.dart

@@ -52,61 +52,64 @@ class UserResponseDto {
   String oauthId;
 
   @override
-  bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
-     other.id == id &&
-     other.email == email &&
-     other.firstName == firstName &&
-     other.lastName == lastName &&
-     other.storageLabel == storageLabel &&
-     other.profileImagePath == profileImagePath &&
-     other.shouldChangePassword == shouldChangePassword &&
-     other.isAdmin == isAdmin &&
-     other.createdAt == createdAt &&
-     other.deletedAt == deletedAt &&
-     other.updatedAt == updatedAt &&
-     other.oauthId == oauthId;
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is UserResponseDto &&
+          other.id == id &&
+          other.email == email &&
+          other.firstName == firstName &&
+          other.lastName == lastName &&
+          other.storageLabel == storageLabel &&
+          other.profileImagePath == profileImagePath &&
+          other.shouldChangePassword == shouldChangePassword &&
+          other.isAdmin == isAdmin &&
+          other.createdAt == createdAt &&
+          other.deletedAt == deletedAt &&
+          other.updatedAt == updatedAt &&
+          other.oauthId == oauthId;
 
   @override
   int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (id.hashCode) +
-    (email.hashCode) +
-    (firstName.hashCode) +
-    (lastName.hashCode) +
-    (storageLabel == null ? 0 : storageLabel!.hashCode) +
-    (profileImagePath.hashCode) +
-    (shouldChangePassword.hashCode) +
-    (isAdmin.hashCode) +
-    (createdAt.hashCode) +
-    (deletedAt == null ? 0 : deletedAt!.hashCode) +
-    (updatedAt.hashCode) +
-    (oauthId.hashCode);
+      // ignore: unnecessary_parenthesis
+      (id.hashCode) +
+      (email.hashCode) +
+      (firstName.hashCode) +
+      (lastName.hashCode) +
+      (storageLabel == null ? 0 : storageLabel!.hashCode) +
+      (profileImagePath.hashCode) +
+      (shouldChangePassword.hashCode) +
+      (isAdmin.hashCode) +
+      (createdAt.hashCode) +
+      (deletedAt == null ? 0 : deletedAt!.hashCode) +
+      (updatedAt.hashCode) +
+      (oauthId.hashCode);
 
   @override
-  String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, createdAt=$createdAt, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
+  String toString() =>
+      'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, createdAt=$createdAt, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
-      json[r'id'] = this.id;
-      json[r'email'] = this.email;
-      json[r'firstName'] = this.firstName;
-      json[r'lastName'] = this.lastName;
+    json[r'id'] = this.id;
+    json[r'email'] = this.email;
+    json[r'firstName'] = this.firstName;
+    json[r'lastName'] = this.lastName;
     if (this.storageLabel != null) {
       json[r'storageLabel'] = this.storageLabel;
     } else {
       // json[r'storageLabel'] = null;
     }
-      json[r'profileImagePath'] = this.profileImagePath;
-      json[r'shouldChangePassword'] = this.shouldChangePassword;
-      json[r'isAdmin'] = this.isAdmin;
-      json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
+    json[r'profileImagePath'] = this.profileImagePath;
+    json[r'shouldChangePassword'] = this.shouldChangePassword;
+    json[r'isAdmin'] = this.isAdmin;
+    json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
     if (this.deletedAt != null) {
       json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
     } else {
       // json[r'deletedAt'] = null;
     }
-      json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
-      json[r'oauthId'] = this.oauthId;
+    json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
+    json[r'oauthId'] = this.oauthId;
     return json;
   }
 
@@ -122,8 +125,10 @@ class UserResponseDto {
       // Note 2: this code is stripped in release mode!
       assert(() {
         requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.');
+          assert(json.containsKey(key),
+              'Required key "UserResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null,
+              'Required key "UserResponseDto[$key]" has a null value in JSON.');
         });
         return true;
       }());
@@ -135,7 +140,8 @@ class UserResponseDto {
         lastName: mapValueOfType<String>(json, r'lastName')!,
         storageLabel: mapValueOfType<String>(json, r'storageLabel'),
         profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
-        shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
+        shouldChangePassword:
+            mapValueOfType<bool>(json, r'shouldChangePassword')!,
         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
         createdAt: mapDateTime(json, r'createdAt', '')!,
         deletedAt: mapDateTime(json, r'deletedAt', ''),
@@ -146,7 +152,10 @@ class UserResponseDto {
     return null;
   }
 
-  static List<UserResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+  static List<UserResponseDto> listFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final result = <UserResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
@@ -174,13 +183,19 @@ class UserResponseDto {
   }
 
   // maps a json object with a list of UserResponseDto-objects as value to a dart map
-  static Map<String, List<UserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+  static Map<String, List<UserResponseDto>> mapListFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final map = <String, List<UserResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
-        map[entry.key] = UserResponseDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = UserResponseDto.listFromJson(
+          entry.value,
+          growable: growable,
+        );
       }
     }
     return map;
@@ -202,4 +217,3 @@ class UserResponseDto {
     'oauthId',
   };
 }
-

+ 16 - 0
mobile/pubspec.lock

@@ -453,6 +453,14 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_thumbhash:
+    dependency: "direct main"
+    description:
+      name: flutter_thumbhash
+      sha256: "0a21f7fc4ffefe79f93b047780ad7d5af42a0f0a49b9ec5fb94005e6deebe4ad"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.0+1"
   flutter_udid:
     dependency: "direct main"
     description:
@@ -1224,6 +1232,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.5.1"
+  thumbhash:
+    dependency: transitive
+    description:
+      name: thumbhash
+      sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.0+1"
   time:
     dependency: transitive
     description:

+ 1 - 0
mobile/pubspec.yaml

@@ -47,6 +47,7 @@ dependencies:
   permission_handler: ^10.2.0
   device_info_plus: ^8.1.0
   crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
+  flutter_thumbhash: 0.1.0+1
 
   openapi:
     path: openapi