XMP: reviewed data prop linking, open thumbnails like other data prop

This commit is contained in:
Thibault Deckers 2020-12-08 19:00:29 +09:00
parent b297fd5fe0
commit 690d257375
16 changed files with 237 additions and 190 deletions

View file

@ -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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val dataPropPath = call.argument<String>("propPath")
if (mimeType == null || uri == null || dataPropPath == null) {
val embedMimeType = call.argument<String>("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 ->

View file

@ -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) {

View file

@ -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',
];
}

View file

@ -106,27 +106,14 @@ class MetadataService {
return [];
}
static Future<List<Uint8List>> getXmpThumbnails(ImageEntry entry) async {
try {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
debugPrint('getXmpThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return [];
}
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath) async {
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
'propPath': propPath,
'propMimeType': propMimeType,
});
return result;
} on PlatformException catch (e) {

View file

@ -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(),
],

View file

@ -99,7 +99,7 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
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<InfoRowGroup> {
class InfoLinkHandler {
final String linkText;
final VoidCallback onTap;
final void Function(BuildContext context) onTap;
const InfoLinkHandler({
@required this.linkText,

View file

@ -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<MetadataThumbnails> {
case MetadataThumbnailSource.exif:
_loader = MetadataService.getExifThumbnails(entry);
break;
case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(entry);
break;
}
}

View file

@ -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<Widget> buildNamespaceSection({
@required List<MapEntry<String, String>> rawProps,
@required void Function(String propPath) openEmbeddedData,
}) {
final linkHandlers = <String, InfoLinkHandler>{};
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<String, InfoLinkHandler> linkifyValues(List<XmpProp> 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}';
}
}

View file

@ -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;

View file

@ -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<Tuple2<String, String>> get dataProps;
@override
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> 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<Tuple2<String, String>> 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<Tuple2<String, String>> 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<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
@override
String get displayTitle => 'Google Image';
}

View file

@ -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);

View file

@ -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;

View file

@ -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

View file

@ -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 = <int, Map<String, String>>{};
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<Widget> buildFromExtractedData() => [

View file

@ -11,10 +11,12 @@ import 'package:flutter/material.dart';
class XmpStructArrayCard extends StatefulWidget {
final String title;
final List<Map<String, String>> structs = [];
final Map<String, InfoLinkHandler> Function(int index) linkifier;
XmpStructArrayCard({
@required this.title,
@required Map<int, Map<String, String>> 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<XmpStructArrayCard> {
child: InfoRowGroup(
structs[_index],
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: widget.linkifier?.call(_index + 1),
),
),
),
@ -101,12 +104,14 @@ class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
class XmpStructCard extends StatelessWidget {
final String title;
final Map<String, String> struct;
final Map<String, InfoLinkHandler> 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(),
),
],
),

View file

@ -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<XmpDirTile> with FeedbackMixin {
@override
Widget build(BuildContext context) {
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.of(
groupBy(widget.tags.entries, (kv) {
final fullKey = kv.key;
@ -52,6 +51,12 @@ class _XmpDirTileState extends State<XmpDirTile> 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<XmpDirTile> 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<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(),
),
),
),
],
);
}
Future<void> _openEmbeddedData(String propPath) async {
final fields = await MetadataService.extractXmpDataProp(entry, propPath);
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, 'Failed');
return;