fullscreen: stream image in chunks instead of reading and sending all at once

This commit is contained in:
Thibault Deckers 2020-04-21 17:19:52 +09:00
parent 6b299f6c86
commit b228fcf55d
9 changed files with 177 additions and 106 deletions

View file

@ -62,4 +62,6 @@ dependencies {
testImplementation 'junit:junit:4.13' testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
compileOnly rootProject.findProject(':streams_channel')
} }

View file

@ -8,9 +8,11 @@ import android.util.Log;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import app.loup.streams_channel.StreamsChannel;
import deckers.thibault.aves.channelhandlers.AppAdapterHandler; import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
import deckers.thibault.aves.channelhandlers.FileAdapterHandler; import deckers.thibault.aves.channelhandlers.FileAdapterHandler;
import deckers.thibault.aves.channelhandlers.ImageFileHandler; import deckers.thibault.aves.channelhandlers.ImageFileHandler;
import deckers.thibault.aves.channelhandlers.ImageStreamHandler;
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler; import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
import deckers.thibault.aves.channelhandlers.MetadataHandler; import deckers.thibault.aves.channelhandlers.MetadataHandler;
import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Constants;
@ -46,6 +48,9 @@ public class MainActivity extends FlutterActivity {
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
final StreamsChannel imageStreamChannel = new StreamsChannel(messenger, ImageStreamHandler.CHANNEL);
imageStreamChannel.setStreamHandlerFactory(arguments -> new ImageStreamHandler(this, arguments));
new MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler( new MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler(
(call, result) -> { (call, result) -> {
if (call.method.contentEquals("getSharedEntry")) { if (call.method.contentEquals("getSharedEntry")) {

View file

@ -1,32 +1,17 @@
package deckers.thibault.aves.channelhandlers; package deckers.thibault.aves.channelhandlers;
import android.app.Activity; import android.app.Activity;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.graphics.ImageDecoder;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import androidx.annotation.NonNull; 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 java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.model.provider.ImageProvider; import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory; import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel;
@ -51,9 +36,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
case "getImageEntry": case "getImageEntry":
new Thread(() -> getImageEntry(call, new MethodResultWrapper(result))).start(); new Thread(() -> getImageEntry(call, new MethodResultWrapper(result))).start();
break; break;
case "getImage":
new Thread(() -> getImage(call, new MethodResultWrapper(result))).start();
break;
case "getThumbnail": case "getThumbnail":
new Thread(() -> getThumbnail(call, new MethodResultWrapper(result))).start(); new Thread(() -> getThumbnail(call, new MethodResultWrapper(result))).start();
break; break;
@ -72,71 +54,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
} }
private void getImage(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
String uriString = call.argument("uri");
Uri uri = Uri.parse(uriString);
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();
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
try {
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-decode-exception", "failed to decode image from uri=" + uri, e.getMessage());
return;
}
} else {
try (InputStream is = cr.openInputStream(uri)) {
if (is != null) {
data = getBytes(is);
}
} catch (IOException e) {
result.error("getImage-read-exception", "failed to get image from uri=" + uri, e.getMessage());
return;
}
}
}
if (data != null) {
result.success(data);
} else {
result.error("getImage-null", "failed to get image from uri=" + uri, null);
}
}
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map entryMap = call.argument("entry"); Map entryMap = call.argument("entry");
Integer width = call.argument("width"); Integer width = call.argument("width");
@ -261,19 +178,4 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
} }
}); });
} }
// convenience methods
// InputStream.readAllBytes is only available from Java 9+
private byte[] getBytes(InputStream inputStream) throws IOException {
ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int len;
while ((len = inputStream.read(buffer)) != -1) {
byteBuffer.write(buffer, 0, len);
}
return byteBuffer.toByteArray();
}
} }

View file

