diff --git a/mobile/lib/modules/album/ui/album_thumbnail_card.dart b/mobile/lib/modules/album/ui/album_thumbnail_card.dart index 880312322c..1da284572b 100644 --- a/mobile/lib/modules/album/ui/album_thumbnail_card.dart +++ b/mobile/lib/modules/album/ui/album_thumbnail_card.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/store.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; class AlbumThumbnailCard extends StatelessWidget { final Function()? onTap; @@ -45,8 +45,8 @@ class AlbumThumbnailCard extends StatelessWidget { ); } - buildAlbumThumbnail() => ImmichImage.thumbnail( - album.thumbnail.value, + buildAlbumThumbnail() => ImmichThumbnail( + asset: album.thumbnail.value, width: cardSize, height: cardSize, ); diff --git a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart index f70c706f35..5a27def4c9 100644 --- a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart +++ b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; class SharedAlbumThumbnailImage extends HookConsumerWidget { final Asset asset; @@ -16,8 +16,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget { }, child: Stack( children: [ - ImmichImage.thumbnail( - asset, + ImmichThumbnail( + asset: asset, width: 500, height: 500, ), diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index 2e826e86da..e6b2ade6bc 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/modules/partner/ui/partner_list.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_app_bar.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; @RoutePage() class SharingPage extends HookConsumerWidget { @@ -72,8 +72,8 @@ class SharingPage extends HookConsumerWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 12), leading: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), - child: ImmichImage.thumbnail( - album.thumbnail.value, + child: ImmichThumbnail( + asset: album.thumbnail.value, width: 60, height: 60, ), diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart index 4c1e9fc5c8..3094c69076 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_image_provider.dart @@ -11,7 +11,7 @@ import 'package:photo_manager/photo_manager.dart'; /// The local image provider for an asset /// Only viable -class ImmichLocalImageProvider extends ImageProvider { +class ImmichLocalImageProvider extends ImageProvider { final Asset asset; ImmichLocalImageProvider({ @@ -21,15 +21,18 @@ class ImmichLocalImageProvider extends ImageProvider { /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(asset); + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); } @override - ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) { + ImageStreamCompleter loadImage( + ImmichLocalImageProvider key, + ImageDecoderCallback decode, + ) { final chunkEvents = StreamController(); return MultiImageStreamCompleter( - codec: _codec(key, decode, chunkEvents), + codec: _codec(key.asset, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, informationCollector: () sync* { @@ -82,11 +85,6 @@ class ImmichLocalImageProvider extends ImageProvider { yield codec; } catch (error) { throw StateError("Loading asset ${asset.fileName} failed"); - } finally { - if (Platform.isIOS) { - // Clean up this file - await file.delete(); - } } } } diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart new file mode 100644 index 0000000000..bb86cfafda --- /dev/null +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:photo_manager/photo_manager.dart'; + +/// The local image provider for an asset +/// Only viable +class ImmichLocalThumbnailProvider extends ImageProvider { + final Asset asset; + final int height; + final int width; + + ImmichLocalThumbnailProvider({ + required this.asset, + this.height = 256, + this.width = 256, + }) : assert(asset.local != null, 'Only usable when asset.local is set'); + + /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key + /// that describes the precise image to load. + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(asset); + } + + @override + ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) { + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + informationCollector: () sync* { + yield ErrorDescription(asset.fileName); + }, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + Asset key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async* { + // Load a small thumbnail + final thumbBytes = await asset.local?.thumbnailDataWithSize( + const ThumbnailSize.square(32), + quality: 75, + ); + if (thumbBytes != null) { + final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + final codec = await decode(buffer); + yield codec; + } else { + debugPrint("Loading thumb for ${asset.fileName} failed"); + } + + final normalThumbBytes = + await asset.local?.thumbnailDataWithSize(ThumbnailSize(width, height)); + if (normalThumbBytes == null) { + throw StateError( + "Loading thumb for local photo ${asset.fileName} failed", + ); + } + final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes); + final codec = await decode(buffer); + yield codec; + + chunkEvents.close(); + } + + @override + bool operator ==(Object other) { + if (other is! ImmichLocalThumbnailProvider) return false; + if (identical(this, other)) return true; + return asset == other.asset; + } + + @override + int get hashCode => asset.hashCode; +} diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart index 9f9af7aded..d9fbd80485 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_image_provider.dart @@ -13,10 +13,13 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; /// Our Image Provider HTTP client to make the request -final _httpClient = HttpClient()..autoUncompress = false; +final _httpClient = HttpClient() + ..autoUncompress = false + ..maxConnectionsPerHost = 10; /// The remote image provider -class ImmichRemoteImageProvider extends ImageProvider { +class ImmichRemoteImageProvider + extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; @@ -32,16 +35,20 @@ class ImmichRemoteImageProvider extends ImageProvider { /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture('$assetId,$isThumbnail'); + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); } @override - ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { - final id = key.split(',').first; + ImageStreamCompleter loadImage( + ImmichRemoteImageProvider key, + ImageDecoderCallback decode, + ) { final chunkEvents = StreamController(); return MultiImageStreamCompleter( - codec: _codec(id, decode, chunkEvents), + codec: _codec(key, decode, chunkEvents), scale: 1.0, chunkEvents: chunkEvents.stream, ); @@ -61,14 +68,14 @@ class ImmichRemoteImageProvider extends ImageProvider { // Streams in each stage of the image as we ask for it Stream _codec( - String key, + ImmichRemoteImageProvider key, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { // Load a preview to the chunk events - if (_loadPreview || isThumbnail) { + if (_loadPreview || key.isThumbnail) { final preview = getThumbnailUrlForRemoteId( - assetId, + key.assetId, type: api.ThumbnailFormat.WEBP, ); @@ -80,14 +87,14 @@ class ImmichRemoteImageProvider extends ImageProvider { } // Guard thumnbail rendering - if (isThumbnail) { + if (key.isThumbnail) { await chunkEvents.close(); return; } // Load the higher resolution version of the image final url = getThumbnailUrlForRemoteId( - assetId, + key.assetId, type: api.ThumbnailFormat.JPEG, ); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); @@ -96,7 +103,7 @@ class ImmichRemoteImageProvider extends ImageProvider { // Load the final remote image if (_useOriginal) { // Load the original image - final url = getImageUrlFromId(assetId); + final url = getImageUrlFromId(key.assetId); final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); yield codec; } @@ -137,7 +144,7 @@ class ImmichRemoteImageProvider extends ImageProvider { bool operator ==(Object other) { if (other is! ImmichRemoteImageProvider) return false; if (identical(this, other)) return true; - return assetId == other.assetId; + return assetId == other.assetId && isThumbnail == other.isThumbnail; } @override diff --git a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart index 8332d8d3d7..92b85b3472 100644 --- a/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart +++ b/mobile/lib/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart @@ -12,14 +12,17 @@ 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'; +/// Our HTTP client to make the request +final _httpClient = HttpClient() + ..autoUncompress = false + ..maxConnectionsPerHost = 100; + /// The remote image provider -class ImmichRemoteThumbnailProvider extends ImageProvider { +class ImmichRemoteThumbnailProvider + extends ImageProvider { /// The [Asset.remoteId] of the asset to fetch final String assetId; - /// Our HTTP client to make the request - final _httpClient = HttpClient()..autoUncompress = false; - ImmichRemoteThumbnailProvider({ required this.assetId, }); @@ -27,12 +30,17 @@ class ImmichRemoteThumbnailProvider extends ImageProvider { /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @override - Future obtainKey(ImageConfiguration configuration) { - return SynchronousFuture(assetId); + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); } @override - ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) { + ImageStreamCompleter loadImage( + ImmichRemoteThumbnailProvider key, + ImageDecoderCallback decode, + ) { final chunkEvents = StreamController(); return MultiImageStreamCompleter( codec: _codec(key, decode, chunkEvents), @@ -43,13 +51,13 @@ class ImmichRemoteThumbnailProvider extends ImageProvider { // Streams in each stage of the image as we ask for it Stream _codec( - String key, + ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { // Load a preview to the chunk events final preview = getThumbnailUrlForRemoteId( - assetId, + key.assetId, type: api.ThumbnailFormat.WEBP, ); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index a78c085de4..48eb778c10 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'dart:math'; +import 'dart:ui' as ui; import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; @@ -10,6 +10,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; @@ -26,13 +27,13 @@ import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.da import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; @@ -481,15 +482,9 @@ class GalleryViewerPage extends HookConsumerWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( + child: Image( fit: BoxFit.cover, - imageUrl: - '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId', - httpHeaders: { - "x-immich-user-token": Store.get(StoreKey.accessToken), - }, - errorWidget: (context, url, error) => - const Icon(Icons.image_not_supported_outlined), + image: ImmichRemoteImageProvider(assetId: assetId!), ), ), ), @@ -740,9 +735,15 @@ class GalleryViewerPage extends HookConsumerWidget { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; }, - loadingBuilder: (context, event, index) => ImmichImage.thumbnail( - asset(), - fit: BoxFit.contain, + loadingBuilder: (context, event, index) => ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: 1, + sigmaY: 1, + ), + child: ImmichThumbnail( + asset: asset(), + fit: BoxFit.contain, + ), ), pageController: controller, scrollPhysics: isZoomed.value diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 73b31617f1..a194bc2ade 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -4,7 +4,7 @@ import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; import 'package:isar/isar.dart'; @@ -134,10 +134,10 @@ class ThumbnailImage extends StatelessWidget { tag: isFromDto ? '${asset.remoteId}-$heroOffset' : asset.id + heroOffset, - child: ImmichImage.thumbnail( - asset, - height: 300, - width: 300, + child: ImmichThumbnail( + asset: asset, + height: 250, + width: 250, ), ), ); diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index 7a93550442..5243e24a13 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart' import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; class MemoryCard extends StatelessWidget { final Asset asset; @@ -42,9 +43,8 @@ class MemoryCard extends StatelessWidget { child: Container( decoration: BoxDecoration( image: DecorationImage( - image: ImmichImage.imageProvider( + image: ImmichThumbnail.imageProvider( asset: asset, - isThumbnail: true, ), fit: BoxFit.cover, ), diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index 199af835c9..4312a7ad2e 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/memories/ui/memory_epilogue.dart'; import 'package:immich_mobile/modules/memories/ui/memory_progress_indicator.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; +import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; @RoutePage() class MemoryPage extends HookConsumerWidget { @@ -120,9 +121,8 @@ class MemoryPage extends HookConsumerWidget { context, ), precacheImage( - ImmichImage.imageProvider( + ImmichThumbnail.imageProvider( asset: asset, - isThumbnail: true, ), context, ), diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index dd38b050b1..3c3c4df82f 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; @@ -279,6 +283,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 && @@ -343,6 +348,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) @@ -379,6 +385,7 @@ class Asset { ExifInfo? exifInfo, String? stackParentId, int? stackCount, + String? thumbhash, }) => Asset( id: id ?? this.id, @@ -403,6 +410,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..e0620ea4f0 --- /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: duration, + image: image, + fit: fit, + placeholder: MemoryImage(kTransparentImage), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/shared/ui/hooks/blurhash_hook.dart b/mobile/lib/shared/ui/hooks/blurhash_hook.dart new file mode 100644 index 0000000000..24b3c25e13 --- /dev/null +++ b/mobile/lib/shared/ui/hooks/blurhash_hook.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:thumbhash/thumbhash.dart' as thumbhash; + +ObjectRef useBlurHashRef(Asset? asset) { + if (asset?.thumbhash == null) { + return useRef(null); + } + + final rbga = thumbhash.thumbHashToRGBA( + base64Decode(asset!.thumbhash!), + ); + + return useRef(thumbhash.rgbaToBmp(rbga)); +} diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 280f7de170..3137f63014 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -9,8 +7,6 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.d import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:octo_image/octo_image.dart'; -import 'package:photo_manager/photo_manager.dart'; -import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; class ImmichImage extends StatelessWidget { const ImmichImage( @@ -19,8 +15,6 @@ class ImmichImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.placeholder = const ThumbnailPlaceholder(), - this.isThumbnail = false, - this.thumbnailSize = 250, super.key, }); @@ -29,32 +23,6 @@ class ImmichImage extends StatelessWidget { final double? width; final double? height; final BoxFit fit; - final bool isThumbnail; - final int thumbnailSize; - - /// Factory constructor to use the thumbnail variant - factory ImmichImage.thumbnail( - Asset? asset, { - BoxFit fit = BoxFit.cover, - double? width, - double? height, - }) { - // Use the width and height to derive thumbnail size - final thumbnailSize = max(width ?? 250, height ?? 250).toInt(); - - return ImmichImage( - asset, - isThumbnail: true, - fit: fit, - width: width, - height: height, - placeholder: ThumbnailPlaceholder( - height: thumbnailSize.toDouble(), - width: thumbnailSize.toDouble(), - ), - thumbnailSize: thumbnailSize, - ); - } // Helper function to return the image provider for the asset // either by using the asset ID or the asset itself @@ -66,8 +34,6 @@ class ImmichImage extends StatelessWidget { static ImageProvider imageProvider({ Asset? asset, String? assetId, - bool isThumbnail = false, - int thumbnailSize = 250, }) { if (asset == null && assetId == null) { throw Exception('Must supply either asset or assetId'); @@ -76,24 +42,18 @@ class ImmichImage extends StatelessWidget { if (asset == null) { return ImmichRemoteImageProvider( assetId: assetId!, - isThumbnail: isThumbnail, + isThumbnail: false, ); } - if (useLocal(asset) && isThumbnail) { - return AssetEntityImageProvider( - asset.local!, - isOriginal: false, - thumbnailSize: ThumbnailSize.square(thumbnailSize), - ); - } else if (useLocal(asset) && !isThumbnail) { + if (useLocal(asset)) { return ImmichLocalImageProvider( asset: asset, ); } else { return ImmichRemoteImageProvider( assetId: asset.remoteId!, - isThumbnail: isThumbnail, + isThumbnail: false, ); } } @@ -105,15 +65,11 @@ class ImmichImage extends StatelessWidget { Widget build(BuildContext context) { if (asset == null) { return Container( - decoration: const BoxDecoration( - color: Colors.grey, - ), - child: SizedBox( - width: width, - height: height, - child: const Center( - child: Icon(Icons.no_photography), - ), + color: Colors.grey, + width: width, + height: height, + child: const Center( + child: Icon(Icons.no_photography), ), ); } @@ -131,7 +87,6 @@ class ImmichImage extends StatelessWidget { }, image: ImmichImage.imageProvider( asset: asset, - isThumbnail: isThumbnail, ), width: width, height: height, diff --git a/mobile/lib/shared/ui/immich_thumbnail.dart b/mobile/lib/shared/ui/immich_thumbnail.dart new file mode 100644 index 0000000000..fe35bdaac2 --- /dev/null +++ b/mobile/lib/shared/ui/immich_thumbnail.dart @@ -0,0 +1,89 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; +import 'package:immich_mobile/shared/ui/thumbhash_placeholder.dart'; +import 'package:octo_image/octo_image.dart'; + +class ImmichThumbnail extends HookWidget { + const ImmichThumbnail({ + this.asset, + this.width = 250, + this.height = 250, + this.fit = BoxFit.cover, + super.key, + }); + + final Asset? asset; + final double width; + final double height; + final BoxFit fit; + + /// Helper function to return the image provider for the asset thumbnail + /// either by using the asset ID or the asset itself + /// [asset] is the Asset to request, or else use [assetId] to get a remote + /// image provider + static ImageProvider imageProvider({ + Asset? asset, + String? assetId, + int thumbnailSize = 256, + }) { + if (asset == null && assetId == null) { + throw Exception('Must supply either asset or assetId'); + } + + if (asset == null) { + return ImmichRemoteImageProvider( + assetId: assetId!, + isThumbnail: true, + ); + } + + if (useLocal(asset)) { + return ImmichLocalThumbnailProvider( + asset: asset, + height: thumbnailSize, + width: thumbnailSize, + ); + } else { + return ImmichRemoteImageProvider( + assetId: asset.remoteId!, + isThumbnail: true, + ); + } + } + + static bool useLocal(Asset asset) => !asset.isRemote || asset.isLocal; + + @override + Widget build(BuildContext context) { + Uint8List? blurhash = useBlurHashRef(asset).value; + if (asset == null) { + return Container( + color: Colors.grey, + width: width, + height: height, + child: const Center( + child: Icon(Icons.no_photography), + ), + ); + } + + return OctoImage.fromSet( + placeholderFadeInDuration: Duration.zero, + fadeInDuration: Duration.zero, + fadeOutDuration: const Duration(milliseconds: 100), + octoSet: blurHashOrPlaceholder(blurhash), + image: ImmichThumbnail.imageProvider( + asset: asset, + ), + width: width, + height: height, + fit: 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 6bc37c922f..9e379d4653 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1491,6 +1491,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 0869d39731..50d170904f 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