diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index c3ee24434..1bab8b566 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -5,6 +5,7 @@ import android.media.MediaMetadataRetriever; import android.text.format.Formatter; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.adobe.xmp.XMPException; import com.adobe.xmp.XMPIterator; @@ -29,6 +30,7 @@ import java.util.Map; import java.util.TimeZone; import deckers.thibault.aves.utils.Constants; +import deckers.thibault.aves.utils.Utils; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -62,6 +64,10 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } } + private boolean isVideo(@Nullable String mimeType) { + return mimeType != null && mimeType.startsWith(Constants.MIME_VIDEO); + } + private void getAllMetadata(MethodCall call, MethodChannel.Result result) { String path = call.argument("path"); try (InputStream is = new FileInputStream(path)) { @@ -108,7 +114,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { private void getAllVideoMetadataFallback(MethodCall call, MethodChannel.Result result) { String path = call.argument("path"); - try { Map> metadataMap = new HashMap<>(); Map dirMap = new HashMap<>(); @@ -182,6 +187,29 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { e.printStackTrace(); } } + + if (isVideo(call.argument("mimeType"))) { + try { + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(path); + String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE); + String rotationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION); + retriever.release(); + + if (dateString != null) { + long dateMillis = Utils.parseVideoMetadataDate(dateString); + // some videos have an invalid default date (19040101T000000.000Z) that is before Epoch time + if (dateMillis > 0) { + metadataMap.put("dateMillis", dateMillis); + } + } + if (rotationString != null) { + metadataMap.put("videoRotation", Integer.parseInt(rotationString)); + } + } catch (Exception e) { + result.error("getCatalogMetadata-exception", "failed to get video metadata for path=" + path, e); + } + } result.success(metadataMap); } catch (ImageProcessingException e) { result.error("getCatalogMetadata-imageprocessing", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null); @@ -193,11 +221,17 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) { + Map metadataMap = new HashMap<>(); + + if (isVideo(call.argument("mimeType"))) { + result.success(metadataMap); + return; + } + String path = call.argument("path"); try (InputStream is = new FileInputStream(path)) { Metadata metadata = ImageMetadataReader.readMetadata(is); ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); - Map metadataMap = new HashMap<>(); if (directory != null) { if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java index bfdddc09d..3b520803a 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java @@ -20,7 +20,6 @@ public class Constants { put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio"); put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video"); put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate"); - put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation"); put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date"); put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location"); put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year"); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java b/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java index 84337c806..27eae8ebd 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Utils.java @@ -1,5 +1,12 @@ package deckers.thibault.aves.utils; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Matcher; import java.util.regex.Pattern; public class Utils { @@ -21,4 +28,49 @@ public class Utils { } return logTag; } + + // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? + public static long parseVideoMetadataDate(String dateString) { + // optional sub-second + String subSecond = null; + Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString); + if (subSecondMatcher.find()) { + subSecond = subSecondMatcher.group(2).substring(1); + dateString = subSecondMatcher.replaceAll("$1"); + } + + // optional time zone + TimeZone timeZone = null; + Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString); + if (timeZoneMatcher.find()) { + timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", "")); + dateString = timeZoneMatcher.replaceAll(""); + } + + Date date = null; + try { + DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US); + parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT")); + date = parser.parse(dateString); + } catch (ParseException ex) { + // ignore + } + + if (date == null) { + return 0; + } + + long dateMillis = date.getTime(); + if (subSecond != null) { + try { + int millis = (int) (Double.parseDouble("." + subSecond) * 1000); + if (millis >= 0 && millis < 1000) { + dateMillis += millis; + } + } catch (NumberFormatException e) { + // ignore + } + } + return dateMillis; + } } \ No newline at end of file diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 14bcb8788..bd0df7936 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -86,7 +86,13 @@ class ImageEntry with ChangeNotifier { bool get isCataloged => catalogMetadata != null; - double get aspectRatio => height == 0 ? 1 : width / height; + double get aspectRatio { + if (width == 0 || height == 0) return 1; + if (isVideo && isCataloged) { + if (catalogMetadata.videoRotation % 180 == 90) return height / width; + } + return width / height; + } int get megaPixels => (width * height / 1000000).round(); @@ -127,7 +133,7 @@ class ImageEntry with ChangeNotifier { catalog() async { if (isCataloged) return; - catalogMetadata = await MetadataService.getCatalogMetadata(contentId, path); + catalogMetadata = await MetadataService.getCatalogMetadata(this); notifyListeners(); } diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index c2ef37a23..babe445d9 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -1,7 +1,7 @@ import 'package:geocoder/model.dart'; class CatalogMetadata { - final int contentId, dateMillis; + final int contentId, dateMillis, videoRotation; final String xmpSubjects; final double latitude, longitude; Address address; @@ -9,6 +9,7 @@ class CatalogMetadata { CatalogMetadata({ this.contentId, this.dateMillis, + this.videoRotation, this.xmpSubjects, double latitude, double longitude, @@ -21,6 +22,7 @@ class CatalogMetadata { return CatalogMetadata( contentId: map['contentId'], dateMillis: map['dateMillis'] ?? 0, + videoRotation: map['videoRotation'] ?? 0, xmpSubjects: map['xmpSubjects'] ?? '', latitude: map['latitude'], longitude: map['longitude'], @@ -30,6 +32,7 @@ class CatalogMetadata { Map toMap() => { 'contentId': contentId, 'dateMillis': dateMillis, + 'videoRotation': videoRotation, 'xmpSubjects': xmpSubjects, 'latitude': latitude, 'longitude': longitude, @@ -37,7 +40,7 @@ class CatalogMetadata { @override String toString() { - return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}'; + return 'CatalogMetadata{contentId=$contentId, dateMillis=$dateMillis, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects}'; } } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 497ced091..8e8c54501 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -20,7 +20,7 @@ class MetadataDb { await path, onCreate: (db, version) { return db.execute( - 'CREATE TABLE $table(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, xmpSubjects TEXT, latitude REAL, longitude REAL)', + 'CREATE TABLE $table(contentId INTEGER PRIMARY KEY, dateMillis INTEGER, videoRotation INTEGER, xmpSubjects TEXT, latitude REAL, longitude REAL)', ); }, version: 1, diff --git a/lib/model/metadata_service.dart b/lib/model/metadata_service.dart index 73cb8b2f7..84c929495 100644 --- a/lib/model/metadata_service.dart +++ b/lib/model/metadata_service.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/metadata_db.dart'; import 'package:flutter/material.dart'; @@ -7,10 +8,11 @@ class MetadataService { static const platform = const MethodChannel('deckers.thibault/aves/metadata'); // return Map> (map of directories, each directory being a map of metadata label and value description) - static Future getAllMetadata(String path) async { + static Future getAllMetadata(ImageEntry entry) async { try { final result = await platform.invokeMethod('getAllMetadata', { - 'path': path, + 'mimeType': entry.mimeType, + 'path': entry.path, }); return result as Map; } on PlatformException catch (e) { @@ -19,7 +21,7 @@ class MetadataService { return Map(); } - static Future getCatalogMetadata(int contentId, String path) async { + static Future getCatalogMetadata(ImageEntry entry) async { CatalogMetadata metadata; try { // return map with: @@ -28,9 +30,10 @@ class MetadataService { // 'longitude': longitude (double) // 'xmpSubjects': space separated XMP subjects (string) final result = await platform.invokeMethod('getCatalogMetadata', { - 'path': path, + 'mimeType': entry.mimeType, + 'path': entry.path, }) as Map; - result['contentId'] = contentId; + result['contentId'] = entry.contentId; metadata = CatalogMetadata.fromMap(result); metadataDb.insert(metadata); return metadata; @@ -40,11 +43,12 @@ class MetadataService { return null; } - static Future getOverlayMetadata(String path) async { + static Future getOverlayMetadata(ImageEntry entry) async { try { // return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso' final result = await platform.invokeMethod('getOverlayMetadata', { - 'path': path, + 'mimeType': entry.mimeType, + 'path': entry.path, }) as Map; return OverlayMetadata.fromMap(result); } on PlatformException catch (e) { diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index b84ff2f25..a40aaee88 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -41,13 +41,13 @@ class InfoPageState extends State { title: Text('Info'), ), body: NotificationListener( - onNotification: handleTopScroll, + onNotification: _handleTopScroll, child: ListView( padding: EdgeInsets.all(8.0), children: [ InfoRow('Title', entry.title), InfoRow('Date', dateText), - if (entry.isVideo) InfoRow('Duration', entry.durationText), + if (entry.isVideo) ..._buildVideoRows(), InfoRow('Resolution', resolutionText), InfoRow('Size', formatFilesize(entry.sizeBytes)), InfoRow('Path', entry.path), @@ -60,7 +60,13 @@ class InfoPageState extends State { ); } - bool handleTopScroll(Notification notification) { + List _buildVideoRows() { + final rotation = entry.catalogMetadata?.videoRotation; + if (rotation != null) InfoRow('Rotation', '$rotation°'); + return [InfoRow('Duration', entry.durationText), if (rotation != null) InfoRow('Rotation', '$rotation°')]; + } + + bool _handleTopScroll(Notification notification) { if (notification is ScrollNotification) { if (notification is ScrollStartNotification) { final metrics = notification.metrics; diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index 609d8de30..9cfca7a0d 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -30,7 +30,7 @@ class MetadataSectionState extends State { } initMetadataLoader() async { - _metadataLoader = MetadataService.getAllMetadata(widget.entry.path); + _metadataLoader = MetadataService.getAllMetadata(widget.entry); } @override diff --git a/lib/widgets/fullscreen/overlay_bottom.dart b/lib/widgets/fullscreen/overlay_bottom.dart index 097c360ab..12909000a 100644 --- a/lib/widgets/fullscreen/overlay_bottom.dart +++ b/lib/widgets/fullscreen/overlay_bottom.dart @@ -45,7 +45,7 @@ class _FullscreenBottomOverlayState extends State { } initDetailLoader() { - _detailLoader = MetadataService.getOverlayMetadata(entry.path); + _detailLoader = MetadataService.getOverlayMetadata(entry); } @override