added thumbnail image provider, clarified image service, get unreadable video preview by Glide

This commit is contained in:
Thibault Deckers 2020-03-27 16:41:03 +09:00
parent 0cedb70666
commit fe0440f265
13 changed files with 190 additions and 184 deletions

View file

@ -74,7 +74,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
bitmap = getThumbnailBytesByMediaStore(p);
}
} else {
Log.d(LOG_TAG, "getImageBytes with uri=" + p.entry.uri + " cancelled");
Log.d(LOG_TAG, "getThumbnail with uri=" + p.entry.uri + " cancelled");
}
byte[] data = null;
if (bitmap != null) {
@ -119,7 +119,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
return null;
}
private Bitmap getImageBytesByGlide(Params params) {
private Bitmap getThumbnailByGlide(Params params) {
ImageEntry entry = params.entry;
int width = params.width;
int height = params.height;
@ -151,7 +151,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
try {
return target.get();
} catch (InterruptedException e) {
Log.d(LOG_TAG, "getImageBytes with uri=" + entry.uri + " interrupted");
Log.d(LOG_TAG, "getThumbnail with uri=" + entry.uri + " interrupted");
} catch (Exception e) {
e.printStackTrace();
}
@ -167,7 +167,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
if (result.data != null) {
r.success(result.data);
} else {
r.error("getImageBytes-null", "failed to get thumbnail for uri=" + uri, null);
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri, null);
}
}
}

View file

@ -11,11 +11,18 @@ import android.os.Looper;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.Target;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
@ -46,14 +53,14 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "getImageEntry":
new Thread(() -> 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<Bitmap> 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);

View file

@ -29,43 +29,43 @@ class ImageFileService {
return null;
}
static Future<Uint8List> readAsBytes(String uri, String mimeType) async {
static Future<Uint8List> getImage(String uri, String mimeType) async {
try {
final result = await platform.invokeMethod('readAsBytes', <String, dynamic>{
final result = await platform.invokeMethod('getImage', <String, dynamic>{
'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<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async {
static Future<Uint8List> 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', <String, dynamic>{
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'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<void> cancelGetImageBytes(String uri) async {
static Future<void> cancelGetThumbnail(String uri) async {
try {
await platform.invokeMethod('cancelGetImageBytes', <String, dynamic>{
await platform.invokeMethod('cancelGetThumbnail', <String, dynamic>{
'uri': uri,
});
} on PlatformException catch (e) {
debugPrint('cancelGetImageBytes failed with exception=${e.message}');
debugPrint('cancelGetThumbnail failed with exception=${e.message}');
}
}

View file

@ -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);
}

View file

@ -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() {

View file

@ -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<StatefulWidget> createState() => ImagePreviewState();
}
class ImagePreviewState extends State<ImagePreview> with AfterInitMixin {
Future<Uint8List> _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<MediaQueryData>(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<Uint8List> 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,
),
);
});
}
}

View file

@ -32,7 +32,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: () sync* {
yield ErrorDescription('uri=$packageName, size=$size');
yield ErrorDescription('packageName=$packageName, size=$size');
},
);
}

View file

@ -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<ThumbnailProviderKey> {
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<ThumbnailProviderKey> 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>(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<ui.Codec> _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);
}

View file

@ -36,7 +36,7 @@ class UriImage extends ImageProvider<UriImage> {
Future<ui.Codec> _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;
}

View file

@ -30,7 +30,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _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;
}

View file

@ -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<FullscreenVerticalPageView>
}
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(() {});

View file

@ -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,

View file

@ -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<AvesVideo> {
Widget build(BuildContext context) {
if (value == null) return const SizedBox();
if (value.hasError) {
return Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) {
final width = min<double>(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(