thumbhash improvements
thumbhash render box refactor wip rebase
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user