From ae413dd82c5af17f33e5183c8ae82c8e6bfd365c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 8 Oct 2020 14:51:43 +0900 Subject: [PATCH] rotate/flip improvements (WIP) --- .../aves/channel/calls/ImageDecodeTask.java | 4 +- .../aves/channel/calls/MetadataHandler.java | 193 +++++++++-------- .../streams/ImageByteStreamHandler.java | 17 +- .../aves/model/provider/ImageProvider.java | 4 +- .../provider/MediaStoreImageProvider.java | 17 +- .../thibault/aves/model/AvesImageEntry.kt | 2 +- .../thibault/aves/model/SourceImageEntry.kt | 197 +++++++----------- .../aves/utils/ExifInterfaceHelper.kt | 52 +++-- .../utils/MediaMetadataRetrieverHelper.kt | 98 +++++---- .../aves/utils/MetadataExtractorHelper.kt | 20 ++ .../deckers/thibault/aves/utils/MimeTypes.kt | 113 ++++++---- lib/model/image_entry.dart | 25 ++- lib/model/image_metadata.dart | 27 +-- lib/model/metadata_db.dart | 33 ++- lib/services/metadata_service.dart | 4 +- lib/widgets/fullscreen/debug.dart | 47 +++-- .../fullscreen/info/basic_section.dart | 2 +- lib/widgets/fullscreen/video_view.dart | 2 +- 18 files changed, 477 insertions(+), 380 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java index de8e1e2a5..871c73e95 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java @@ -131,7 +131,7 @@ public class ImageDecodeTask extends AsyncTask videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri); if (!videoDir.isEmpty()) { metadataMap.put("Video", videoDir); @@ -277,8 +272,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { Uri uri = Uri.parse(call.argument("uri")); String extension = call.argument("extension"); - Map metadataMap = new HashMap<>(getCatalogMetadataByImageMetadataReader(uri, mimeType, extension)); - if (isVideo(mimeType)) { + Map metadataMap = new HashMap<>(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension)); + if (MimeTypes.isVideo(mimeType)) { metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri)); } @@ -286,83 +281,119 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { result.success(metadataMap); } - private Map getCatalogMetadataByImageMetadataReader(Uri uri, String mimeType, String extension) { + private Map getCatalogMetadataByMetadataExtractor(Uri uri, String mimeType, String extension) { Map metadataMap = new HashMap<>(); - if (!MimeTypes.isSupportedByMetadataExtractor(mimeType)) return metadataMap; + boolean foundExif = false; - try (InputStream is = StorageUtils.openInputStream(context, uri)) { - Metadata metadata = ImageMetadataReader.readMetadata(is); + if (MimeTypes.isSupportedByMetadataExtractor(mimeType)) { + try (InputStream is = StorageUtils.openInputStream(context, uri)) { + Metadata metadata = ImageMetadataReader.readMetadata(is); - // File type - for (FileTypeDirectory dir : metadata.getDirectoriesOfType(FileTypeDirectory.class)) { - // `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`) - // the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`) - // `context.getContentResolver().getType()` sometimes return incorrect value - // `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` - if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { - String detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE); - if (detectedMimeType != null && !detectedMimeType.equals(mimeType)) { - // file extension is unreliable, but we use it as a tie breaker - String extensionMimeType = MimeTypes.getMimeTypeForExtension(extension.toLowerCase()); - if (detectedMimeType.equals(extensionMimeType)) { - metadataMap.put(KEY_MIME_TYPE, detectedMimeType); + // File type + for (FileTypeDirectory dir : metadata.getDirectoriesOfType(FileTypeDirectory.class)) { + // `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`) + // the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`) + // `context.getContentResolver().getType()` sometimes return incorrect value + // `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000` + if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) { + String detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE); + if (detectedMimeType != null && !detectedMimeType.equals(mimeType)) { + // file extension is unreliable, but we use it as a tie breaker + String extensionMimeType = MimeTypes.getMimeTypeForExtension(extension.toLowerCase()); + if (detectedMimeType.equals(extensionMimeType)) { + metadataMap.put(KEY_MIME_TYPE, detectedMimeType); + } } } } - } - // EXIF - putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); - if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { - putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME); - } - - // GPS - for (GpsDirectory dir : metadata.getDirectoriesOfType(GpsDirectory.class)) { - GeoLocation geoLocation = dir.getGeoLocation(); - if (geoLocation != null) { - metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude()); - metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude()); + // EXIF + for (ExifSubIFDDirectory dir : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) { + putDateFromTag(metadataMap, KEY_DATE_MILLIS, dir, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL); + } + for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) { + foundExif = true; + if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { + putDateFromTag(metadataMap, KEY_DATE_MILLIS, dir, ExifIFD0Directory.TAG_DATETIME); + } + if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { + int orientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION); + metadataMap.put(KEY_IS_FLIPPED, MetadataHelper.isFlippedForExifCode(orientation)); + metadataMap.put(KEY_ROTATION_DEGREES, MetadataHelper.getRotationDegreesForExifCode(orientation)); + } } - } - // XMP - for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) { - XMPMeta xmpMeta = dir.getXMPMeta(); - try { - if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME)) { - StringBuilder sb = new StringBuilder(); - int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME); - for (int i = 1; i < count + 1; i++) { - XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i); - sb.append(";").append(item.getValue()); + // GPS + for (GpsDirectory dir : metadata.getDirectoriesOfType(GpsDirectory.class)) { + GeoLocation geoLocation = dir.getGeoLocation(); + if (geoLocation != null) { + metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude()); + metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude()); + } + } + + // XMP + for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) { + XMPMeta xmpMeta = dir.getXMPMeta(); + try { + if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME)) { + StringBuilder sb = new StringBuilder(); + int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME); + for (int i = 1; i < count + 1; i++) { + XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i); + sb.append(";").append(item.getValue()); + } + metadataMap.put(KEY_XMP_SUBJECTS, sb.toString()); } - metadataMap.put(KEY_XMP_SUBJECTS, sb.toString()); - } - putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME); - if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { - putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME); - } - } catch (XMPException e) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e); - } - } - - // Animated GIF & WEBP - if (MimeTypes.GIF.equals(mimeType)) { - metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class)); - } else if (MimeTypes.WEBP.equals(mimeType)) { - for (WebpDirectory dir : metadata.getDirectoriesOfType(WebpDirectory.class)) { - if (dir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) { - metadataMap.put(KEY_IS_ANIMATED, dir.getBoolean(WebpDirectory.TAG_IS_ANIMATION)); + putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME); + if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { + putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME); + } + } catch (XMPException e) { + Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e); } } + + // Animated GIF & WEBP + if (MimeTypes.GIF.equals(mimeType)) { + metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class)); + } else if (MimeTypes.WEBP.equals(mimeType)) { + for (WebpDirectory dir : metadata.getDirectoriesOfType(WebpDirectory.class)) { + if (dir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) { + metadataMap.put(KEY_IS_ANIMATED, dir.getBoolean(WebpDirectory.TAG_IS_ANIMATION)); + } + } + } + } catch (Exception | NoClassDefFoundError e) { + Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=" + uri + ", mimeType=" + mimeType, e); } - } catch (Exception | NoClassDefFoundError e) { - Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=" + uri + ", mimeType=" + mimeType, e); } + + if (!foundExif) { + // fallback to read EXIF via ExifInterface + try (InputStream is = StorageUtils.openInputStream(context, uri)) { + ExifInterface exif = new ExifInterface(is); + + // TODO TLAD get KEY_DATE_MILLIS from ExifInterface.TAG_DATETIME_ORIGINAL/TAG_DATETIME after Kotlin migration + if (exif.hasAttribute(ExifInterface.TAG_ORIENTATION)) { + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0); + if (orientation != 0) { + metadataMap.put(KEY_IS_FLIPPED, exif.isFlipped()); + metadataMap.put(KEY_ROTATION_DEGREES, exif.getRotationDegrees()); + } + } + double[] latLong = exif.getLatLong(); + if (latLong != null && latLong.length == 2) { + metadataMap.put(KEY_LATITUDE, latLong[0]); + metadataMap.put(KEY_LONGITUDE, latLong[1]); + } + } catch (IOException e) { + Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=" + uri, e); + } + } + return metadataMap; } @@ -383,7 +414,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } } if (rotationString != null) { - metadataMap.put(KEY_VIDEO_ROTATION, Integer.parseInt(rotationString)); + metadataMap.put(KEY_ROTATION_DEGREES, Integer.parseInt(rotationString)); } if (locationString != null) { Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString); @@ -420,7 +451,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { Map metadataMap = new HashMap<>(); - if (isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) { + if (MimeTypes.isVideo(mimeType) || !MimeTypes.isSupportedByMetadataExtractor(mimeType)) { result.success(metadataMap); return; } @@ -462,9 +493,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { long id = ContentUris.parseId(uri); Uri contentUri = uri; - if (mimeType.startsWith(MimeTypes.IMAGE)) { + if (MimeTypes.isImage(mimeType)) { contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); - } else if (mimeType.startsWith(MimeTypes.VIDEO)) { + } else if (MimeTypes.isVideo(mimeType)) { contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -536,10 +567,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { try { Map metadataMap = new HashMap<>(); - for (Map.Entry kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) { - String value = retriever.extractMetadata(kv.getValue()); + for (Map.Entry kv : MediaMetadataRetrieverHelper.allKeys.entrySet()) { + String value = retriever.extractMetadata(kv.getKey()); if (value != null) { - metadataMap.put(kv.getKey(), value); + metadataMap.put(kv.getValue(), value); } } result.success(metadataMap); @@ -625,12 +656,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { // convenience methods - private static void putDateFromDirectoryTag(Map metadataMap, String key, Metadata metadata, Class dirClass, int tag) { - for (T dir : metadata.getDirectoriesOfType(dirClass)) { - putDateFromTag(metadataMap, key, dir, tag); - } - } - private static void putDateFromTag(Map metadataMap, String key, Directory dir, int tag) { if (dir.containsTag(tag)) { metadataMap.put(key, dir.getDate(tag, null, TimeZone.getDefault()).getTime()); diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java index 5c55d896b..107e7f22c 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.java @@ -16,8 +16,6 @@ import com.bumptech.glide.request.RequestOptions; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.util.Arrays; -import java.util.List; import java.util.Map; import deckers.thibault.aves.decoder.VideoThumbnail; @@ -34,17 +32,6 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler { private EventChannel.EventSink eventSink; private Handler handler; - private static final List flutterSupportedTypes = Arrays.asList( - MimeTypes.JPEG, - MimeTypes.PNG, - MimeTypes.GIF, - MimeTypes.WEBP, - MimeTypes.BMP, - MimeTypes.WBMP, - MimeTypes.ICO, - MimeTypes.SVG - ); - @SuppressWarnings("unchecked") public ImageByteStreamHandler(Activity activity, Object arguments) { this.activity = activity; @@ -84,7 +71,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler { // - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats // - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java private void getImage() { - if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) { + if (MimeTypes.isVideo(mimeType)) { RequestOptions options = new RequestOptions() .diskCacheStrategy(DiskCacheStrategy.RESOURCE); FutureTarget target = Glide.with(activity) @@ -108,7 +95,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler { } finally { Glide.with(activity).clear(target); } - } else if (!flutterSupportedTypes.contains(mimeType)) { + } else if (!MimeTypes.isSupportedByFlutter(mimeType)) { // we convert the image on platform side first, when Dart Image.memory does not support it FutureTarget target = Glide.with(activity) .asBitmap() diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 0d96a86fa..d303e7863 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -167,9 +167,9 @@ public abstract class ImageProvider { // newURI is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") contentId = ContentUris.parseId(newUri); - if (mimeType.startsWith(MimeTypes.IMAGE)) { + if (MimeTypes.isImage(mimeType)) { contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId); - } else if (mimeType.startsWith(MimeTypes.VIDEO)) { + } else if (MimeTypes.isVideo(mimeType)) { contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId); } } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 4dd00189e..156f28f81 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -85,10 +85,10 @@ public class MediaStoreImageProvider extends ImageProvider { callback.onSuccess(entry); }; NewEntryChecker alwaysValid = (contentId, dateModifiedSecs) -> true; - if (mimeType.startsWith(MimeTypes.IMAGE)) { + if (MimeTypes.isImage(mimeType)) { Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION); - } else if (mimeType.startsWith(MimeTypes.VIDEO)) { + } else if (MimeTypes.isVideo(mimeType)) { Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION); } @@ -126,10 +126,6 @@ public class MediaStoreImageProvider extends ImageProvider { int newEntryCount = 0; final String orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC"; - // it is reasonable to assume a default orientation when it is missing for videos, - // but not so for images, often containing with metadata ignored by the Media Store - final boolean needOrientation = projection == IMAGE_PROJECTION; - final boolean needDuration = projection == VIDEO_PROJECTION; try { @@ -164,18 +160,15 @@ public class MediaStoreImageProvider extends ImageProvider { int height = cursor.getInt(heightColumn); final long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0; - Integer rotationDegrees = null; // check whether the field may be `null` to distinguish it from a legitimate `0` // this can happen for specific formats (e.g. for PNG, WEBP) // or for JPEG that were not properly registered - if (orientationColumn != -1 && cursor.getType(orientationColumn) == Cursor.FIELD_TYPE_INTEGER) { - rotationDegrees = cursor.getInt(orientationColumn); - } Map entryMap = new HashMap() {{ put("uri", itemUri.toString()); put("path", path); put("sourceMimeType", mimeType); + put("sourceRotationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); put("sizeBytes", cursor.getLong(sizeColumn)); put("title", cursor.getString(titleColumn)); put("dateModifiedSecs", dateModifiedSecs); @@ -186,10 +179,8 @@ public class MediaStoreImageProvider extends ImageProvider { entryMap.put("width", width); entryMap.put("height", height); entryMap.put("durationMillis", durationMillis); - entryMap.put("rotationDegrees", rotationDegrees != null ? rotationDegrees : 0); if (((width <= 0 || height <= 0) && needSize(mimeType)) - || (rotationDegrees == null && needOrientation) || (durationMillis == 0 && needDuration)) { // some images are incorrectly registered in the Media Store, // they are valid but miss some attributes, such as width, height, orientation @@ -331,7 +322,7 @@ public class MediaStoreImageProvider extends ImageProvider { contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); String volumeName = destination.volumeNameForMediaStore; - Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? + Uri tableUrl = MimeTypes.isVideo(mimeType) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName); Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues); diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt index ccbbd280e..a9b6856e7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/AvesImageEntry.kt @@ -26,7 +26,7 @@ class AvesImageEntry(map: Map) { val dateModifiedSecs = toLong(map["dateModifiedSecs"]) val isVideo: Boolean - get() = mimeType.startsWith(MimeTypes.VIDEO) + get() = MimeTypes.isVideo(mimeType) companion object { // convenience method diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index 8c7e5cd55..c4eb7da3b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -6,7 +6,7 @@ import android.content.Context import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Build +import androidx.exifinterface.media.ExifInterface import com.drew.imaging.ImageMetadataReader import com.drew.metadata.avi.AviDirectory import com.drew.metadata.exif.ExifIFD0Directory @@ -14,29 +14,35 @@ import com.drew.metadata.jpeg.JpegDirectory import com.drew.metadata.mp4.Mp4Directory import com.drew.metadata.mp4.media.Mp4VideoDirectory import com.drew.metadata.photoshop.PsdHeaderDirectory +import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDate +import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt +import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis +import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt +import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong +import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString +import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis +import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt +import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong import deckers.thibault.aves.utils.MetadataHelper.getRotationDegreesForExifCode -import deckers.thibault.aves.utils.MetadataHelper.isFlippedForExifCode -import deckers.thibault.aves.utils.MetadataHelper.parseVideoMetadataDate import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor import deckers.thibault.aves.utils.StorageUtils import java.io.IOException -import java.util.* class SourceImageEntry { val uri: Uri // content or file URI var path: String? = null // best effort to get local path private val sourceMimeType: String - var title: String? = null + private var title: String? = null var width: Int? = null var height: Int? = null - private var rotationDegrees: Int? = null - private var isFlipped: Boolean? = null - var sizeBytes: Long? = null - var dateModifiedSecs: Long? = null + private var sourceRotationDegrees: Int? = null + private var sizeBytes: Long? = null + private var dateModifiedSecs: Long? = null private var sourceDateTakenMillis: Long? = null private var durationMillis: Long? = null + private var foundExif: Boolean = false + constructor(uri: Uri, sourceMimeType: String) { this.uri = uri this.sourceMimeType = sourceMimeType @@ -46,9 +52,9 @@ class SourceImageEntry { uri = Uri.parse(map["uri"] as String) path = map["path"] as String? sourceMimeType = map["sourceMimeType"] as String - width = map["width"] as Int - height = map["height"] as Int - rotationDegrees = map["rotationDegrees"] as Int + width = map["width"] as Int? + height = map["height"] as Int? + sourceRotationDegrees = map["sourceRotationDegrees"] as Int? sizeBytes = toLong(map["sizeBytes"]) title = map["title"] as String? dateModifiedSecs = toLong(map["dateModifiedSecs"]) @@ -70,8 +76,7 @@ class SourceImageEntry { "sourceMimeType" to sourceMimeType, "width" to width, "height" to height, - "rotationDegrees" to (rotationDegrees ?: 0), - "isFlipped" to (isFlipped ?: false), + "sourceRotationDegrees" to (sourceRotationDegrees ?: 0), "sizeBytes" to sizeBytes, "title" to title, "dateModifiedSecs" to dateModifiedSecs, @@ -99,17 +104,14 @@ class SourceImageEntry { val hasSize: Boolean get() = width ?: 0 > 0 && height ?: 0 > 0 - private val hasOrientation: Boolean - get() = rotationDegrees != null - private val hasDuration: Boolean get() = durationMillis ?: 0 > 0 private val isImage: Boolean - get() = sourceMimeType.startsWith(MimeTypes.IMAGE) + get() = MimeTypes.isImage(sourceMimeType) private val isVideo: Boolean - get() = sourceMimeType.startsWith(MimeTypes.VIDEO) + get() = MimeTypes.isVideo(sourceMimeType) val isSvg: Boolean get() = sourceMimeType == MimeTypes.SVG @@ -119,58 +121,32 @@ class SourceImageEntry { // finds: width, height, orientation/rotation, date, title, duration fun fillPreCatalogMetadata(context: Context): SourceImageEntry { if (isSvg) return this - fillByMediaMetadataRetriever(context) - if (hasSize && hasOrientation && (!isVideo || hasDuration)) return this - fillByMetadataExtractor(context) - if (hasSize) return this + if (isVideo) { + fillVideoByMediaMetadataRetriever(context) + if (hasSize && hasDuration) return this + } + if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) { + fillByMetadataExtractor(context) + if (hasSize && foundExif) return this + } + if (ExifInterface.isSupportedMimeType(sourceMimeType)) { + fillByExifInterface(context) + if (hasSize) return this + } fillByBitmapDecode(context) return this } - // expects entry with: uri, mimeType - // finds: width, height, orientation/rotation, date, title, duration - private fun fillByMediaMetadataRetriever(context: Context) { - if (isImage) return + // finds: width, height, orientation, date, duration, title + private fun fillVideoByMediaMetadataRetriever(context: Context) { val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return try { - var width: String? = null - var height: String? = null - var rotationDegrees: String? = null - var durationMillis: String? = null - if (isImage) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH) - height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT) - rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION) - } - } else if (isVideo) { - width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) - height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) - rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) - durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - } - if (width != null) { - this.width = width.toInt() - } - if (height != null) { - this.height = height.toInt() - } - if (rotationDegrees != null) { - this.rotationDegrees = rotationDegrees.toInt() - } - if (durationMillis != null) { - this.durationMillis = durationMillis.toLong() - } - val dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE) - val dateMillis = parseVideoMetadataDate(dateString) - // some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time - if (dateMillis > 0) { - sourceDateTakenMillis = dateMillis - } - val title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) - if (title != null) { - this.title = title - } + retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it } + retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it } + retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it } + retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it } + retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it } + retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it } } catch (e: Exception) { // ignore } finally { @@ -179,10 +155,8 @@ class SourceImageEntry { } } - // expects entry with: uri, mimeType - // finds: width, height, orientation, date + // finds: width, height, orientation, date, duration private fun fillByMetadataExtractor(context: Context) { - if (!isSupportedByMetadataExtractor(sourceMimeType)) return try { StorageUtils.openInputStream(context, uri).use { input -> val metadata = ImageMetadataReader.readMetadata(input) @@ -191,62 +165,36 @@ class SourceImageEntry { // (e.g. PNG registered as JPG) if (isVideo) { for (dir in metadata.getDirectoriesOfType(AviDirectory::class.java)) { - if (dir.containsTag(AviDirectory.TAG_WIDTH)) { - width = dir.getInt(AviDirectory.TAG_WIDTH) - } - if (dir.containsTag(AviDirectory.TAG_HEIGHT)) { - height = dir.getInt(AviDirectory.TAG_HEIGHT) - } - if (dir.containsTag(AviDirectory.TAG_DURATION)) { - durationMillis = dir.getLong(AviDirectory.TAG_DURATION) - } + dir.getSafeInt(AviDirectory.TAG_WIDTH) { width = it } + dir.getSafeInt(AviDirectory.TAG_HEIGHT) { height = it } + dir.getSafeLong(AviDirectory.TAG_DURATION) { durationMillis = it } } for (dir in metadata.getDirectoriesOfType(Mp4VideoDirectory::class.java)) { - if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { - width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH) - } - if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) { - height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT) - } + dir.getSafeInt(Mp4VideoDirectory.TAG_WIDTH) { width = it } + dir.getSafeInt(Mp4VideoDirectory.TAG_HEIGHT) { height = it } } for (dir in metadata.getDirectoriesOfType(Mp4Directory::class.java)) { - if (dir.containsTag(Mp4Directory.TAG_DURATION)) { - durationMillis = dir.getLong(Mp4Directory.TAG_DURATION) - } + dir.getSafeInt(Mp4Directory.TAG_ROTATION) { sourceRotationDegrees = it } + dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it } } } else { - for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) { - if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { - width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH) - } - if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) { - height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT) - } - } - for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) { - if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_WIDTH)) { - width = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) - } - if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_HEIGHT)) { - height = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) - } - } - // EXIF, if defined, should override metadata found in other directories for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) { - if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) { - width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) + foundExif = true + dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it } + dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT) { height = it } + dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { sourceRotationDegrees = getRotationDegreesForExifCode(it) } + dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it } + } + + if (!foundExif) { + for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) { + dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it } + dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it } } - if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) { - height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT) - } - if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) { - val exifOrientation = dir.getInt(ExifIFD0Directory.TAG_ORIENTATION) - rotationDegrees = getRotationDegreesForExifCode(exifOrientation) - isFlipped = isFlippedForExifCode(exifOrientation) - } - if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) { - sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).time + for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) { + dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it } + dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it } } } } @@ -258,7 +206,22 @@ class SourceImageEntry { } } - // expects entry with: uri + // finds: width, height, orientation, date + private fun fillByExifInterface(context: Context) { + try { + StorageUtils.openInputStream(context, uri).use { input -> + val exif = ExifInterface(input) + foundExif = true + exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it } + exif.getSafeInt(ExifInterface.TAG_IMAGE_LENGTH, acceptZero = false) { height = it } + exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { sourceRotationDegrees = exif.rotationDegrees } + exif.getSafeDate(ExifInterface.TAG_DATETIME) { sourceDateTakenMillis = it } + } + } catch (e: IOException) { + // ignore + } + } + // finds: width, height private fun fillByBitmapDecode(context: Context) { try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt index e0d031189..3403f5803 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ExifInterfaceHelper.kt @@ -257,24 +257,22 @@ object ExifInterfaceHelper { private fun fillMetadataExtractorDir(exif: ExifInterface, metadataExtractorDirs: Map, tags: Map) { for (kv in tags) { val exifInterfaceTag: String = kv.key - if (exif.hasAttribute(exifInterfaceTag)) { + val mapper = kv.value + if (exif.hasAttribute(exifInterfaceTag) && mapper != null) { val value: String? = exif.getAttribute(exifInterfaceTag) if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) { - val mapper = kv.value - if (mapper != null) { - val obj: Any? = when (mapper.format) { - TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value - TagFormat.BYTE -> value.toByteArray() - TagFormat.SHORT -> value.toShortOrNull() - TagFormat.LONG -> value.toLongOrNull() - TagFormat.RATIONAL -> toRational(value) - TagFormat.RATIONAL_ARRAY -> toRationalArray(value) - null -> null - } - if (obj != null) { - val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance") - dir.setObject(mapper.type, obj) - } + val obj: Any? = when (mapper.format) { + TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value + TagFormat.BYTE -> value.toByteArray() + TagFormat.SHORT -> value.toShortOrNull() + TagFormat.LONG -> value.toLongOrNull() + TagFormat.RATIONAL -> toRational(value) + TagFormat.RATIONAL_ARRAY -> toRationalArray(value) + null -> null + } + if (obj != null) { + val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance") + dir.setObject(mapper.type, obj) } } } @@ -314,6 +312,28 @@ object ExifInterfaceHelper { if (list.isEmpty()) return null return list.toTypedArray() } + + // extensions + + fun ExifInterface.getSafeInt(tag: String, acceptZero: Boolean = true, save: (value: Int) -> Unit) { + if (this.hasAttribute(tag)) { + val value = this.getAttributeInt(tag, 0) + if (acceptZero || value != 0) { + save(value) + } + } + } + + fun ExifInterface.getSafeDate(tag: String, save: (value: Long) -> Unit) { + if (this.hasAttribute(tag)) { + // TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long + val formattedDate = this.getAttribute(tag) + val value = formattedDate?.toLongOrNull() + if (value != null && value > 0) { + save(value) + } + } + } } enum class DirType { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt index 6c822f407..d7008a9e7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MediaMetadataRetrieverHelper.kt @@ -5,47 +5,71 @@ import android.os.Build object MediaMetadataRetrieverHelper { @JvmField - val allKeys: Map = hashMapOf( - "METADATA_KEY_ALBUM" to MediaMetadataRetriever.METADATA_KEY_ALBUM, - "METADATA_KEY_ALBUMARTIST" to MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, - "METADATA_KEY_ARTIST" to MediaMetadataRetriever.METADATA_KEY_ARTIST, - "METADATA_KEY_AUTHOR" to MediaMetadataRetriever.METADATA_KEY_AUTHOR, - "METADATA_KEY_BITRATE" to MediaMetadataRetriever.METADATA_KEY_BITRATE, - "METADATA_KEY_CAPTURE_FRAMERATE" to MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, - "METADATA_KEY_CD_TRACK_NUMBER" to MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER, - "METADATA_KEY_COLOR_RANGE" to MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE, - "METADATA_KEY_COLOR_STANDARD" to MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD, - "METADATA_KEY_COLOR_TRANSFER" to MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER, - "METADATA_KEY_COMPILATION" to MediaMetadataRetriever.METADATA_KEY_COMPILATION, - "METADATA_KEY_COMPOSER" to MediaMetadataRetriever.METADATA_KEY_COMPOSER, - "METADATA_KEY_DATE" to MediaMetadataRetriever.METADATA_KEY_DATE, - "METADATA_KEY_DISC_NUMBER" to MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER, - "METADATA_KEY_DURATION" to MediaMetadataRetriever.METADATA_KEY_DURATION, - "METADATA_KEY_EXIF_LENGTH" to MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH, - "METADATA_KEY_EXIF_OFFSET" to MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET, - "METADATA_KEY_GENRE" to MediaMetadataRetriever.METADATA_KEY_GENRE, - "METADATA_KEY_HAS_AUDIO" to MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, - "METADATA_KEY_HAS_VIDEO" to MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, - "METADATA_KEY_LOCATION" to MediaMetadataRetriever.METADATA_KEY_LOCATION, - "METADATA_KEY_MIMETYPE" to MediaMetadataRetriever.METADATA_KEY_MIMETYPE, - "METADATA_KEY_NUM_TRACKS" to MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS, - "METADATA_KEY_TITLE" to MediaMetadataRetriever.METADATA_KEY_TITLE, - "METADATA_KEY_VIDEO_HEIGHT" to MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, - "METADATA_KEY_VIDEO_ROTATION" to MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, - "METADATA_KEY_VIDEO_WIDTH" to MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH, - "METADATA_KEY_WRITER" to MediaMetadataRetriever.METADATA_KEY_WRITER, - "METADATA_KEY_YEAR" to MediaMetadataRetriever.METADATA_KEY_YEAR, + val allKeys = hashMapOf( + MediaMetadataRetriever.METADATA_KEY_ALBUM to "ALBUM", + MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "ALBUMARTIST", + MediaMetadataRetriever.METADATA_KEY_ARTIST to "ARTIST", + MediaMetadataRetriever.METADATA_KEY_AUTHOR to "AUTHOR", + MediaMetadataRetriever.METADATA_KEY_BITRATE to "BITRATE", + MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "CAPTURE_FRAMERATE", + MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD_TRACK_NUMBER", + MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "COLOR_RANGE", + MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "COLOR_STANDARD", + MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "COLOR_TRANSFER", + MediaMetadataRetriever.METADATA_KEY_COMPILATION to "COMPILATION", + MediaMetadataRetriever.METADATA_KEY_COMPOSER to "COMPOSER", + MediaMetadataRetriever.METADATA_KEY_DATE to "DATE", + MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "DISC_NUMBER", + MediaMetadataRetriever.METADATA_KEY_DURATION to "DURATION", + MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH to "EXIF_LENGTH", + MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET to "EXIF_OFFSET", + MediaMetadataRetriever.METADATA_KEY_GENRE to "GENRE", + MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "HAS_AUDIO", + MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "HAS_VIDEO", + MediaMetadataRetriever.METADATA_KEY_LOCATION to "LOCATION", + MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIMETYPE", + MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "NUM_TRACKS", + MediaMetadataRetriever.METADATA_KEY_TITLE to "TITLE", + MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "VIDEO_HEIGHT", + MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "VIDEO_ROTATION", + MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "VIDEO_WIDTH", + MediaMetadataRetriever.METADATA_KEY_WRITER to "WRITER", + MediaMetadataRetriever.METADATA_KEY_YEAR to "YEAR", ).apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { putAll(hashMapOf( - "METADATA_KEY_HAS_IMAGE" to MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE, - "METADATA_KEY_IMAGE_COUNT" to MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT, - "METADATA_KEY_IMAGE_HEIGHT" to MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, - "METADATA_KEY_IMAGE_PRIMARY" to MediaMetadataRetriever.METADATA_KEY_IMAGE_PRIMARY, - "METADATA_KEY_IMAGE_ROTATION" to MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION, - "METADATA_KEY_IMAGE_WIDTH" to MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH, - "METADATA_KEY_VIDEO_FRAME_COUNT" to MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, + MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE to "HAS_IMAGE", + MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT to "IMAGE_COUNT", + MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT to "IMAGE_HEIGHT", + MediaMetadataRetriever.METADATA_KEY_IMAGE_PRIMARY to "IMAGE_PRIMARY", + MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION to "IMAGE_ROTATION", + MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH to "IMAGE_WIDTH", + MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT to "VIDEO_FRAME_COUNT", )) } } + + // extensions + + fun MediaMetadataRetriever.getSafeString(tag: Int, save: (value: String) -> Unit) { + val value = this.extractMetadata(tag) + if (value != null) save(value) + } + + fun MediaMetadataRetriever.getSafeInt(tag: Int, save: (value: Int) -> Unit) { + val value = this.extractMetadata(tag)?.toIntOrNull() + if (value != null) save(value) + } + + fun MediaMetadataRetriever.getSafeLong(tag: Int, save: (value: Long) -> Unit) { + val value = this.extractMetadata(tag)?.toLongOrNull() + if (value != null) save(value) + } + + fun MediaMetadataRetriever.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) { + val dateString = this.extractMetadata(tag) + val dateMillis = MetadataHelper.parseVideoMetadataDate(dateString) + // some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time + if (dateMillis > 0) save(dateMillis) + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt new file mode 100644 index 000000000..88ccce6a9 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataExtractorHelper.kt @@ -0,0 +1,20 @@ +package deckers.thibault.aves.utils + +import com.drew.metadata.Directory +import java.util.* + +object MetadataExtractorHelper { + // extensions + + fun Directory.getSafeInt(tag: Int, save: (value: Int) -> Unit) { + if (this.containsTag(tag)) save(this.getInt(tag)) + } + + fun Directory.getSafeLong(tag: Int, save: (value: Long) -> Unit) { + if (this.containsTag(tag)) save(this.getLong(tag)) + } + + fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) { + if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 835fd183c..4a9c0551e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -3,60 +3,91 @@ package deckers.thibault.aves.utils import java.util.* object MimeTypes { - const val IMAGE = "image" + private const val IMAGE = "image" // generic raster - const val BMP = "image/bmp" + private const val BMP = "image/bmp" const val GIF = "image/gif" - const val HEIC = "image/heic" - const val HEIF = "image/heif" - const val ICO = "image/x-icon" - const val JPEG = "image/jpeg" - const val PCX = "image/x-pcx" - const val PNG = "image/png" - const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop" - const val TIFF = "image/tiff" - const val WBMP = "image/vnd.wap.wbmp" + private const val HEIC = "image/heic" + private const val HEIF = "image/heif" + private const val ICO = "image/x-icon" + private const val JPEG = "image/jpeg" + private const val PCX = "image/x-pcx" + private const val PNG = "image/png" + private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop" + private const val TIFF = "image/tiff" + private const val WBMP = "image/vnd.wap.wbmp" const val WEBP = "image/webp" // raw raster - const val ARW = "image/x-sony-arw" - const val CR2 = "image/x-canon-cr2" - const val CRW = "image/x-canon-crw" - const val DCR = "image/x-kodak-dcr" - const val DNG = "image/x-adobe-dng" - const val ERF = "image/x-epson-erf" - const val K25 = "image/x-kodak-k25" - const val KDC = "image/x-kodak-kdc" - const val MRW = "image/x-minolta-mrw" - const val NEF = "image/x-nikon-nef" - const val NRW = "image/x-nikon-nrw" - const val ORF = "image/x-olympus-orf" - const val PEF = "image/x-pentax-pef" - const val RAF = "image/x-fuji-raf" - const val RAW = "image/x-panasonic-raw" - const val RW2 = "image/x-panasonic-rw2" - const val SR2 = "image/x-sony-sr2" - const val SRF = "image/x-sony-srf" - const val SRW = "image/x-samsung-srw" - const val X3F = "image/x-sigma-x3f" + private const val ARW = "image/x-sony-arw" + private const val CR2 = "image/x-canon-cr2" + private const val CRW = "image/x-canon-crw" + private const val DCR = "image/x-kodak-dcr" + private const val DNG = "image/x-adobe-dng" + private const val ERF = "image/x-epson-erf" + private const val K25 = "image/x-kodak-k25" + private const val KDC = "image/x-kodak-kdc" + private const val MRW = "image/x-minolta-mrw" + private const val NEF = "image/x-nikon-nef" + private const val NRW = "image/x-nikon-nrw" + private const val ORF = "image/x-olympus-orf" + private const val PEF = "image/x-pentax-pef" + private const val RAF = "image/x-fuji-raf" + private const val RAW = "image/x-panasonic-raw" + private const val RW2 = "image/x-panasonic-rw2" + private const val SR2 = "image/x-sony-sr2" + private const val SRF = "image/x-sony-srf" + private const val SRW = "image/x-samsung-srw" + private const val X3F = "image/x-sigma-x3f" // vector const val SVG = "image/svg+xml" - const val VIDEO = "video" + private const val VIDEO = "video" - const val AVI = "video/avi" - const val MOV = "video/quicktime" - const val MP2T = "video/mp2t" - const val MP4 = "video/mp4" - const val WEBM = "video/webm" - - // as of metadata-extractor v2.14.0, the following formats are not supported - private val unsupportedMetadataExtractorFormats = listOf(WBMP, MP2T, WEBM) + private const val AVI = "video/avi" + private const val MOV = "video/quicktime" + private const val MP2T = "video/mp2t" + private const val MP4 = "video/mp4" + private const val WEBM = "video/webm" @JvmStatic - fun isSupportedByMetadataExtractor(mimeType: String) = !unsupportedMetadataExtractorFormats.contains(mimeType) + fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE) + + @JvmStatic + fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) + + // as of Flutter v1.22.0 + @JvmStatic + fun isSupportedByFlutter(mimeType: String) = when (mimeType) { + JPEG, PNG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true + else -> false + } + + // as of metadata-extractor v2.14.0 + @JvmStatic + fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { + WBMP, MP2T, WEBM -> false + else -> true + } + + // Glide automatically applies EXIF orientation when decoding images of known formats + // but we need to rotate the decoded bitmap for the other formats + @JvmStatic + fun needRotationAfterGlide(mimeType: String) = when (mimeType) { + DNG, HEIC, HEIF, PNG, WEBP -> true + else -> false + } + + // Thumbnails obtained from the Media Store are automatically rotated + // according to EXIF orientation when decoding images of known formats + // but we need to rotate the decoded bitmap for the other formats + @JvmStatic + fun needRotationAfterContentResolverThumbnail(mimeType: String) = when (mimeType) { + DNG, PNG -> true + else -> false + } @JvmStatic fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) { diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index a8fd87923..8a517aa2a 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -23,7 +23,7 @@ class ImageEntry { final String sourceMimeType; int width; int height; - int rotationDegrees; + int sourceRotationDegrees; final int sizeBytes; String sourceTitle; int _dateModifiedSecs; @@ -42,7 +42,7 @@ class ImageEntry { this.sourceMimeType, @required this.width, @required this.height, - this.rotationDegrees, + this.sourceRotationDegrees, this.sizeBytes, this.sourceTitle, int dateModifiedSecs, @@ -68,7 +68,7 @@ class ImageEntry { sourceMimeType: sourceMimeType, width: width, height: height, - rotationDegrees: rotationDegrees, + sourceRotationDegrees: sourceRotationDegrees, sizeBytes: sizeBytes, sourceTitle: sourceTitle, dateModifiedSecs: dateModifiedSecs, @@ -90,7 +90,7 @@ class ImageEntry { sourceMimeType: map['sourceMimeType'] as String, width: map['width'] as int ?? 0, height: map['height'] as int ?? 0, - rotationDegrees: map['rotationDegrees'] as int ?? 0, + sourceRotationDegrees: map['sourceRotationDegrees'] as int ?? 0, sizeBytes: map['sizeBytes'] as int, sourceTitle: map['title'] as String, dateModifiedSecs: map['dateModifiedSecs'] as int, @@ -108,7 +108,7 @@ class ImageEntry { 'sourceMimeType': sourceMimeType, 'width': width, 'height': height, - 'rotationDegrees': rotationDegrees, + 'sourceRotationDegrees': sourceRotationDegrees, 'sizeBytes': sizeBytes, 'title': sourceTitle, 'dateModifiedSecs': dateModifiedSecs, @@ -171,10 +171,10 @@ class ImageEntry { bool get isCatalogued => _catalogMetadata != null; - bool get isFlipped => _catalogMetadata?.isFlipped ?? false; - bool get isAnimated => _catalogMetadata?.isAnimated ?? false; + bool get isFlipped => _catalogMetadata?.isFlipped ?? false; + bool get canEdit => path != null; bool get canPrint => !isVideo; @@ -194,7 +194,7 @@ class ImageEntry { } } - bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : rotationDegrees) % 180 == 90; + bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.rotationDegrees : rotationDegrees) % 180 == 90; double get displayAspectRatio { if (width == 0 || height == 0) return 1; @@ -220,6 +220,13 @@ class ImageEntry { return _bestDate; } + int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; + + set rotationDegrees(int rotationDegrees) { + sourceRotationDegrees = rotationDegrees; + catalogMetadata?.rotationDegrees = rotationDegrees; + } + int get dateModifiedSecs => _dateModifiedSecs; set dateModifiedSecs(int dateModifiedSecs) { @@ -257,7 +264,7 @@ class ImageEntry { String _bestTitle; String get bestTitle { - _bestTitle ??= (_catalogMetadata != null && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle; + _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle; return _bestTitle; } diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index c9273e9b3..59c90a3ad 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -28,8 +28,10 @@ class DateMetadata { } class CatalogMetadata { - final int contentId, dateMillis, videoRotation; - final bool isFlipped, isAnimated; + final int contentId, dateMillis; + final bool isAnimated; + bool isFlipped; + int rotationDegrees; final String mimeType, xmpSubjects, xmpTitleDescription; final double latitude, longitude; Address address; @@ -38,9 +40,9 @@ class CatalogMetadata { this.contentId, this.mimeType, this.dateMillis, - this.isFlipped, this.isAnimated, - this.videoRotation, + this.isFlipped, + this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, double latitude, @@ -57,9 +59,9 @@ class CatalogMetadata { contentId: contentId ?? this.contentId, mimeType: mimeType, dateMillis: dateMillis, - isFlipped: isFlipped, isAnimated: isAnimated, - videoRotation: videoRotation, + isFlipped: isFlipped, + rotationDegrees: rotationDegrees, xmpSubjects: xmpSubjects, xmpTitleDescription: xmpTitleDescription, latitude: latitude, @@ -68,15 +70,16 @@ class CatalogMetadata { } factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) { - final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false); final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false); + final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false); return CatalogMetadata( contentId: map['contentId'], mimeType: map['mimeType'], dateMillis: map['dateMillis'] ?? 0, - isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped, isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated, - videoRotation: map['videoRotation'] ?? 0, + isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped, + // `rotationDegrees` should default to `sourceRotationDegrees`, not 0 + rotationDegrees: map['rotationDegrees'], xmpSubjects: map['xmpSubjects'] ?? '', xmpTitleDescription: map['xmpTitleDescription'] ?? '', latitude: map['latitude'], @@ -88,9 +91,9 @@ class CatalogMetadata { 'contentId': contentId, 'mimeType': mimeType, 'dateMillis': dateMillis, - 'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped, 'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated, - 'videoRotation': videoRotation, + 'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped, + 'rotationDegrees': rotationDegrees, 'xmpSubjects': xmpSubjects, 'xmpTitleDescription': xmpTitleDescription, 'latitude': latitude, @@ -99,7 +102,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isFlipped=$isFlipped, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; + return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 08777d02e..a0138b9b6 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -33,7 +33,7 @@ class MetadataDb { ', sourceMimeType TEXT' ', width INTEGER' ', height INTEGER' - ', rotationDegrees INTEGER' + ', sourceRotationDegrees INTEGER' ', sizeBytes INTEGER' ', title TEXT' ', dateModifiedSecs INTEGER' @@ -48,9 +48,9 @@ class MetadataDb { 'contentId INTEGER PRIMARY KEY' ', mimeType TEXT' ', dateMillis INTEGER' - ', isFlipped INTEGER' ', isAnimated INTEGER' - ', videoRotation INTEGER' + ', isFlipped INTEGER' + ', rotationDegrees INTEGER' ', xmpSubjects TEXT' ', xmpTitleDescription TEXT' ', latitude REAL' @@ -74,8 +74,8 @@ class MetadataDb { // on SQLite <3.25.0, bundled on older Android devices while (oldVersion < newVersion) { if (oldVersion == 1) { + // rename column 'orientationDegrees' to 'sourceRotationDegrees' await db.transaction((txn) async { - // rename column 'orientationDegrees' to 'rotationDegrees' const newEntryTable = '${entryTable}TEMP'; await db.execute('CREATE TABLE $newEntryTable(' 'contentId INTEGER PRIMARY KEY' @@ -84,20 +84,41 @@ class MetadataDb { ', sourceMimeType TEXT' ', width INTEGER' ', height INTEGER' - ', rotationDegrees INTEGER' + ', sourceRotationDegrees INTEGER' ', sizeBytes INTEGER' ', title TEXT' ', dateModifiedSecs INTEGER' ', sourceDateTakenMillis INTEGER' ', durationMillis INTEGER' ')'); - await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,rotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)' + await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)' ' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis' ' FROM $entryTable;'); await db.execute('DROP TABLE $entryTable;'); await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;'); }); + // rename column 'videoRotation' to 'rotationDegrees' + await db.transaction((txn) async { + const newMetadataTable = '${metadataTable}TEMP'; + await db.execute('CREATE TABLE $newMetadataTable(' + 'contentId INTEGER PRIMARY KEY' + ', mimeType TEXT' + ', dateMillis INTEGER' + ', isAnimated INTEGER' + ', rotationDegrees INTEGER' + ', xmpSubjects TEXT' + ', xmpTitleDescription TEXT' + ', latitude REAL' + ', longitude REAL' + ')'); + await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)' + ' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude' + ' FROM $metadataTable;'); + await db.execute('DROP TABLE $metadataTable;'); + await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); + }); + // new column 'isFlipped' await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;'); diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index e9df0907f..366beb497 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -33,11 +33,11 @@ class MetadataService { // return map with: // 'mimeType': MIME type as reported by metadata extractors, not Media Store (string) // 'dateMillis': date taken in milliseconds since Epoch (long) - // 'isFlipped': flipped according to EXIF orientation (bool) // 'isAnimated': animated gif/webp (bool) + // 'isFlipped': flipped according to EXIF orientation (bool) + // 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int) // 'latitude': latitude (double) // 'longitude': longitude (double) - // 'videoRotation': video rotation degrees (int) // 'xmpSubjects': ';' separated XMP subjects (string) // 'xmpTitleDescription': XMP title or XMP description (string) final result = await platform.invokeMethod('getCatalogMetadata', { diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index c27dd3de0..03d7ebebc 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -39,7 +39,8 @@ class _FullscreenDebugPageState extends State { @override void initState() { super.initState(); - _initFutures(); + _loadDatabase(); + _loadMetadata(); } @override @@ -100,6 +101,7 @@ class _FullscreenDebugPageState extends State { InfoRowGroup({ 'width': '${entry.width}', 'height': '${entry.height}', + 'sourceRotationDegrees': '${entry.sourceRotationDegrees}', 'rotationDegrees': '${entry.rotationDegrees}', 'portrait': '${entry.portrait}', 'displayAspectRatio': '${entry.displayAspectRatio}', @@ -118,8 +120,8 @@ class _FullscreenDebugPageState extends State { 'isPhoto': '${entry.isPhoto}', 'isVideo': '${entry.isVideo}', 'isCatalogued': '${entry.isCatalogued}', - 'isFlipped': '${entry.isFlipped}', 'isAnimated': '${entry.isAnimated}', + 'isFlipped': '${entry.isFlipped}', 'canEdit': '${entry.canEdit}', 'canEditExif': '${entry.canEditExif}', 'canPrint': '${entry.canPrint}', @@ -165,10 +167,24 @@ class _FullscreenDebugPageState extends State { } Widget _buildDbTabView() { - final catalog = entry.catalogMetadata; return ListView( padding: EdgeInsets.all(16), children: [ + Row( + children: [ + Expanded( + child: Text('DB'), + ), + SizedBox(width: 8), + RaisedButton( + onPressed: () async { + await metadataDb.removeIds([entry.contentId]); + _loadDatabase(); + }, + child: Text('Remove from DB'), + ), + ], + ), FutureBuilder( future: _dbDateLoader, builder: (context, snapshot) { @@ -202,9 +218,9 @@ class _FullscreenDebugPageState extends State { InfoRowGroup({ 'mimeType': '${data.mimeType}', 'dateMillis': '${data.dateMillis}', - 'isFlipped': '${data.isFlipped}', 'isAnimated': '${data.isAnimated}', - 'videoRotation': '${data.videoRotation}', + 'isFlipped': '${data.isFlipped}', + 'rotationDegrees': '${data.rotationDegrees}', 'latitude': '${data.latitude}', 'longitude': '${data.longitude}', 'xmpSubjects': '${data.xmpSubjects}', @@ -237,21 +253,6 @@ class _FullscreenDebugPageState extends State { ); }, ), - Divider(), - Text('Catalog metadata:${catalog == null ? ' no data' : ''}'), - if (catalog != null) - InfoRowGroup({ - 'contentId': '${catalog.contentId}', - 'mimeType': '${catalog.mimeType}', - 'dateMillis': '${catalog.dateMillis}', - 'isFlipped': '${catalog.isFlipped}', - 'isAnimated': '${catalog.isAnimated}', - 'videoRotation': '${catalog.videoRotation}', - 'latitude': '${catalog.latitude}', - 'longitude': '${catalog.longitude}', - 'xmpSubjects': '${catalog.xmpSubjects}', - 'xmpTitleDescription': '${catalog.xmpTitleDescription}', - }), ], ); } @@ -312,10 +313,14 @@ class _FullscreenDebugPageState extends State { ); } - void _initFutures() { + void _loadDatabase() { _dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); + setState(() {}); + } + + void _loadMetadata() { _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); _mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry); diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 82e1307c5..c9895e1ed 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -84,7 +84,7 @@ class BasicSection extends StatelessWidget { } Map _buildVideoRows() { - final rotation = entry.catalogMetadata?.videoRotation; + final rotation = entry.catalogMetadata?.rotationDegrees; return { 'Duration': entry.durationText, if (rotation != null) 'Rotation': '$rotation°', diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index 86e693f9a..e28bec1fc 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -80,7 +80,7 @@ class AvesVideoState extends State { color: Colors.black, ); - final degree = entry.catalogMetadata?.videoRotation ?? 0; + final degree = entry.catalogMetadata?.rotationDegrees ?? 0; if (degree != 0) { child = RotatedBox( quarterTurns: degree ~/ 90,