@ -0,0 +1,135 @@
package deckers.thibault.aves.channelhandlers;
import android.app.Activity;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.graphics.ImageDecoder;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
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.utils.MimeTypes;
import io.flutter.plugin.common.EventChannel;
public class ImageStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/imagestream";
private Activity activity;
private Uri uri;
private String mimeType;
private EventChannel.EventSink eventSink;
private Handler handler;
public ImageStreamHandler(Activity activity, Object arguments) {
this.activity = activity;
if (arguments instanceof Map) {
Map argMap = (Map) arguments;
this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri"));
}
}
@Override
public void onListen(Object o, final EventChannel.EventSink eventSink) {
this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper());
new Thread(this::getImage).start();
}
@Override
public void onCancel(Object o) {
}
private void success(final byte[] bytes) {
handler.post(() -> eventSink.success(bytes));
}
private void error(final String errorCode, final String errorMessage, final Object errorDetails) {
handler.post(() -> eventSink.error(errorCode, errorMessage, errorDetails));
}
private void endOfStream() {
handler.post(() -> eventSink.endOfStream());
}
private void getImage() {
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);
success(stream.toByteArray());
} else {
error("getImage-video-null", "failed to get image from uri=" + uri, null);
}
} catch (Exception e) {
error("getImage-video-exception", "failed to get image from uri=" + uri, e.getMessage());
return;
}
Glide.with(activity).clear(target);
} else {
ContentResolver cr = activity.getContentResolver();
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
try {
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);
success(stream.toByteArray());
} catch (IOException e) {
error("getImage-image-decode-exception", "failed to decode image from uri=" + uri, e.getMessage());
}
} else {
try (InputStream is = cr.openInputStream(uri)) {
if (is != null) {
streamBytes(is);
} else {
error("getImage-image-read-null", "failed to get image from uri=" + uri, null);
}
} catch (IOException e) {
error("getImage-image-read-exception", "failed to get image from uri=" + uri, e.getMessage());
}
}
}
endOfStream();
}
private void streamBytes(InputStream inputStream) throws IOException {
int bufferSize = 2 << 17; // ~250k
byte[] buffer = new byte[bufferSize];
int len;
while ((len = inputStream.read(buffer)) != -1) {
// cannot decode image on Flutter side when using `buffer` directly...
byte[] sub = new byte[len];
System.arraycopy(buffer, 0, sub, 0, len);
success(sub);
}
}
}

View file

@ -1,12 +1,16 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart';
class ImageFileService { class ImageFileService {
static const platform = MethodChannel('deckers.thibault/aves/image'); static const platform = MethodChannel('deckers.thibault/aves/image');
static final StreamsChannel streamsChannel = StreamsChannel('deckers.thibault/aves/imagestream');
static Future<void> getImageEntries() async { static Future<void> getImageEntries() async {
try { try {
@ -30,24 +34,30 @@ class ImageFileService {
return null; return null;
} }
static Future<Uint8List> getImage(String uri, String mimeType) async { static Future<Uint8List> getImage(String uri, String mimeType) {
try { try {
final result = await platform.invokeMethod('getImage', <String, dynamic>{ final completer = Completer<Uint8List>();
final bytesBuilder = BytesBuilder(copy: false);
streamsChannel.receiveBroadcastStream(<String, dynamic>{
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
}); }).listen(
return result as Uint8List; (data) => bytesBuilder.add(data as Uint8List),
onError: completer.completeError,
onDone: () => completer.complete(bytesBuilder.takeBytes()),
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return Uint8List(0); return Future.sync(() => Uint8List(0));
} }
static Future<Uint8List> getThumbnail(ImageEntry entry, int width, int height, {Object cancellationKey}) { static Future<Uint8List> getThumbnail(ImageEntry entry, int width, int height, {Object cancellationKey}) {
return servicePolicy.call( return servicePolicy.call(
() async { () async {
if (width > 0 && height > 0) { if (width > 0 && height > 0) {
// debugPrint('getThumbnail width=$width path=${entry.path}');
try { try {
final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{ final result = await platform.invokeMethod('getThumbnail', <String, dynamic>{
'entry': entry.toMap(), 'entry': entry.toMap(),

View file

@ -4,17 +4,19 @@ import 'package:outline_material_icons/outline_material_icons.dart';
class EmptyContent extends StatelessWidget { class EmptyContent extends StatelessWidget {
final IconData icon; final IconData icon;
final String text; final String text;
final AlignmentGeometry alignment;
const EmptyContent({ const EmptyContent({
this.icon = OMIcons.photo, this.icon = OMIcons.photo,
this.text = 'Nothing!', this.text = 'Nothing!',
this.alignment = const FractionalOffset(.5, .35),
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const color = Color(0xFF607D8B); const color = Color(0xFF607D8B);
return Align( return Align(
alignment: const FractionalOffset(.5, .35), alignment: alignment,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View file

@ -1,12 +1,14 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/album/empty.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.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/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/video_view.dart'; import 'package:aves/widgets/fullscreen/video_view.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -101,6 +103,11 @@ class ImageView extends StatelessWidget {
image: uriImage, image: uriImage,
) )
: thumbnailLoadingBuilder(context), : thumbnailLoadingBuilder(context),
loadFailedChild: EmptyContent(
icon: OMIcons.errorOutline,
text: 'Oops!',
alignment: Alignment.center,
),
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged, scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,

View file

@ -399,6 +399,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
streams_channel:
dependency: "direct main"
description:
name: streams_channel
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:

View file

@ -70,6 +70,7 @@ dependencies:
screen: screen:
shared_preferences: shared_preferences:
sqflite: sqflite:
streams_channel:
tuple: tuple:
uuid: uuid: