info: present video cover like XMP embedded images

This commit is contained in:
Thibault Deckers 2021-04-15 10:01:08 +09:00
parent 8e10dc7bda
commit 484baaaccb
11 changed files with 219 additions and 163 deletions

View file

@ -88,8 +88,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented()
}
@ -217,10 +217,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (isVideo(mimeType)) {
// this is used as fallback when the video metadata cannot be found on the Dart side
// and to identify whether there is an accessible cover image
// do not include HEIC here
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
if (mediaDir.isNotEmpty()) {
metadataMap[Metadata.DIR_MEDIA] = mediaDir
if (mediaDir.containsKey(KEY_HAS_EMBEDDED_PICTURE)) {
metadataMap[Metadata.DIR_COVER_ART] = hashMapOf(
// dummy entry value
"Image" to "data",
)
}
}
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
// about embedded images as they do not list them as separate tracks
@ -241,6 +248,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
retriever.getSafeDescription(code) { dirMap[name] = it }
}
if (retriever.embeddedPicture != null) {
// additional key for the Dart side to know whether to add a `Cover` section
dirMap[KEY_HAS_EMBEDDED_PICTURE] = "yes"
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=$uri", e)
} finally {
@ -734,28 +745,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(value?.toString())
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
return
}
val pictures = ArrayList<ByteArray>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
retriever.embeddedPicture?.let { pictures.add(it) }
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.success(pictures)
}
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -785,6 +774,39 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(thumbnails)
}
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("extractVideoEmbeddedPicture-args", "failed because of missing arguments", null)
return
}
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
retriever.embeddedPicture?.let { bytes ->
var embedMimeType: String? = null
bytes.inputStream().use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
metadata.getFirstDirectoryOfType(FileTypeDirectory::class.java)?.let { dir ->
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { embedMimeType = it }
}
}
embedMimeType?.let { mime ->
copyEmbeddedBytes(bytes, mime, result)
return
}
}
} catch (e: Exception) {
result.error("extractVideoEmbeddedPicture-fetch", "failed to fetch picture for uri=$uri", e.message)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.error("extractVideoEmbeddedPicture-empty", "failed to extract picture for uri=$uri", null)
}
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
@ -820,36 +842,10 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
}
}
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { outputStream ->
embedBytes.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
}
val embedUri = Uri.fromFile(embedFile)
val embedFields: FieldMap = hashMapOf(
"uri" to embedUri.toString(),
"mimeType" to embedMimeType,
)
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
GlobalScope.launch(Dispatchers.IO) {
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
embedFields.putAll(fields)
result.success(embedFields)
}
override fun onFailure(throwable: Throwable) = result.error("extractXmpDataProp-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message)
})
}
} else {
result.success(embedFields)
}
copyEmbeddedBytes(embedBytes, embedMimeType, result)
return
} catch (e: XMPException) {
result.error("extractXmpDataProp-args", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
result.error("extractXmpDataProp-xmp", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message)
return
}
}
@ -862,6 +858,36 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
}
private fun copyEmbeddedBytes(embedBytes: ByteArray, embedMimeType: String, result: MethodChannel.Result) {
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
deleteOnExit()
outputStream().use { outputStream ->
embedBytes.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
}
val embedUri = Uri.fromFile(embedFile)
val embedFields: FieldMap = hashMapOf(
"uri" to embedUri.toString(),
"mimeType" to embedMimeType,
)
if (isImage(embedMimeType) || isVideo(embedMimeType)) {
GlobalScope.launch(Dispatchers.IO) {
FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
embedFields.putAll(fields)
result.success(embedFields)
}
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message)
})
}
} else {
result.success(embedFields)
}
}
private fun isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1
private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? {
@ -923,5 +949,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
private const val KEY_EXPOSURE_TIME = "exposureTime"
private const val KEY_FOCAL_LENGTH = "focalLength"
private const val KEY_ISO = "iso"
// additional media key
private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture"
}
}

View file

