video: get rotation angle and date from metadata
This commit is contained in:
parent
1b7d80dfc2
commit
836730f23c
10 changed files with 124 additions and 20 deletions
|
@ -5,6 +5,7 @@ import android.media.MediaMetadataRetriever;
|
||||||
import android.text.format.Formatter;
|
import android.text.format.Formatter;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import com.adobe.xmp.XMPException;
|
import com.adobe.xmp.XMPException;
|
||||||
import com.adobe.xmp.XMPIterator;
|
import com.adobe.xmp.XMPIterator;
|
||||||
|
@ -29,6 +30,7 @@ import java.util.Map;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
|
|
||||||
import deckers.thibault.aves.utils.Constants;
|
import deckers.thibault.aves.utils.Constants;
|
||||||
|
import deckers.thibault.aves.utils.Utils;
|
||||||
import io.flutter.plugin.common.MethodCall;
|
import io.flutter.plugin.common.MethodCall;
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
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) {
|
private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
|
||||||
String path = call.argument("path");
|
String path = call.argument("path");
|
||||||
try (InputStream is = new FileInputStream(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) {
|
private void getAllVideoMetadataFallback(MethodCall call, MethodChannel.Result result) {
|
||||||
String path = call.argument("path");
|
String path = call.argument("path");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Map<String, String>> metadataMap = new HashMap<>();
|
Map<String, Map<String, String>> metadataMap = new HashMap<>();
|
||||||
Map<String, String> dirMap = new HashMap<>();
|
Map<String, String> dirMap = new HashMap<>();
|
||||||
|
@ -182,6 +187,29 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
||||||
e.printStackTrace();
|
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);
|
result.success(metadataMap);
|
||||||
} catch (ImageProcessingException e) {
|
} catch (ImageProcessingException e) {
|
||||||
result.error("getCatalogMetadata-imageprocessing", "failed to get metadata for path=" + path + " (" + e.getMessage() + ")", null);
|
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) {
|
private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) {
|
||||||
|
Map<String, String> metadataMap = new HashMap<>();
|
||||||
|
|
||||||
|
if (isVideo(call.argument("mimeType"))) {
|
||||||
|
result.success(metadataMap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String path = call.argument("path");
|
String path = call.argument("path");
|
||||||
try (InputStream is = new FileInputStream(path)) {
|
try (InputStream is = new FileInputStream(path)) {
|
||||||
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
Metadata metadata = ImageMetadataReader.readMetadata(is);
|
||||||
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
|
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
|
||||||
Map<String, String> metadataMap = new HashMap<>();
|
|
||||||
if (directory != null) {
|
if (directory != null) {
|
||||||
if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) {
|
if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) {
|
||||||
metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER));
|
metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER));
|
||||||
|
|
|
@ -20,7 +20,6 @@ public class Constants {
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
|
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
|
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
|
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation");
|
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
|
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
|
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
|
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
package deckers.thibault.aves.utils;
|
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;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class Utils {
|
public class Utils {
|
||||||
|
@ -21,4 +28,49 @@ public class Utils {
|
||||||
}
|
}
|
||||||
return logTag;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -86,7 +86,13 @@ class ImageEntry with ChangeNotifier {
|
||||||
|
|
||||||
bool get isCataloged => catalogMetadata != null;
|
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();
|
int get megaPixels => (width * height / 1000000).round();
|
||||||
|
|
||||||
|
@ -127,7 +133,7 @@ class ImageEntry with ChangeNotifier {
|
||||||
|
|
||||||
catalog() async {
|
catalog() async {
|
||||||
if (isCataloged) return;
|
if (isCataloged) return;
|
||||||
catalogMetadata = await MetadataService.getCatalogMetadata(contentId, path);
|
catalogMetadata = await MetadataService.getCatalogMetadata(this);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:geocoder/model.dart';
|
import 'package:geocoder/model.dart';
|
||||||
|
|
||||||
class CatalogMetadata {
|
class CatalogMetadata {
|
||||||
final int contentId, dateMillis;
|
final int contentId, dateMillis, videoRotation;
|
||||||
final String xmpSubjects;
|
final String xmpSubjects;
|
||||||
final double latitude, longitude;
|
final double latitude, longitude;
|
||||||
Address address;
|
Address address;
|
||||||
|
@ -9,6 +9,7 @@ class CatalogMetadata {
|
||||||
CatalogMetadata({
|
CatalogMetadata({
|
||||||
this.contentId,
|
this.contentId,
|
||||||
this.dateMillis,
|
this.dateMillis,
|
||||||
|
this.videoRotation,
|
||||||
this.xmpSubjects,
|
this.xmpSubjects,
|
||||||
double latitude,
|
double latitude,
|
||||||
double longitude,
|
double longitude,
|
||||||
|
@ -21,6 +22,7 @@ class CatalogMetadata {
|
||||||
return CatalogMetadata(
|
return CatalogMetadata(
|
||||||
contentId: map['contentId'],
|
contentId: map['contentId'],
|
||||||
dateMillis: map['dateMillis'] ?? 0,
|
dateMillis: map['dateMillis'] ?? 0,
|
||||||
|
videoRotation: map['videoRotation'] ?? 0,
|
||||||
xmpSubjects: map['xmpSubjects'] ?? '',
|
xmpSubjects: map['xmpSubjects'] ?? '',
|
||||||
latitude: map['latitude'],
|
latitude: map['latitude'],
|
||||||
longitude: map['longitude'],
|
longitude: map['longitude'],
|
||||||
|
@ -30,6 +32,7 @@ class CatalogMetadata {
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
'contentId': contentId,
|
'contentId': contentId,
|
||||||
'dateMillis': dateMillis,
|
'dateMillis': dateMillis,
|
||||||
|
'videoRotation': videoRotation,
|
||||||
'xmpSubjects': xmpSubjects,
|
'xmpSubjects': xmpSubjects,
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
'longitude': longitude,
|
'longitude': longitude,
|
||||||
|
@ -37,7 +40,7 @@ class CatalogMetadata {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ class MetadataDb {
|
||||||
await path,
|
await path,
|
||||||
onCreate: (db, version) {
|
onCreate: (db, version) {
|
||||||
return db.execute(
|
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,
|
version: 1,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:aves/model/image_entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/image_metadata.dart';
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -7,10 +8,11 @@ class MetadataService {
|
||||||
static const platform = const MethodChannel('deckers.thibault/aves/metadata');
|
static const platform = const MethodChannel('deckers.thibault/aves/metadata');
|
||||||
|
|
||||||
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||||
static Future<Map> getAllMetadata(String path) async {
|
static Future<Map> getAllMetadata(ImageEntry entry) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
|
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
|
||||||
'path': path,
|
'mimeType': entry.mimeType,
|
||||||
|
'path': entry.path,
|
||||||
});
|
});
|
||||||
return result as Map;
|
return result as Map;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
|
@ -19,7 +21,7 @@ class MetadataService {
|
||||||
return Map();
|
return Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<CatalogMetadata> getCatalogMetadata(int contentId, String path) async {
|
static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry) async {
|
||||||
CatalogMetadata metadata;
|
CatalogMetadata metadata;
|
||||||
try {
|
try {
|
||||||
// return map with:
|
// return map with:
|
||||||
|
@ -28,9 +30,10 @@ class MetadataService {
|
||||||
// 'longitude': longitude (double)
|
// 'longitude': longitude (double)
|
||||||
// 'xmpSubjects': space separated XMP subjects (string)
|
// 'xmpSubjects': space separated XMP subjects (string)
|
||||||
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
|
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
|
||||||
'path': path,
|
'mimeType': entry.mimeType,
|
||||||
|
'path': entry.path,
|
||||||
}) as Map;
|
}) as Map;
|
||||||
result['contentId'] = contentId;
|
result['contentId'] = entry.contentId;
|
||||||
metadata = CatalogMetadata.fromMap(result);
|
metadata = CatalogMetadata.fromMap(result);
|
||||||
metadataDb.insert(metadata);
|
metadataDb.insert(metadata);
|
||||||
return metadata;
|
return metadata;
|
||||||
|
@ -40,11 +43,12 @@ class MetadataService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<OverlayMetadata> getOverlayMetadata(String path) async {
|
static Future<OverlayMetadata> getOverlayMetadata(ImageEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso'
|
// return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso'
|
||||||
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
|
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
|
||||||
'path': path,
|
'mimeType': entry.mimeType,
|
||||||
|
'path': entry.path,
|
||||||
}) as Map;
|
}) as Map;
|
||||||
return OverlayMetadata.fromMap(result);
|
return OverlayMetadata.fromMap(result);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
|
|
|
@ -41,13 +41,13 @@ class InfoPageState extends State<InfoPage> {
|
||||||
title: Text('Info'),
|
title: Text('Info'),
|
||||||
),
|
),
|
||||||
body: NotificationListener(
|
body: NotificationListener(
|
||||||
onNotification: handleTopScroll,
|
onNotification: _handleTopScroll,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: EdgeInsets.all(8.0),
|
padding: EdgeInsets.all(8.0),
|
||||||
children: [
|
children: [
|
||||||
InfoRow('Title', entry.title),
|
InfoRow('Title', entry.title),
|
||||||
InfoRow('Date', dateText),
|
InfoRow('Date', dateText),
|
||||||
if (entry.isVideo) InfoRow('Duration', entry.durationText),
|
if (entry.isVideo) ..._buildVideoRows(),
|
||||||
InfoRow('Resolution', resolutionText),
|
InfoRow('Resolution', resolutionText),
|
||||||
InfoRow('Size', formatFilesize(entry.sizeBytes)),
|
InfoRow('Size', formatFilesize(entry.sizeBytes)),
|
||||||
InfoRow('Path', entry.path),
|
InfoRow('Path', entry.path),
|
||||||
|
@ -60,7 +60,13 @@ class InfoPageState extends State<InfoPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool handleTopScroll(Notification notification) {
|
List<Widget> _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 ScrollNotification) {
|
||||||
if (notification is ScrollStartNotification) {
|
if (notification is ScrollStartNotification) {
|
||||||
final metrics = notification.metrics;
|
final metrics = notification.metrics;
|
||||||
|
|
|
@ -30,7 +30,7 @@ class MetadataSectionState extends State<MetadataSection> {
|
||||||
}
|
}
|
||||||
|
|
||||||
initMetadataLoader() async {
|
initMetadataLoader() async {
|
||||||
_metadataLoader = MetadataService.getAllMetadata(widget.entry.path);
|
_metadataLoader = MetadataService.getAllMetadata(widget.entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -45,7 +45,7 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
|
||||||
}
|
}
|
||||||
|
|
||||||
initDetailLoader() {
|
initDetailLoader() {
|
||||||
_detailLoader = MetadataService.getOverlayMetadata(entry.path);
|
_detailLoader = MetadataService.getOverlayMetadata(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
Loading…
Reference in a new issue