thumbhash improvements

thumbhash render box

refactor

wip

rebase
This commit is contained in:
mertalev
2025-07-08 16:17:03 +03:00
parent e7060dc292
commit ddd65dea58
37 changed files with 1903 additions and 549 deletions
@@ -1,7 +1,7 @@
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/presentation/widgets/images/thumbnail.widget.dart';
import 'package:octo_image/octo_image.dart';
class FullImage extends StatelessWidget {
@@ -9,7 +9,7 @@ class FullImage extends StatelessWidget {
this.asset, {
required this.size,
this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(),
this.placeholder = const Thumbnail(),
super.key,
});
@@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Thumbnail(asset: data),
child: Thumbnail.fromBaseAsset(asset: data),
);
},
error: (error, stack) {
@@ -1,39 +0,0 @@
import 'dart:convert' hide Codec;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:thumbhash/thumbhash.dart';
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
final String thumbHash;
const ThumbHashProvider({required this.thumbHash});
@override
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
return MultiFrameImageStreamCompleter(codec: _loadCodec(key, decode), scale: 1.0);
}
Future<Codec> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) async {
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ThumbHashProvider) {
return thumbHash == other.thumbHash;
}
return false;
}
@override
int get hashCode => thumbHash.hashCode;
}
@@ -1,61 +1,402 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;
import 'dart:ui';
import 'package:flutter_cache_manager/flutter_cache_manager.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/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart';
import 'package:octo_image/octo_image.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
class Thumbnail extends StatelessWidget {
const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key})
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
final log = Logger('ThumbnailWidget');
final BaseAsset? asset;
final String? remoteId;
final Size size;
class Thumbnail extends StatefulWidget {
final BoxFit fit;
final Size size;
final String? blurhash;
final String? localId;
final String? remoteId;
final bool thumbhashOnly;
const Thumbnail({
this.fit = BoxFit.cover,
this.size = const Size.square(256),
this.blurhash,
this.localId,
this.remoteId,
this.thumbhashOnly = false,
super.key,
});
Thumbnail.fromAsset({
required Asset asset,
this.fit = BoxFit.cover,
this.size = const Size.square(256),
this.thumbhashOnly = false,
super.key,
}) : blurhash = asset.thumbhash,
localId = asset.localId,
remoteId = asset.remoteId;
Thumbnail.fromBaseAsset({
required BaseAsset? asset,
this.fit = BoxFit.cover,
this.size = const Size.square(256),
this.thumbhashOnly = false,
super.key,
}) : blurhash = switch (asset) {
RemoteAsset() => asset.thumbHash,
_ => null,
},
localId = switch (asset) {
RemoteAsset() => asset.localId,
LocalAsset() => asset.id,
_ => null,
},
remoteId = switch (asset) {
RemoteAsset() => asset.id,
LocalAsset() => asset.remoteId,
_ => null,
};
@override
State<Thumbnail> createState() => _ThumbnailState();
}
class _ThumbnailState extends State<Thumbnail> {
ui.Image? _image;
static final _gradientCache = <ColorScheme, Gradient>{};
static final _imageCache = ThumbnailImageCacheManager();
@override
void initState() {
super.initState();
_decode();
}
@override
void didUpdateWidget(Thumbnail oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.blurhash != widget.blurhash ||
oldWidget.localId != widget.localId ||
oldWidget.remoteId != widget.remoteId ||
oldWidget.thumbhashOnly != widget.thumbhashOnly) {
_decode();
}
}
Future<void> _decode() async {
if (!mounted) {
return;
}
final thumbhashOnly = widget.thumbhashOnly;
final blurhash = widget.blurhash;
final imageFuture = thumbhashOnly ? Future.value(null) : _decodeFromFile();
if (blurhash != null) {
final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash));
try {
await _decodeThumbhash(
await ImmutableBuffer.fromUint8List(image.rgba),
image.width,
image.height,
);
} catch (e) {
log.info('Error decoding thumbhash for ${widget.remoteId}: $e');
}
}
if (!mounted || thumbhashOnly) {
return;
}
try {
final image = await imageFuture;
if (!mounted || image == null) {
return;
}
_image?.dispose();
setState(() {
_image = image;
});
} catch (e) {
log.info('Error decoding thumbnail: $e');
}
}
Future<void> _decodeThumbhash(
ImmutableBuffer buffer,
int width,
int height,
) async {
if (!mounted) {
buffer.dispose();
return;
}
final descriptor = ImageDescriptor.raw(
buffer,
width: width,
height: height,
pixelFormat: PixelFormat.rgba8888,
);
if (!mounted) {
buffer.dispose();
descriptor.dispose();
return;
}
final codec = await descriptor.instantiateCodec(
targetWidth: width,
targetHeight: 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;
});
}
Future<ui.Image?> _decodeFromFile() async {
final buffer = await _getFile();
if (buffer == null) {
return null;
}
final stopwatch = Stopwatch()..start();
final thumb = await _decodeThumbnail(buffer, 256);
stopwatch.stop();
return thumb;
}
Future<ui.Image?> _decodeThumbnail(ImmutableBuffer buffer, int height) async {
if (!mounted) {
buffer.dispose();
return null;
}
final descriptor = await ImageDescriptor.encoded(buffer);
if (!mounted) {
buffer.dispose();
descriptor.dispose();
return null;
}
final codec = await descriptor.instantiateCodec(targetHeight: height);
if (!mounted) {
buffer.dispose();
descriptor.dispose();
codec.dispose();
return null;
}
final frame = (await codec.getNextFrame()).image;
buffer.dispose();
descriptor.dispose();
codec.dispose();
if (!mounted) {
frame.dispose();
return null;
}
return frame;
}
Future<ImmutableBuffer?> _getFile() async {
final stopwatch = Stopwatch()..start();
final localId = widget.localId;
if (localId != null) {
try {
final data =
await thumbnailApi.getThumbnail(localId, width: 256, height: 256);
stopwatch.stop();
log.info(
'Retrieved local image $localId in ${stopwatch.elapsedMilliseconds.toStringAsFixed(2)} ms',
);
return ImmutableBuffer.fromUint8List(data);
} catch (e) {
log.warning('Failed to retrieve local image $localId: $e');
}
}
final remoteId = widget.remoteId;
if (remoteId != null) {
final uri = getThumbnailUrlForRemoteId(remoteId);
final headers = ApiService.getRequestHeaders();
final stream = _imageCache.getFileStream(
uri,
key: uri,
withProgress: true,
headers: headers,
);
await for (final result in stream) {
if (!mounted) {
return null;
}
if (result is FileInfo) {
stopwatch.stop();
log.info(
'Retrieved remote image $remoteId in ${stopwatch.elapsedMilliseconds.toStringAsFixed(2)} ms',
);
return ImmutableBuffer.fromFilePath(result.file.path);
}
}
}
return null;
}
@override
Widget build(BuildContext context) {
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
return OctoImage.fromSet(
image: provider,
octoSet: OctoSet(
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset),
),
fadeOutDuration: const Duration(milliseconds: 100),
fadeInDuration: Duration.zero,
width: size.width,
height: size.height,
fit: fit,
placeholderFadeInDuration: Duration.zero,
final colorScheme = context.colorScheme;
final gradient = _gradientCache[colorScheme] ??= LinearGradient(
colors: [
colorScheme.surfaceContainer,
colorScheme.surfaceContainer.darken(amount: .1),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
return _ThumbhashLeaf(
image: _image,
fit: widget.fit,
placeholderGradient: gradient,
);
}
@override
void dispose() {
_image?.dispose();
super.dispose();
}
}
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) {
return (context) => thumbHash == null
? const ThumbnailPlaceholder()
: FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(),
image: ThumbHashProvider(thumbHash: thumbHash),
fit: fit ?? BoxFit.cover,
);
class _ThumbhashLeaf extends LeafRenderObjectWidget {
final ui.Image? image;
final BoxFit fit;
final Gradient placeholderGradient;
const _ThumbhashLeaf({
required this.image,
required this.fit,
required this.placeholderGradient,
});
@override
RenderObject createRenderObject(BuildContext context) {
return _ThumbhashRenderBox(
image: image,
fit: fit,
placeholderGradient: placeholderGradient,
);
}
@override
void updateRenderObject(
BuildContext context,
_ThumbhashRenderBox renderObject,
) {
renderObject.fit = fit;
renderObject.image = image;
renderObject.placeholderGradient = placeholderGradient;
}
}
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) =>
(context, e, s) {
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
provider?.evict();
return Stack(
alignment: Alignment.center,
children: [
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
],
);
};
class _ThumbhashRenderBox extends RenderBox {
ui.Image? _image;
BoxFit _fit;
Gradient _placeholderGradient;
@override
bool isRepaintBoundary = true;
_ThumbhashRenderBox({
required ui.Image? image,
required BoxFit fit,
required Gradient placeholderGradient,
}) : _image = image,
_fit = fit,
_placeholderGradient = placeholderGradient;
@override
void paint(PaintingContext context, Offset offset) {
final image = _image;
final rect = offset & size;
if (image == null) {
final paint = Paint();
paint.shader = _placeholderGradient.createShader(rect);
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) {
return;
}
_fit = value;
if (_image != null) {
markNeedsPaint();
}
}
set placeholderGradient(Gradient value) {
if (_placeholderGradient == value) {
return;
}
_placeholderGradient = value;
if (_image == null) {
markNeedsPaint();
}
}
}
@@ -21,7 +21,7 @@ class ThumbnailTile extends ConsumerWidget {
super.key,
});
final BaseAsset asset;
final BaseAsset? asset;
final Size size;
final BoxFit fit;
final bool? showStorageIndicator;
@@ -30,6 +30,7 @@ class ThumbnailTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final assetContainerColor = context.isDarkTheme
@@ -71,8 +72,8 @@ class ThumbnailTile extends ConsumerWidget {
children: [
Positioned.fill(
child: Hero(
tag: '${asset.heroTag}_$heroIndex',
child: Thumbnail(asset: asset, fit: fit, size: size),
tag: '${asset?.heroTag ?? ''}_$heroIndex',
child: Thumbnail.fromBaseAsset(asset: asset, fit: fit, size: size),
),
),
if (hasStack)
@@ -83,7 +84,7 @@ class ThumbnailTile extends ConsumerWidget {
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
),
),
if (asset.isVideo)
if (asset != null && asset.isVideo)
Align(
alignment: Alignment.topRight,
child: Padding(
@@ -91,7 +92,7 @@ class ThumbnailTile extends ConsumerWidget {
child: _VideoIndicator(asset.duration),
),
),
if (storageIndicator)
if (storageIndicator && asset != null)
switch (asset.storage) {
AssetState.local => const Align(
alignment: Alignment.bottomRight,
@@ -115,7 +116,7 @@ class ThumbnailTile extends ConsumerWidget {
),
),
},
if (asset.isFavorite)
if (asset != null && asset.isFavorite)
const Align(
alignment: Alignment.bottomLeft,
child: Padding(