diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java index 768649cfb..cd8fd443a 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageDecodeTask.java @@ -74,7 +74,7 @@ public class ImageDecodeTask extends AsyncTask getImageEntry(call, new MethodResultWrapper(result))).start(); break; - case "readAsBytes": - new Thread(() -> readAsBytes(call, new MethodResultWrapper(result))).start(); + case "getImage": + new Thread(() -> getImage(call, new MethodResultWrapper(result))).start(); break; - case "getImageBytes": - new Thread(() -> getImageBytes(call, new MethodResultWrapper(result))).start(); + case "getThumbnail": + new Thread(() -> getThumbnail(call, new MethodResultWrapper(result))).start(); break; - case "cancelGetImageBytes": - new Thread(() -> cancelGetImageBytes(call, new MethodResultWrapper(result))).start(); + case "cancelGetThumbnail": + new Thread(() -> cancelGetThumbnail(call, new MethodResultWrapper(result))).start(); break; case "delete": new Thread(() -> delete(call, new MethodResultWrapper(result))).start(); @@ -70,52 +77,78 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { } } - private void readAsBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + private void getImage(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { String mimeType = call.argument("mimeType"); String uriString = call.argument("uri"); - byte[] data = null; - ContentResolver cr = activity.getContentResolver(); Uri uri = Uri.parse(uriString); - try (InputStream is = cr.openInputStream(uri)) { - if (is != null) { - data = getBytes(is); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && (MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType))) { - // as of Flutter v1.15.17, Dart Image.memory cannot decode HEIF/HEIC images - // so we convert the image using Android native decoder - ImageDecoder.Source source = ImageDecoder.createSource(cr, uri); - Bitmap bitmap = ImageDecoder.decodeBitmap(source); + + byte[] data = null; + if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) { + RequestOptions options = new RequestOptions() + .diskCacheStrategy(DiskCacheStrategy.RESOURCE); + FutureTarget target = Glide.with(activity) + .asBitmap() + .apply(options) + .load(new VideoThumbnail(activity, uri)) + .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL); + try { + Bitmap bitmap = target.get(); + if (bitmap != null) { ByteArrayOutputStream stream = new ByteArrayOutputStream(); // we compress the bitmap because Dart Image.memory cannot decode the raw bytes // Bitmap.CompressFormat.PNG is slower than JPEG bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); data = stream.toByteArray(); } + } catch (Exception e) { + result.error("getImage-video-exception", "failed to get image from uri=" + uri, e.getMessage()); + return; + } + Glide.with(activity).clear(target); + } else { + ContentResolver cr = activity.getContentResolver(); + try (InputStream is = cr.openInputStream(uri)) { + if (is != null) { + data = getBytes(is); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && (MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType))) { + // as of Flutter v1.15.17, Dart Image.memory cannot decode HEIF/HEIC images + // so we convert the image using Android native decoder + ImageDecoder.Source source = ImageDecoder.createSource(cr, uri); + Bitmap bitmap = ImageDecoder.decodeBitmap(source); + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + // we compress the bitmap because Dart Image.memory cannot decode the raw bytes + // Bitmap.CompressFormat.PNG is slower than JPEG + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream); + data = stream.toByteArray(); + } + } + } catch (IOException e) { + result.error("getImage-image-exception", "failed to get image from uri=" + uri, e.getMessage()); + return; } - } catch (IOException ex) { - // ignore } if (data != null) { result.success(data); } else { - result.error("readAsBytes-null", "failed to read bytes from uri=" + uri, null); + result.error("getImage-null", "failed to get image from uri=" + uri, null); } } - private void getImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { Map entryMap = call.argument("entry"); Integer width = call.argument("width"); Integer height = call.argument("height"); if (entryMap == null || width == null || height == null) { - result.error("getImageBytes-args", "failed because of missing arguments", null); + result.error("getThumbnail-args", "failed because of missing arguments", null); return; } ImageEntry entry = new ImageEntry(entryMap); imageDecodeTaskManager.fetch(result, entry, width, height); } - private void cancelGetImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + private void cancelGetThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { String uri = call.argument("uri"); imageDecodeTaskManager.cancel(uri); result.success(null); diff --git a/lib/model/image_file_service.dart b/lib/model/image_file_service.dart index 52f5484fc..cd1f9538a 100644 --- a/lib/model/image_file_service.dart +++ b/lib/model/image_file_service.dart @@ -29,43 +29,43 @@ class ImageFileService { return null; } - static Future readAsBytes(String uri, String mimeType) async { + static Future getImage(String uri, String mimeType) async { try { - final result = await platform.invokeMethod('readAsBytes', { + final result = await platform.invokeMethod('getImage', { 'uri': uri, 'mimeType': mimeType, }); return result as Uint8List; } on PlatformException catch (e) { - debugPrint('readAsBytes failed with exception=${e.message}'); + debugPrint('getImage failed with exception=${e.message}'); } return Uint8List(0); } - static Future getImageBytes(ImageEntry entry, int width, int height) async { + static Future getThumbnail(ImageEntry entry, int width, int height) async { if (width > 0 && height > 0) { -// debugPrint('getImageBytes width=$width path=${entry.path}'); +// debugPrint('getThumbnail width=$width path=${entry.path}'); try { - final result = await platform.invokeMethod('getImageBytes', { + final result = await platform.invokeMethod('getThumbnail', { 'entry': entry.toMap(), 'width': width, 'height': height, }); return result as Uint8List; } on PlatformException catch (e) { - debugPrint('getImageBytes failed with exception=${e.message}'); + debugPrint('getThumbnail failed with exception=${e.message}'); } } return Uint8List(0); } - static Future cancelGetImageBytes(String uri) async { + static Future cancelGetThumbnail(String uri) async { try { - await platform.invokeMethod('cancelGetImageBytes', { + await platform.invokeMethod('cancelGetThumbnail', { 'uri': uri, }); } on PlatformException catch (e) { - debugPrint('cancelGetImageBytes failed with exception=${e.message}'); + debugPrint('cancelGetThumbnail failed with exception=${e.message}'); } } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 139fa203e..21341ec36 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -6,6 +6,9 @@ class Constants { // so we give it a `strutStyle` with a slightly larger height static const overflowStrutStyle = StrutStyle(height: 1.3); + // TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling + static const double thumbnailCacheExtent = 50; + static const svgBackground = Colors.white; static const svgColorFilter = ColorFilter.mode(svgBackground, BlendMode.dstOver); } diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index 6a0df071e..c5b77e3ee 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/icons.dart'; -import 'package:aves/widgets/common/image_preview.dart'; +import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -48,33 +48,31 @@ class Thumbnail extends StatelessWidget { } Widget _buildRasterImage() { - return ImagePreview( - entry: entry, - // TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling - width: 50, - height: 50, - builder: (bytes) { - final imageBuilder = (bytes, dim) => Image.memory( - bytes, - width: dim, - height: dim, - fit: BoxFit.cover, - ); - return heroTag == null - ? imageBuilder(bytes, extent) - : Hero( - tag: heroTag, - flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) { - // use LayoutBuilder only during hero animation - return LayoutBuilder(builder: (context, constraints) { - final dim = min(constraints.maxWidth, constraints.maxHeight); - return imageBuilder(bytes, dim); - }); - }, - child: imageBuilder(bytes, extent), - ); - }, + final provider = ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent); + final image = Image( + image: provider, + width: extent, + height: extent, + fit: BoxFit.cover, ); + return heroTag == null + ? image + : Hero( + tag: heroTag, + flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) { + // use LayoutBuilder only during hero animation + return LayoutBuilder(builder: (context, constraints) { + final dim = min(constraints.maxWidth, constraints.maxHeight); + return Image( + image: provider, + width: dim, + height: dim, + fit: BoxFit.cover, + ); + }); + }, + child: image, + ); } Widget _buildVectorImage() { diff --git a/lib/widgets/common/image_preview.dart b/lib/widgets/common/image_preview.dart deleted file mode 100644 index 5b88c1567..000000000 --- a/lib/widgets/common/image_preview.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:typed_data'; - -import 'package:after_init/after_init.dart'; -import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/image_file_service.dart'; -import 'package:flutter/material.dart'; -import 'package:outline_material_icons/outline_material_icons.dart'; -import 'package:provider/provider.dart'; -import 'package:transparent_image/transparent_image.dart'; - -class ImagePreview extends StatefulWidget { - final ImageEntry entry; - final double width, height; - final Widget Function(Uint8List bytes) builder; - - const ImagePreview({ - Key key, - @required this.entry, - @required this.width, - @required this.height, - @required this.builder, - }) : super(key: key); - - @override - State createState() => ImagePreviewState(); -} - -class ImagePreviewState extends State with AfterInitMixin { - Future _byteLoader; - Listenable _entryChangeNotifier; - double _devicePixelRatio; - - ImageEntry get entry => widget.entry; - - String get uri => widget.entry.uri; - - @override - void initState() { -// debugPrint('$runtimeType initState path=${entry.path}'); - super.initState(); - _entryChangeNotifier = Listenable.merge([ - entry.imageChangeNotifier, - entry.metadataChangeNotifier, - ]); - _entryChangeNotifier.addListener(_onEntryChange); - } - - @override - void didInitState() { - _devicePixelRatio = Provider.of(context, listen: false).devicePixelRatio; - _initByteLoader(); - } - - @override - void didUpdateWidget(ImagePreview old) { -// debugPrint('$runtimeType didUpdateWidget from=${old.entry.path} to=${entry.path}'); - super.didUpdateWidget(old); - if (widget.width == old.width && widget.height == old.height && uri == old.entry.uri && entry.width == old.entry.width && entry.height == old.entry.height && entry.orientationDegrees == old.entry.orientationDegrees) return; - _initByteLoader(); - } - - void _initByteLoader() { - final width = (widget.width * _devicePixelRatio).round(); - final height = (widget.height * _devicePixelRatio).round(); - _byteLoader = ImageFileService.getImageBytes(entry, width, height); - } - - void _onEntryChange() => setState(() => _initByteLoader()); - - @override - void dispose() { -// debugPrint('$runtimeType dispose path=${entry.path}'); - _entryChangeNotifier.removeListener(_onEntryChange); - super.dispose(); - } - - @override - Widget build(BuildContext context) { -// debugPrint('$runtimeType build path=${entry.path}'); - return FutureBuilder( - future: _byteLoader, - builder: (futureContext, AsyncSnapshot snapshot) { - final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; - return bytes.isNotEmpty - ? widget.builder(bytes) - : Center( - child: Icon( - OMIcons.error, - color: Colors.blueGrey, - ), - ); - }); - } -} diff --git a/lib/widgets/common/image_providers/app_icon_image_provider.dart b/lib/widgets/common/image_providers/app_icon_image_provider.dart index 3b738ec94..dae957ca8 100644 --- a/lib/widgets/common/image_providers/app_icon_image_provider.dart +++ b/lib/widgets/common/image_providers/app_icon_image_provider.dart @@ -32,7 +32,7 @@ class AppIconImage extends ImageProvider { codec: _loadAsync(key, decode), scale: key.scale, informationCollector: () sync* { - yield ErrorDescription('uri=$packageName, size=$size'); + yield ErrorDescription('packageName=$packageName, size=$size'); }, ); } diff --git a/lib/widgets/common/image_providers/thumbnail_provider.dart b/lib/widgets/common/image_providers/thumbnail_provider.dart new file mode 100644 index 000000000..47468275b --- /dev/null +++ b/lib/widgets/common/image_providers/thumbnail_provider.dart @@ -0,0 +1,78 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui show Codec; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/image_file_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ThumbnailProvider extends ImageProvider { + const ThumbnailProvider({ + @required this.entry, + @required this.extent, + this.scale = 1.0, + }) : assert(entry != null), + assert(extent != null), + assert(scale != null); + + final ImageEntry entry; + final double extent; + final double scale; + + @override + Future obtainKey(ImageConfiguration configuration) { + // configuration can be empty (e.g. when obtaining key for eviction) + // so we do not compute the target width/height here + // and pass it to the key, to use it later for image loading + return SynchronousFuture(ThumbnailProviderKey( + entry: entry, + extent: extent, + devicePixelRatio: configuration.devicePixelRatio, + scale: scale, + )); + } + + @override + ImageStreamCompleter load(ThumbnailProviderKey key, DecoderCallback decode) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: key.scale, + informationCollector: () sync* { + yield ErrorDescription('uri=${entry.uri}, extent=$extent'); + }, + ); + } + + Future _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async { + final dimPixels = (extent * key.devicePixelRatio).round(); + final Uint8List bytes = await ImageFileService.getThumbnail(key.entry, dimPixels, dimPixels); + if (bytes.lengthInBytes == 0) { + return null; + } + + return await decode(bytes); + } +} + +class ThumbnailProviderKey { + final ImageEntry entry; + final double extent; + final double devicePixelRatio; // do not include configuration in key hashcode or == operator + final double scale; + + const ThumbnailProviderKey({ + @required this.entry, + @required this.extent, + @required this.devicePixelRatio, + this.scale, + }); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is ThumbnailProviderKey && other.entry.uri == entry.uri && other.extent == extent && other.scale == scale; + } + + @override + int get hashCode => hashValues(entry.uri, extent, scale); +} diff --git a/lib/widgets/common/image_providers/uri_image_provider.dart b/lib/widgets/common/image_providers/uri_image_provider.dart index ce873626a..cf23596d7 100644 --- a/lib/widgets/common/image_providers/uri_image_provider.dart +++ b/lib/widgets/common/image_providers/uri_image_provider.dart @@ -36,7 +36,7 @@ class UriImage extends ImageProvider { Future _loadAsync(UriImage key, DecoderCallback decode) async { assert(key == this); - final Uint8List bytes = await ImageFileService.readAsBytes(uri, mimeType); + final Uint8List bytes = await ImageFileService.getImage(uri, mimeType); if (bytes.lengthInBytes == 0) { return null; } diff --git a/lib/widgets/common/image_providers/uri_picture_provider.dart b/lib/widgets/common/image_providers/uri_picture_provider.dart index 93cfcc516..2b0197d2c 100644 --- a/lib/widgets/common/image_providers/uri_picture_provider.dart +++ b/lib/widgets/common/image_providers/uri_picture_provider.dart @@ -30,7 +30,7 @@ class UriPicture extends PictureProvider { Future _loadAsync(UriPicture key, {PictureErrorListener onError}) async { assert(key == this); - final data = await ImageFileService.readAsBytes(uri, mimeType); + final data = await ImageFileService.getImage(uri, mimeType); if (data == null || data.isEmpty) { return null; } diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index ded410e6a..2f98a4915 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -3,6 +3,8 @@ import 'dart:math'; import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart'; import 'package:aves/widgets/fullscreen/image_page.dart'; @@ -418,10 +420,8 @@ class _FullscreenVerticalPageViewState extends State } void _onImageChange() async { - await UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - ).evict(); + await UriImage(uri: entry.uri, mimeType: entry.mimeType).evict(); + await ThumbnailProvider(entry: entry, extent: Constants.thumbnailCacheExtent).evict(); if (entry.path != null) await FileImage(File(entry.path)).evict(); // rebuild to refresh the Image inside ImagePage setState(() {}); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 5108ee412..8eefe80e1 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -2,7 +2,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; -import 'package:aves/widgets/fullscreen/video.dart'; +import 'package:aves/widgets/fullscreen/video_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_view/photo_view.dart'; @@ -84,10 +84,7 @@ class ImageView extends StatelessWidget { return PhotoView( // key includes size and orientation to refresh when the image is rotated key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'), - imageProvider: UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - ), + imageProvider: UriImage(uri: entry.uri, mimeType: entry.mimeType), loadingBuilder: (context, event) => placeholderBuilder(context), backgroundDecoration: backgroundDecoration, heroAttributes: heroAttributes, diff --git a/lib/widgets/fullscreen/video.dart b/lib/widgets/fullscreen/video_view.dart similarity index 76% rename from lib/widgets/fullscreen/video.dart rename to lib/widgets/fullscreen/video_view.dart index 2e12a2050..39661e455 100644 --- a/lib/widgets/fullscreen/video.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -1,10 +1,8 @@ -import 'dart:math'; import 'dart:ui'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/common/image_preview.dart'; +import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import 'package:video_player/video_player.dart'; class AvesVideo extends StatefulWidget { @@ -58,17 +56,10 @@ class AvesVideoState extends State { Widget build(BuildContext context) { if (value == null) return const SizedBox(); if (value.hasError) { - return Selector( - selector: (c, mq) => mq.size.width, - builder: (c, mqWidth, child) { - final width = min(mqWidth, entry.width.toDouble()); - return ImagePreview( - entry: entry, - width: width, - height: width / entry.aspectRatio, - builder: (bytes) => Image.memory(bytes), - ); - }, + return Image( + image: UriImage(uri: entry.uri, mimeType: entry.mimeType), + width: entry.width.toDouble(), + height: entry.height.toDouble(), ); } return Center(