diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index ed5a8bcd6..5a5e254c0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -74,7 +74,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } - "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } "extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) } else -> result.notImplemented() } @@ -539,53 +538,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(thumbnails) } - private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) { - val mimeType = call.argument("mimeType") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val sizeBytes = call.argument("sizeBytes")?.toLong() - if (mimeType == null || uri == null) { - result.error("getXmpThumbnails-args", "failed because of missing arguments", null) - return - } - - val thumbnails = ArrayList() - if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { - try { - StorageUtils.openInputStream(context, uri)?.use { input -> - val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) - for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { - val xmpMeta = dir.xmpMeta - try { - if (xmpMeta.doesPropertyExist(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)) { - val count = xmpMeta.countArrayItems(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME) - for (i in 1 until count + 1) { - val structName = "${XMP.THUMBNAIL_PROP_NAME}[$i]" - val image = xmpMeta.getStructField(XMP.XMP_SCHEMA_NS, structName, XMP.IMG_SCHEMA_NS, XMP.THUMBNAIL_IMAGE_PROP_NAME) - if (image != null) { - thumbnails.add(XMPUtils.decodeBase64(image.value)) - } - } - } - } catch (e: XMPException) { - Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e) - } - } - } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) - } catch (e: NoClassDefFoundError) { - Log.w(LOG_TAG, "failed to extract xmp thumbnail", e) - } - } - result.success(thumbnails) - } - private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) { val mimeType = call.argument("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } val sizeBytes = call.argument("sizeBytes")?.toLong() val dataPropPath = call.argument("propPath") - if (mimeType == null || uri == null || dataPropPath == null) { + val embedMimeType = call.argument("propMimeType") + if (mimeType == null || uri == null || dataPropPath == null || embedMimeType == null) { result.error("extractXmpDataProp-args", "failed because of missing arguments", null) return } @@ -598,10 +557,22 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // which is returned as a second XMP directory val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) try { - val ns = XMP.namespaceForDataPath(dataPropPath) - val mimePropPath = XMP.mimeTypePathForDataPath(dataPropPath) - val embedMimeType = xmpDirs.map { it.xmpMeta.getPropertyString(ns, mimePropPath) }.first { it != null } - val embedBytes = xmpDirs.map { it.xmpMeta.getPropertyBase64(ns, dataPropPath) }.first { it != null } + val pathParts = dataPropPath.split('/') + + val embedBytes: ByteArray = if (pathParts.size == 1) { + val propName = pathParts[0] + val propNs = XMP.namespaceForPropPath(propName) + xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null } + } else { + val structName = pathParts[0] + val structNs = XMP.namespaceForPropPath(structName) + val fieldName = pathParts[1] + val fieldNs = XMP.namespaceForPropPath(fieldName) + xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let { + XMPUtils.decodeBase64(it.value) + } + } + val embedFile = File.createTempFile("aves", null, context.cacheDir).apply { deleteOnExit() outputStream().use { outputStream -> diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 1953e7d9e..9e71c08a5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -12,19 +12,33 @@ object XMP { const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" - const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" const val SUBJECT_PROP_NAME = "dc:subject" const val TITLE_PROP_NAME = "dc:title" const val DESCRIPTION_PROP_NAME = "dc:description" - const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated"; + const val PS_DATE_CREATED_PROP_NAME = "photoshop:DateCreated" const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" - const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" - const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" + private val schemas = hashMapOf( + "GAudio" to "http://ns.google.com/photos/1.0/audio/", + "GDepth" to "http://ns.google.com/photos/1.0/depthmap/", + "GImage" to "http://ns.google.com/photos/1.0/image/", + "xmp" to XMP_SCHEMA_NS, + "xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/", + ) + + fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]] + + // embedded media data properties + // cf https://developers.google.com/depthmap-metadata + // cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format + private val knownDataPaths = listOf("GAudio:Data", "GImage:Data", "GDepth:Data", "GDepth:Confidence") + + fun isDataPath(path: String) = knownDataPaths.contains(path) + // panorama // cf https://developers.google.com/streetview/spherical-metadata @@ -48,44 +62,6 @@ object XMP { GPANO_PROJECTION_TYPE_PROP_NAME, ) - // embedded media data properties - // cf https://developers.google.com/depthmap-metadata - // cf https://developers.google.com/vr/reference/cardboard-camera-vr-photo-format - - private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" - private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" - private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" - - private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data" - private const val GIMAGE_DATA_PROP_NAME = "GImage:Data" - private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" - private const val GDEPTH_CONFIDENCE_PROP_NAME = "GDepth:Confidence" - - private const val GAUDIO_MIME_PROP_NAME = "GAudio:Mime" - private const val GIMAGE_MIME_PROP_NAME = "GImage:Mime" - private const val GDEPTH_MIME_PROP_NAME = "GDepth:Mime" - private const val GDEPTH_CONFIDENCE_MIME_PROP_NAME = "GDepth:ConfidenceMime" - - private val dataPropNamespaces = hashMapOf( - GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS, - GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS, - GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, - GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_SCHEMA_NS, - ) - - private val dataPropMimeProps = hashMapOf( - GAUDIO_DATA_PROP_NAME to GAUDIO_MIME_PROP_NAME, - GIMAGE_DATA_PROP_NAME to GIMAGE_MIME_PROP_NAME, - GDEPTH_DATA_PROP_NAME to GDEPTH_MIME_PROP_NAME, - GDEPTH_CONFIDENCE_PROP_NAME to GDEPTH_CONFIDENCE_MIME_PROP_NAME, - ) - - fun isDataPath(path: String) = dataPropNamespaces.containsKey(path) - - fun namespaceForDataPath(dataPropPath: String) = dataPropNamespaces[dataPropPath] - - fun mimeTypePathForDataPath(dataPropPath: String) = dataPropMimeProps[dataPropPath] - // extensions fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, save: (value: String) -> Unit) { diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 7874edbb5..56c819de7 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -11,17 +11,12 @@ class XMP { 'crs': 'Camera Raw Settings', 'dc': 'Dublin Core', 'drone-dji': 'DJI Drone', - 'exif': 'Exif', 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', - 'GAudio': 'Google Audio', - 'GDepth': 'Google Depth', 'GFocus': 'Google Focus', - 'GImage': 'Google Image', 'GPano': 'Google Panorama', 'illustrator': 'Illustrator', - 'Iptc4xmpCore': 'IPTC Core', 'lr': 'Lightroom', 'MicrosoftPhoto': 'Microsoft Photo', 'panorama': 'Panorama', @@ -29,21 +24,10 @@ class XMP { 'pdfx': 'PDF/X', 'PanoStudioXMP': 'PanoramaStudio', 'photomechanic': 'Photo Mechanic', - 'photoshop': 'Photoshop', 'plus': 'PLUS', - 'tiff': 'TIFF', - 'xmp': 'Basic', 'xmpBJ': 'Basic Job Ticket', 'xmpDM': 'Dynamic Media', - 'xmpMM': 'Media Management', 'xmpRights': 'Rights Management', 'xmpTPg': 'Paged-Text', }; - - // TODO TLAD 'xmp:Thumbnails[\d]/Image' - static const dataProps = [ - 'GAudio:Data', - 'GDepth:Data', - 'GImage:Data', - ]; } diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index c09845310..268bf1c17 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -106,27 +106,14 @@ class MetadataService { return []; } - static Future> getXmpThumbnails(ImageEntry entry) async { - try { - final result = await platform.invokeMethod('getXmpThumbnails', { - 'mimeType': entry.mimeType, - 'uri': entry.uri, - 'sizeBytes': entry.sizeBytes, - }); - return (result as List).cast(); - } on PlatformException catch (e) { - debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); - } - return []; - } - - static Future extractXmpDataProp(ImageEntry entry, String propPath) async { + static Future extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async { try { final result = await platform.invokeMethod('extractXmpDataProp', { 'mimeType': entry.mimeType, 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, 'propPath': propPath, + 'propMimeType': propMimeType, }); return result; } on PlatformException catch (e) { diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 958ba72b1..c14b92088 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -33,17 +33,23 @@ class BasicSection extends StatelessWidget { final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final resolutionText = '${entry.resolutionText}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; + // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 + // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) + final title = entry.bestTitle ?? Constants.infoUnknown; + final uri = entry.uri ?? Constants.infoUnknown; + final path = entry.path; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InfoRowGroup({ - 'Title': entry.bestTitle ?? Constants.infoUnknown, + 'Title': title, 'Date': dateText, if (entry.isVideo) ..._buildVideoRows(), if (!entry.isSvg) 'Resolution': resolutionText, 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown, - 'URI': entry.uri ?? Constants.infoUnknown, - if (entry.path != null) 'Path': entry.path, + 'URI': uri, + if (path != null) 'Path': path, }), _buildChips(), ], diff --git a/lib/widgets/fullscreen/info/common.dart b/lib/widgets/fullscreen/info/common.dart index 259c7d43f..4002d1571 100644 --- a/lib/widgets/fullscreen/info/common.dart +++ b/lib/widgets/fullscreen/info/common.dart @@ -99,7 +99,7 @@ class _InfoRowGroupState extends State { final handler = linkHandlers[key]; value = handler.linkText; // open link on tap - recognizer = TapGestureRecognizer()..onTap = handler.onTap; + recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); style = linkStyle; } else { value = kv.value; @@ -149,7 +149,7 @@ class _InfoRowGroupState extends State { class InfoLinkHandler { final String linkText; - final VoidCallback onTap; + final void Function(BuildContext context) onTap; const InfoLinkHandler({ @required this.linkText, diff --git a/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart b/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart index f47024f2a..7072ac53e 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_thumbnail.dart @@ -5,7 +5,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:flutter/material.dart'; -enum MetadataThumbnailSource { embedded, exif, xmp } +enum MetadataThumbnailSource { embedded, exif } class MetadataThumbnails extends StatefulWidget { final MetadataThumbnailSource source; @@ -38,9 +38,6 @@ class _MetadataThumbnailsState extends State { case MetadataThumbnailSource.exif: _loader = MetadataService.getExifThumbnails(entry); break; - case MetadataThumbnailSource.xmp: - _loader = MetadataService.getXmpThumbnails(entry); - break; } } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart index cfcb92ecc..de15d6b32 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -7,38 +7,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class XmpProp { - final String path, value; - final String displayKey; - - static final sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); - static final sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); - - XmpProp(this.path, this.value) : displayKey = formatKey(path); - - bool get isOpenable => XMP.dataProps.contains(path); - - static String formatKey(String propPath) { - return propPath.splitMapJoin(XMP.structFieldSeparator, - onMatch: (match) => ' ${match.group(0)} ', - onNonMatch: (s) { - // strip namespace - var key = s.split(XMP.propNamespaceSeparator).last; - - // uppercase first letter - key = key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); - - // sentence case - return key.replaceAllMapped(sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); - }); - } - - @override - String toString() { - return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; - } -} - class XmpNamespace { final String namespace; @@ -48,22 +16,11 @@ class XmpNamespace { List buildNamespaceSection({ @required List> rawProps, - @required void Function(String propPath) openEmbeddedData, }) { - final linkHandlers = {}; - final props = rawProps .map((kv) { final prop = XmpProp(kv.key, kv.value); - if (extractData(prop)) return null; - - if (prop.isOpenable) { - linkHandlers.putIfAbsent( - prop.displayKey, - () => InfoLinkHandler(linkText: 'Open', onTap: () => openEmbeddedData(prop.path)), - ); - } - return prop; + return extractData(prop) ? null : prop; }) .where((e) => e != null) .toList() @@ -74,7 +31,7 @@ class XmpNamespace { InfoRowGroup( Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))), maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: linkHandlers, + linkHandlers: linkifyValues(props), ), ...buildFromExtractedData(), ]; @@ -123,6 +80,8 @@ class XmpNamespace { String formatValue(XmpProp prop) => prop.value; + Map linkifyValues(List props) => null; + // identity @override @@ -139,3 +98,48 @@ class XmpNamespace { return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; } } + +class XmpProp { + final String path, value; + final String displayKey; + + static final sentenceCaseStep1 = RegExp(r'([A-Z][a-z]|\[)'); + static final sentenceCaseStep2 = RegExp(r'([a-z])([A-Z])'); + + XmpProp(this.path, this.value) : displayKey = formatKey(path); + + static String formatKey(String propPath) { + return propPath.splitMapJoin(XMP.structFieldSeparator, + onMatch: (match) => ' ${match.group(0)} ', + onNonMatch: (s) { + // strip namespace + var key = s.split(XMP.propNamespaceSeparator).last; + + // uppercase first letter + key = key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); + + // sentence case + return key.replaceAllMapped(sentenceCaseStep1, (m) => ' ${m.group(1)}').replaceAllMapped(sentenceCaseStep2, (m) => '${m.group(1)} ${m.group(2)}').trim(); + }); + } + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; + } +} + +class OpenEmbeddedDataNotification extends Notification { + final String propPath; + final String mimeType; + + const OpenEmbeddedDataNotification({ + @required this.propPath, + @required this.mimeType, + }); + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}'; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart index a4c17f069..168401ce5 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/exif.dart @@ -7,6 +7,9 @@ class XmpExifNamespace extends XmpNamespace { XmpExifNamespace() : super(ns); + @override + String get displayTitle => 'Exif'; + @override String formatValue(XmpProp prop) { final v = prop.value; diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart new file mode 100644 index 000000000..ee9154591 --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/google.dart @@ -0,0 +1,69 @@ +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; +import 'package:tuple/tuple.dart'; + +abstract class XmpGoogleNamespace extends XmpNamespace { + XmpGoogleNamespace(String ns) : super(ns); + + List> get dataProps; + + @override + Map linkifyValues(List props) { + return Map.fromEntries(dataProps.map((t) { + final dataPropPath = t.item1; + final mimePropPath = t.item2; + final dataProp = props.firstWhere((prop) => prop.path == dataPropPath, orElse: () => null); + final mimeProp = props.firstWhere((prop) => prop.path == mimePropPath, orElse: () => null); + return (dataProp != null && mimeProp != null) + ? MapEntry( + dataProp.displayKey, + InfoLinkHandler( + linkText: 'Open', + onTap: (context) => OpenEmbeddedDataNotification( + propPath: dataProp.path, + mimeType: mimeProp.value, + ).dispatch(context), + )) + : null; + }).where((e) => e != null)); + } +} + +class XmpGAudioNamespace extends XmpGoogleNamespace { + static const ns = 'GAudio'; + + XmpGAudioNamespace() : super(ns); + + @override + List> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')]; + + @override + String get displayTitle => 'Google Audio'; +} + +class XmpGDepthNamespace extends XmpGoogleNamespace { + static const ns = 'GDepth'; + + XmpGDepthNamespace() : super(ns); + + @override + List> get dataProps => [ + Tuple2('$ns:Data', '$ns:Mime'), + Tuple2('$ns:Confidence', '$ns:ConfidenceMime'), + ]; + + @override + String get displayTitle => 'Google Depth'; +} + +class XmpGImageNamespace extends XmpGoogleNamespace { + static const ns = 'GImage'; + + XmpGImageNamespace() : super(ns); + + @override + List> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')]; + + @override + String get displayTitle => 'Google Image'; +} diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart index a6654e3e5..5876ce30c 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart @@ -11,6 +11,9 @@ class XmpIptcCoreNamespace extends XmpNamespace { XmpIptcCoreNamespace() : super(ns); + @override + String get displayTitle => 'IPTC Core'; + @override bool extractData(XmpProp prop) => extractStruct(prop, creatorContactInfoPattern, creatorContactInfo); diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart index d659bd627..b8241caa9 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart @@ -7,6 +7,9 @@ class XmpPhotoshopNamespace extends XmpNamespace { XmpPhotoshopNamespace() : super(ns); + @override + String get displayTitle => 'Photoshop'; + @override String formatValue(XmpProp prop) { final value = prop.value; diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart index 38e6f0937..6083d5a39 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart @@ -5,6 +5,9 @@ import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; class XmpTiffNamespace extends XmpNamespace { static const ns = 'tiff'; + @override + String get displayTitle => 'TIFF'; + XmpTiffNamespace() : super(ns); @override diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart index 36376e1ae..1a5dd4f28 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart @@ -1,3 +1,5 @@ +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_structs.dart'; import 'package:flutter/material.dart'; @@ -6,11 +8,15 @@ class XmpBasicNamespace extends XmpNamespace { static const ns = 'xmp'; static final thumbnailsPattern = RegExp(r'xmp:Thumbnails\[(\d+)\]/(.*)'); + static const thumbnailDataDisplayKey = 'Image'; final thumbnails = >{}; XmpBasicNamespace() : super(ns); + @override + String get displayTitle => 'Basic'; + @override bool extractData(XmpProp prop) => extractIndexedStruct(prop, thumbnailsPattern, thumbnails); @@ -20,6 +26,19 @@ class XmpBasicNamespace extends XmpNamespace { XmpStructArrayCard( title: 'Thumbnail', structByIndex: thumbnails, + linkifier: (index) { + final struct = thumbnails[index]; + return { + if (struct.containsKey(thumbnailDataDisplayKey)) + thumbnailDataDisplayKey: InfoLinkHandler( + linkText: 'Open', + onTap: (context) => OpenEmbeddedDataNotification( + propPath: 'xmp:Thumbnails[$index]/xmpGImg:image', + mimeType: MimeTypes.jpeg, + ).dispatch(context), + ), + }; + }, ) ]; } @@ -39,7 +58,14 @@ class XmpMMNamespace extends XmpNamespace { XmpMMNamespace() : super(ns); @override - bool extractData(XmpProp prop) => extractStruct(prop, derivedFromPattern, derivedFrom) || extractIndexedStruct(prop, historyPattern, history); + String get displayTitle => 'Media Management'; + + @override + bool extractData(XmpProp prop) { + final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom); + final hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history); + return hasStructs || hasIndexedStructs; + } @override List buildFromExtractedData() => [ diff --git a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart index e45e6312b..836dab6ec 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart @@ -11,10 +11,12 @@ import 'package:flutter/material.dart'; class XmpStructArrayCard extends StatefulWidget { final String title; final List> structs = []; + final Map Function(int index) linkifier; XmpStructArrayCard({ @required this.title, @required Map> structByIndex, + this.linkifier, }) { structs.length = structByIndex.keys.fold(0, max); structByIndex.keys.forEach((index) => structs[index - 1] = structByIndex[index]); @@ -89,6 +91,7 @@ class _XmpStructArrayCardState extends State { child: InfoRowGroup( structs[_index], maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: widget.linkifier?.call(_index + 1), ), ), ), @@ -101,12 +104,14 @@ class _XmpStructArrayCardState extends State { class XmpStructCard extends StatelessWidget { final String title; final Map struct; + final Map Function() linkifier; static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0); const XmpStructCard({ @required this.title, @required this.struct, + this.linkifier, }); @override @@ -126,6 +131,7 @@ class XmpStructCard extends StatelessWidget { InfoRowGroup( struct, maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: linkifier?.call(), ), ], ), diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 80cef89e9..8a7338b2c 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -10,9 +10,9 @@ import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/exif.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/google.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/iptc.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/photoshop.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_ns/tiff.dart'; @@ -41,7 +41,6 @@ class _XmpDirTileState extends State with FeedbackMixin { @override Widget build(BuildContext context) { - final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); final sections = SplayTreeMap>>.of( groupBy(widget.tags.entries, (kv) { final fullKey = kv.key; @@ -52,6 +51,12 @@ class _XmpDirTileState extends State with FeedbackMixin { return XmpBasicNamespace(); case XmpExifNamespace.ns: return XmpExifNamespace(); + case XmpGAudioNamespace.ns: + return XmpGAudioNamespace(); + case XmpGDepthNamespace.ns: + return XmpGDepthNamespace(); + case XmpGImageNamespace.ns: + return XmpGImageNamespace(); case XmpIptcCoreNamespace.ns: return XmpIptcCoreNamespace(); case XmpMMNamespace.ns: @@ -72,25 +77,29 @@ class _XmpDirTileState extends State with FeedbackMixin { title: 'XMP', expandedNotifier: widget.expandedNotifier, children: [ - if (thumbnail != null) thumbnail, - 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, - openEmbeddedData: _openEmbeddedData, - )) - .toList(), + NotificationListener( + 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(), + ), ), ), ], ); } - Future _openEmbeddedData(String propPath) async { - final fields = await MetadataService.extractXmpDataProp(entry, propPath); + Future _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, 'Failed'); return;