@ -25,7 +25,8 @@ object Metadata {
// directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // from metadata-extractor
const val DIR_MEDIA = "Media"
const val DIR_MEDIA = "Media" // custom
const val DIR_COVER_ART = "Cover" // custom
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {

View file

@ -22,10 +22,10 @@ abstract class MetadataService {
Future<String> getContentResolverProp(AvesEntry entry, String prop);
Future<List<Uint8List>> getEmbeddedPictures(String uri);
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractVideoEmbeddedPicture(String uri);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
@ -152,19 +152,6 @@ class PlatformMetadataService implements MetadataService {
return null;
}
@override
Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
'uri': uri,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getEmbeddedPictures failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
@ -180,6 +167,19 @@ class PlatformMetadataService implements MetadataService {
return [];
}
@override
Future<Map> extractVideoEmbeddedPicture(String uri) async {
try {
final result = await platform.invokeMethod('extractVideoEmbeddedPicture', <String, dynamic>{
'uri': uri,
});
return result;
} on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return null;
}
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {

View file

@ -216,8 +216,9 @@ class _OwnerPropState extends State<OwnerProp> {
Future<void> _getOwner() async {
if (entry == null) return;
if (_loadedUri.value == entry.uri) return;
if (isVisible) {
_ownerPackage = await metadataService.getContentResolverProp(widget.entry, 'owner_package_name');
final isMediaContent = entry.uri.startsWith('content://media/external/');
if (isVisible && isMediaContent) {
_ownerPackage = await metadataService.getContentResolverProp(entry, 'owner_package_name');
_loadedUri.value = entry.uri;
} else {
_ownerPackage = null;

View file

@ -2,20 +2,28 @@ import 'dart:collection';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/brand_colors.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
class MetadataDirTile extends StatelessWidget {
class MetadataDirTile extends StatelessWidget with FeedbackMixin {
final AvesEntry entry;
final String title;
final MetadataDirectory dir;
@ -37,43 +45,49 @@ class MetadataDirTile extends StatelessWidget {
if (tags.isEmpty) return SizedBox.shrink();
final dirName = dir.name;
Widget tile;
if (dirName == MetadataDirectory.xmpDirectory) {
return XmpDirTile(
tile = XmpDirTile(
entry: entry,
tags: tags,
expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded,
);
}
Widget thumbnail;
if (showThumbnails) {
} else {
Map<String, InfoLinkHandler> linkHandlers;
switch (dirName) {
case MetadataDirectory.exifThumbnailDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
case SvgMetadataService.metadataDirectory:
linkHandlers = getSvgLinkHandlers(tags);
break;
case MetadataDirectory.mediaDirectory:
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
case MetadataDirectory.coverDirectory:
linkHandlers = getVideoCoverLinkHandlers(tags);
break;
}
}
return AvesExpansionTile(
title: title,
color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName),
expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded,
children: [
if (thumbnail != null) thumbnail,
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
tags,
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(tags) : null,
tile = AvesExpansionTile(
title: title,
color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName),
expandedNotifier: expandedDirectoryNotifier,
initiallyExpanded: initiallyExpanded,
children: [
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
tags,
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: linkHandlers,
),
),
),
],
],
);
}
return NotificationListener<OpenEmbeddedDataNotification>(
onNotification: (notification) {
_openEmbeddedData(context, notification);
return true;
},
child: tile,
);
}
@ -95,4 +109,46 @@ class MetadataDirTile extends StatelessWidget {
),
};
}
static Map<String, InfoLinkHandler> getVideoCoverLinkHandlers(SplayTreeMap<String, String> tags) {
return {
'Image': InfoLinkHandler(
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification.videoCover().dispatch(context),
),
};
}
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
Map fields;
switch (notification.source) {
case EmbeddedDataSource.videoCover:
fields = await metadataService.extractVideoEmbeddedPicture(entry.uri);
break;
case EmbeddedDataSource.xmp:
fields = await metadataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
break;
}
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
return;
}
final mimeType = fields['mimeType'];
final uri = fields['uri'];
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
// open with another app
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
if (!success) {
// fallback to sharing, so that the file can be saved somewhere
AndroidAppService.shareSingle(uri, mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
}
}));
return;
}
OpenTempEntryNotification(entry: AvesEntry.fromMap(fields)).dispatch(context);
}
}

View file

