load video embedded thumbnail
This commit is contained in:
parent
8f94af28d3
commit
8c265b6479
15 changed files with 353 additions and 211 deletions
|
@ -3,7 +3,8 @@ package deckers.thibault.aves.channelhandlers;
|
|||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
|
|
@ -6,7 +6,8 @@ import android.app.AlertDialog;
|
|||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.provider.Settings;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.karumi.dexter.Dexter;
|
||||
import com.karumi.dexter.PermissionToken;
|
||||
|
|
|
@ -8,12 +8,15 @@ import android.util.Log;
|
|||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.Key;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.FutureTarget;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import deckers.thibault.aves.decoder.VideoThumbnail;
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
@ -61,11 +64,28 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
|
|||
if (!this.isCancelled()) {
|
||||
// add signature to ignore cache for images which got modified but kept the same URI
|
||||
Key signature = new ObjectKey("" + entry.getDateModifiedSecs() + entry.getWidth() + entry.getOrientationDegrees());
|
||||
FutureTarget<Bitmap> target = Glide.with(activity)
|
||||
RequestOptions options = new RequestOptions()
|
||||
.signature(signature)
|
||||
.override(p.width, p.height);
|
||||
|
||||
FutureTarget<Bitmap> target;
|
||||
if (entry.isVideo()) {
|
||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
|
||||
target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(new VideoThumbnail(activity, entry.getUri()))
|
||||
.signature(signature)
|
||||
.submit(p.width, p.height);
|
||||
} else {
|
||||
target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(entry.getUri())
|
||||
.signature(signature)
|
||||
.submit(p.width, p.height);
|
||||
}
|
||||
|
||||
try {
|
||||
Bitmap bmp = target.get();
|
||||
if (bmp != null) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channelhandlers;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.adobe.xmp.XMPException;
|
||||
import com.adobe.xmp.XMPIterator;
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package deckers.thibault.aves.decoder;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
public class VideoThumbnail {
|
||||
private Context mContext;
|
||||
private Uri mUri;
|
||||
|
||||
public VideoThumbnail(Context context, Uri uri) {
|
||||
mContext = context;
|
||||
mUri = uri;
|
||||
}
|
||||
|
||||
public Context getContext() {
|
||||
return mContext;
|
||||
}
|
||||
|
||||
Uri getUri() {
|
||||
return mUri;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package deckers.thibault.aves.decoder;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Priority;
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.data.DataFetcher;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
class VideoThumbnailFetcher implements DataFetcher<InputStream> {
|
||||
private final VideoThumbnail model;
|
||||
|
||||
VideoThumbnailFetcher(VideoThumbnail model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
|
||||
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
||||
try {
|
||||
retriever.setDataSource(model.getContext(), model.getUri());
|
||||
byte[] picture = retriever.getEmbeddedPicture();
|
||||
if (picture != null) {
|
||||
callback.onDataReady(new ByteArrayInputStream(picture));
|
||||
} else {
|
||||
// not ideal: bitmap -> byte[] -> bitmap
|
||||
// but simple fallback and we cache result
|
||||
Bitmap bitmap = retriever.getFrameAtTime();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 0, bos);
|
||||
callback.onDataReady(new ByteArrayInputStream(bos.toByteArray()));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
callback.onLoadFailed(ex);
|
||||
} finally {
|
||||
retriever.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel() {
|
||||
// cannot cancel
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Class<InputStream> getDataClass() {
|
||||
return InputStream.class;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public DataSource getDataSource() {
|
||||
return DataSource.LOCAL;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package deckers.thibault.aves.decoder;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.GlideBuilder;
|
||||
import com.bumptech.glide.Registry;
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
@GlideModule
|
||||
public class VideoThumbnailGlideModule extends AppGlideModule {
|
||||
@Override
|
||||
public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
|
||||
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
|
||||
registry.append(VideoThumbnail.class, InputStream.class, new VideoThumbnailLoader.Factory());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isManifestParsingEnabled() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package deckers.thibault.aves.decoder;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.load.Options;
|
||||
import com.bumptech.glide.load.model.ModelLoader;
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory;
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
class VideoThumbnailLoader implements ModelLoader<VideoThumbnail, InputStream> {
|
||||
@Nullable
|
||||
@Override
|
||||
public LoadData<InputStream> buildLoadData(@NonNull VideoThumbnail model, int width, int height, @NonNull Options options) {
|
||||
return new LoadData<>(new ObjectKey(model.getUri()), new VideoThumbnailFetcher(model));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handles(@NonNull VideoThumbnail videoThumbnail) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static class Factory implements ModelLoaderFactory<VideoThumbnail, InputStream> {
|
||||
@NonNull
|
||||
@Override
|
||||
public ModelLoader<VideoThumbnail, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
|
||||
return new VideoThumbnailLoader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void teardown() {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
package deckers.thibault.aves.model;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.text.format.DateUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
|
@ -21,14 +21,11 @@ public class ImageEntry {
|
|||
private String title, bucketDisplayName;
|
||||
private long dateModifiedSecs, sourceDateTakenMillis;
|
||||
private long durationMillis;
|
||||
// derived
|
||||
private boolean isVideo;
|
||||
|
||||
public ImageEntry(Uri uri, String mimeType) {
|
||||
this.contentId = -1;
|
||||
this.uri = uri;
|
||||
this.mimeType = mimeType;
|
||||
init();
|
||||
}
|
||||
|
||||
// uri: content provider uri
|
||||
|
@ -48,7 +45,6 @@ public class ImageEntry {
|
|||
this.sourceDateTakenMillis = dateTakenMillis;
|
||||
this.bucketDisplayName = bucketDisplayName;
|
||||
this.durationMillis = durationMillis;
|
||||
init();
|
||||
}
|
||||
|
||||
public ImageEntry(Map map) {
|
||||
|
@ -87,10 +83,6 @@ public class ImageEntry {
|
|||
}};
|
||||
}
|
||||
|
||||
private void init() {
|
||||
isVideo = mimeType.startsWith(Constants.MIME_VIDEO);
|
||||
}
|
||||
|
||||
public Uri getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
@ -105,7 +97,7 @@ public class ImageEntry {
|
|||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return isVideo;
|
||||
return mimeType.startsWith(Constants.MIME_VIDEO);
|
||||
}
|
||||
|
||||
public boolean isEditable() {
|
||||
|
@ -132,52 +124,10 @@ public class ImageEntry {
|
|||
return orientationDegrees;
|
||||
}
|
||||
|
||||
public long getSizeBytes() {
|
||||
return sizeBytes;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public long getDateModifiedSecs() {
|
||||
return dateModifiedSecs;
|
||||
}
|
||||
|
||||
String getBucketDisplayName() {
|
||||
return bucketDisplayName;
|
||||
}
|
||||
|
||||
public String getDuration() {
|
||||
return DateUtils.formatElapsedTime(durationMillis / 1000);
|
||||
}
|
||||
|
||||
public int getMegaPixels() {
|
||||
return Math.round((width * height) / 1000000f);
|
||||
}
|
||||
|
||||
// setters
|
||||
|
||||
public void setContentId(long contentId) {
|
||||
this.contentId = contentId;
|
||||
}
|
||||
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public void setSizeBytes(long sizeBytes) {
|
||||
this.sizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
public void setDateModifiedSecs(long dateModifiedSecs) {
|
||||
this.dateModifiedSecs = dateModifiedSecs;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
// convenience method
|
||||
|
||||
private static long toLong(Object o) {
|
||||
|
|
|
@ -8,7 +8,6 @@ import android.provider.MediaStore;
|
|||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
|
|
|
@ -32,6 +32,7 @@ import android.provider.DocumentsContract;
|
|||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
|
@ -162,7 +163,9 @@ public class FileUtils {
|
|||
return success ? file.getPath() : null;
|
||||
}
|
||||
|
||||
/** @return extension of image with dot, or default .jpg if it none. */
|
||||
/**
|
||||
* @return extension of image with dot, or default .jpg if it none.
|
||||
*/
|
||||
private static String getImageExtension(Context context, Uri uriImage) {
|
||||
String extension = null;
|
||||
|
||||
|
|
|
@ -1 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
|
|
@ -16,7 +16,7 @@ class ImageDecodeService {
|
|||
}
|
||||
|
||||
static Future<Uint8List> getImageBytes(ImageEntry entry, int width, int height) async {
|
||||
debugPrint('getImageBytes with uri=${entry.uri}');
|
||||
debugPrint('getImageBytes with path=${entry.path} contentId=${entry.contentId}');
|
||||
if (width > 0 && height > 0) {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getImageBytes', <String, dynamic>{
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'mime_types.dart';
|
||||
|
||||
class ImageEntry {
|
||||
String uri;
|
||||
String path;
|
||||
|
@ -65,6 +67,8 @@ class ImageEntry {
|
|||
};
|
||||
}
|
||||
|
||||
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
|
||||
|
||||
int getMegaPixels() {
|
||||
return ((width * height) / 1000000).round();
|
||||
}
|
||||
|
|
|
@ -38,6 +38,8 @@ class InfoPageState extends State<InfoPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = entry.getBestDate();
|
||||
final dateText = '${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}';
|
||||
final resolutionText = '${entry.width} × ${entry.height}${entry.isVideo ? '': ' (${entry.getMegaPixels()} MP)'}';
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
|
@ -72,8 +74,8 @@ class InfoPageState extends State<InfoPage> {
|
|||
children: [
|
||||
SectionRow('File'),
|
||||
InfoRow('Title', entry.title),
|
||||
InfoRow('Date', '${DateFormat.yMMMd().format(date)} – ${DateFormat.Hm().format(date)}'),
|
||||
InfoRow('Resolution', '${entry.width} × ${entry.height} (${entry.getMegaPixels()} MP)'),
|
||||
InfoRow('Date', dateText),
|
||||
InfoRow('Resolution', resolutionText),
|
||||
InfoRow('Size', formatFilesize(entry.sizeBytes)),
|
||||
InfoRow('Path', entry.path),
|
||||
SectionRow('Metadata'),
|
||||
|
|
Loading…
Reference in a new issue