From 2a1e914245bfcb052075a5929b6b2e2b3e6f0a80 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:41:53 +0300 Subject: [PATCH] thumbhash render box --- mobile/lib/constants/constants.dart | 2 +- .../widgets/images/thumbnail.widget.dart | 5 +- .../widgets/memory/memory_card.widget.dart | 7 +- .../common/fade_in_placeholder_image.dart | 30 --- mobile/lib/widgets/common/thumbhash.dart | 181 +++++++++++++++++- .../widgets/common/thumbhash_placeholder.dart | 14 +- 6 files changed, 183 insertions(+), 56 deletions(-) delete mode 100644 mobile/lib/widgets/common/fade_in_placeholder_image.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index b3d9d138c4..158bfa7147 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -26,7 +26,7 @@ const String kDownloadGroupLivePhoto = 'group_livephoto'; // Timeline constants const int kTimelineNoneSegmentSize = 120; -const int kTimelineAssetLoadBatchSize = 256; +const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadOppositeSize = 64; // Widget keys diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index b44b92b223..4cb22eeefa 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/common/thumbhash.dart'; import 'package:logging/logging.dart'; import 'package:octo_image/octo_image.dart'; @@ -37,9 +36,7 @@ class Thumbnail extends StatelessWidget { } OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) { - return (context) => thumbHash == null - ? const ThumbnailPlaceholder() - : Thumbhash(blurhash: thumbHash, fit: fit ?? BoxFit.cover); + return (context) => Thumbhash(blurhash: thumbHash, fit: fit ?? BoxFit.cover); } OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) => diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart index 0073c42dab..eba0a3bae2 100644 --- a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -91,12 +91,7 @@ class _BlurredBackdrop extends HookWidget { final blurhash = asset.thumbHash; if (blurhash != null) { // Use a nice cheap blur hash image decoration - return Stack( - children: [ - const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)), - Thumbhash(blurhash: blurhash, fit: BoxFit.cover), - ], - ); + return Thumbhash(blurhash: blurhash, fit: BoxFit.cover); } // Fall back to using a more expensive image filtered diff --git a/mobile/lib/widgets/common/fade_in_placeholder_image.dart b/mobile/lib/widgets/common/fade_in_placeholder_image.dart deleted file mode 100644 index 2461dbe6bf..0000000000 --- a/mobile/lib/widgets/common/fade_in_placeholder_image.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:immich_mobile/widgets/common/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/widgets/common/thumbhash.dart b/mobile/lib/widgets/common/thumbhash.dart index 81ba62db07..3b6d970742 100644 --- a/mobile/lib/widgets/common/thumbhash.dart +++ b/mobile/lib/widgets/common/thumbhash.dart @@ -1,23 +1,194 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:ui' as ui; +import 'dart:ui'; +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:thumbhash/thumbhash.dart' as thumbhash; -class Thumbhash extends StatelessWidget { - final String blurhash; +class ThumbhashImage extends RenderBox { + Color _placeholderColor; + ui.Image? _image; + BoxFit _fit; + + ThumbhashImage({ + required ui.Image? image, + required BoxFit fit, + required Color placeholderColor, + }) : _image = image, + _fit = fit, + _placeholderColor = placeholderColor; + + @override + void paint(PaintingContext context, Offset offset) { + final image = _image; + final rect = offset & size; + if (image == null) { + final paint = Paint(); + paint.color = _placeholderColor; + context.canvas.drawRect(rect, paint); + return; + } + + paintImage( + canvas: context.canvas, + rect: rect, + image: image, + fit: _fit, + filterQuality: FilterQuality.low, + ); + } + + @override + void performLayout() { + size = constraints.biggest; + } + + set image(ui.Image? value) { + if (_image != value) { + _image = value; + markNeedsPaint(); + } + } + + set fit(BoxFit value) { + if (_fit != value) { + _fit = value; + markNeedsPaint(); + } + } + + set placeholderColor(Color value) { + if (_placeholderColor != value) { + _placeholderColor = value; + markNeedsPaint(); + } + } +} + +class ThumbhashLeaf extends LeafRenderObjectWidget { + final ui.Image? image; final BoxFit fit; + final Color placeholderColor; + + const ThumbhashLeaf({ + super.key, + required this.image, + required this.fit, + required this.placeholderColor, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return ThumbhashImage( + image: image, + fit: fit, + placeholderColor: placeholderColor, + ); + } + + @override + void updateRenderObject(BuildContext context, ThumbhashImage renderObject) { + renderObject.fit = fit; + renderObject.image = image; + renderObject.placeholderColor = placeholderColor; + } +} + +class Thumbhash extends StatefulWidget { + final String? blurhash; + final BoxFit fit; + final Color placeholderColor; const Thumbhash({ required this.blurhash, this.fit = BoxFit.cover, + this.placeholderColor = const Color.fromRGBO(0, 0, 0, 0.2), super.key, }); + @override + State createState() => _ThumbhashState(); +} + +class _ThumbhashState extends State { + String? blurhash; + BoxFit? fit; + ui.Image? _image; + Color? placeholderColor; + + @override + void initState() { + super.initState(); + final blurhash_ = blurhash = widget.blurhash; + fit = widget.fit; + placeholderColor = widget.placeholderColor; + if (blurhash_ == null) { + return; + } + final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash_)); + _decode(image); + } + + Future _decode(thumbhash.Image image) async { + if (!mounted) { + return; + } + final buffer = await ImmutableBuffer.fromUint8List(image.rgba); + if (!mounted) { + buffer.dispose(); + return; + } + + final descriptor = ImageDescriptor.raw( + buffer, + width: image.width, + height: image.height, + pixelFormat: PixelFormat.rgba8888, + ); + if (!mounted) { + buffer.dispose(); + descriptor.dispose(); + return; + } + + final codec = await descriptor.instantiateCodec( + targetWidth: image.width, + targetHeight: image.height, + ); + if (!mounted) { + buffer.dispose(); + descriptor.dispose(); + codec.dispose(); + return; + } + + final frame = (await codec.getNextFrame()).image; + buffer.dispose(); + descriptor.dispose(); + codec.dispose(); + if (!mounted) { + frame.dispose(); + return; + } + setState(() { + _image = frame; + }); + } + @override Widget build(BuildContext context) { - return Image.memory( - thumbhash.rgbaToBmp(thumbhash.thumbHashToRGBA(base64.decode(blurhash))), - fit: fit, + return ThumbhashLeaf( + image: _image, + fit: fit!, + placeholderColor: placeholderColor!, ); } + + @override + void dispose() { + _image?.dispose(); + super.dispose(); + } } diff --git a/mobile/lib/widgets/common/thumbhash_placeholder.dart b/mobile/lib/widgets/common/thumbhash_placeholder.dart index f3879fda9e..a1e9bc1d21 100644 --- a/mobile/lib/widgets/common/thumbhash_placeholder.dart +++ b/mobile/lib/widgets/common/thumbhash_placeholder.dart @@ -1,29 +1,23 @@ import 'package:flutter/material.dart'; -import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/common/thumbhash.dart'; import 'package:octo_image/octo_image.dart'; /// Simple set to show [OctoPlaceholder.circularProgressIndicator] as /// placeholder and [OctoError.icon] as error. -OctoSet blurHashOrPlaceholder(String? blurhash, {BoxFit? fit, Text? errorMessage}) { +OctoSet blurHashOrPlaceholder(String? blurhash, {BoxFit fit = BoxFit.cover, Text? errorMessage}) { return OctoSet( placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), errorBuilder: blurHashErrorBuilder(blurhash, fit: fit, message: errorMessage), ); } -OctoPlaceholderBuilder blurHashPlaceholderBuilder(String? blurhash, {BoxFit? fit}) { - return (context) => blurhash == null - ? const ThumbnailPlaceholder() - : Thumbhash( - blurhash: blurhash, - fit: fit ?? BoxFit.cover, - ); +OctoPlaceholderBuilder blurHashPlaceholderBuilder(String? blurhash, {required BoxFit fit}) { + return (context) => Thumbhash(blurhash: blurhash, fit: fit); } OctoErrorBuilder blurHashErrorBuilder( String? blurhash, { - BoxFit? fit, + BoxFit fit = BoxFit.cover, Text? message, IconData? icon, Color? iconColor,