@ -276,7 +276,8 @@ class MetadataDirectory {
// special directory names
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
static const mediaDirectory = 'Media'; // custom
static const coverDirectory = 'Cover'; // custom
const MetadataDirectory(this.name, this.parent, SplayTreeMap<String, String> allTags, {SplayTreeMap<String, String> tags, this.color})
: allTags = allTags,

View file

@ -6,15 +6,11 @@ import 'package:aves/services/services.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
enum MetadataThumbnailSource { embedded, exif }
class MetadataThumbnails extends StatefulWidget {
final MetadataThumbnailSource source;
final AvesEntry entry;
const MetadataThumbnails({
Key key,
@required this.source,
@required this.entry,
}) : super(key: key);
@ -32,14 +28,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
@override
void initState() {
super.initState();
switch (widget.source) {
case MetadataThumbnailSource.embedded:
_loader = metadataService.getEmbeddedPictures(uri);
break;
case MetadataThumbnailSource.exif:
_loader = metadataService.getExifThumbnails(entry);
break;
}
_loader = metadataService.getExifThumbnails(entry);
}
@override

View file

@ -117,15 +117,33 @@ class XmpProp {
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
}
enum EmbeddedDataSource { videoCover, xmp }
class OpenEmbeddedDataNotification extends Notification {
final EmbeddedDataSource source;
final String propPath;
final String mimeType;
const OpenEmbeddedDataNotification({
@required this.propPath,
@required this.mimeType,
const OpenEmbeddedDataNotification._private({
@required this.source,
this.propPath,
this.mimeType,
});
factory OpenEmbeddedDataNotification.videoCover() => OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.videoCover,
);
factory OpenEmbeddedDataNotification.xmp({
@required String propPath,
@required String mimeType,
}) =>
OpenEmbeddedDataNotification._private(
source: EmbeddedDataSource.xmp,
propPath: propPath,
mimeType: mimeType,
);
@override
String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}';
}

View file

@ -20,7 +20,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
dataProp.displayKey,
InfoLinkHandler(
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification(
onTap: (context) => OpenEmbeddedDataNotification.xmp(
propPath: dataProp.path,
mimeType: mimeProp.value,
).dispatch(context),

View file

@ -33,7 +33,7 @@ class XmpBasicNamespace extends XmpNamespace {
if (struct.containsKey(thumbnailDataDisplayKey))
thumbnailDataDisplayKey: InfoLinkHandler(
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification(
onTap: (context) => OpenEmbeddedDataNotification.xmp(
propPath: 'xmp:Thumbnails[$index]/xmpGImg:image',
mimeType: MimeTypes.jpeg,
).dispatch(context),

View file

@ -1,14 +1,8 @@
import 'dart:collection';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/ref/xmp.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
@ -17,10 +11,8 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_ns/mwg.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart';
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
class XmpDirTile extends StatefulWidget {
final AvesEntry entry;
@ -39,7 +31,7 @@ class XmpDirTile extends StatefulWidget {
_XmpDirTileState createState() => _XmpDirTileState();
}
class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
class _XmpDirTileState extends State<XmpDirTile> {
AvesEntry get entry => widget.entry;
@override
@ -83,49 +75,18 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
expandedNotifier: widget.expandedNotifier,
initiallyExpanded: widget.initiallyExpanded,
children: [
NotificationListener<OpenEmbeddedDataNotification>(
onNotification: (notification) {
_openEmbeddedData(notification.propPath, notification.mimeType);
return true;
},
child: Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries
.expand((kv) => kv.key.buildNamespaceSection(
rawProps: kv.value,
))
.toList(),
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries
.expand((kv) => kv.key.buildNamespaceSection(
rawProps: kv.value,
))
.toList(),
),
),
],
);
}
Future<void> _openEmbeddedData(String propPath, String propMimeType) async {
final fields = await metadataService.extractXmpDataProp(entry, propPath, propMimeType);
if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) {
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
return;
}
final mimeType = fields['mimeType'];
final uri = fields['uri'];
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
// open with another app
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
if (!success) {
// fallback to sharing, so that the file can be saved somewhere
AndroidAppService.shareSingle(uri, mimeType).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
}
}));
return;
}
OpenTempEntryNotification(entry: AvesEntry.fromMap(fields)).dispatch(context);
}
}