info: access to motion photo video, improved video metadata stream handling
This commit is contained in:
parent
7a66df5e37
commit
95b34b753b
20 changed files with 189 additions and 81 deletions
Binary file not shown.
|
@ -76,6 +76,7 @@ import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
@ -90,6 +91,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) }
|
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) }
|
||||||
|
"extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) }
|
||||||
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
|
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
|
||||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
|
@ -775,6 +777,41 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(thumbnails)
|
result.success(thumbnails)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractMotionPhotoVideo(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 || sizeBytes == null) {
|
||||||
|
result.error("extractMotionPhotoVideo-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
|
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||||
|
val xmpMeta = dir.xmpMeta
|
||||||
|
// offset from end
|
||||||
|
var offsetFromEnd: Int? = null
|
||||||
|
xmpMeta.getSafeInt(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||||
|
if (offsetFromEnd != null) {
|
||||||
|
StorageUtils.openInputStream(context, uri)?.let { original ->
|
||||||
|
original.skip(sizeBytes - offsetFromEnd!!)
|
||||||
|
copyEmbeddedBytes(result, MimeTypes.MP4, original)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to extract video from motion photo", e)
|
||||||
|
} catch (e: NoClassDefFoundError) {
|
||||||
|
Log.w(LOG_TAG, "failed to extract video from motion photo", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.error("extractMotionPhotoVideo-empty", "failed to extract video from motion photo at uri=$uri", null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
|
@ -794,7 +831,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
embedMimeType?.let { mime ->
|
embedMimeType?.let { mime ->
|
||||||
copyEmbeddedBytes(bytes, mime, result)
|
copyEmbeddedBytes(result, mime, bytes.inputStream())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -843,7 +880,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
copyEmbeddedBytes(embedBytes, embedMimeType, result)
|
copyEmbeddedBytes(result, embedMimeType, embedBytes.inputStream())
|
||||||
return
|
return
|
||||||
} catch (e: XMPException) {
|
} catch (e: XMPException) {
|
||||||
result.error("extractXmpDataProp-xmp", "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)
|
||||||
|
@ -859,11 +896,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null)
|
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) {
|
private fun copyEmbeddedBytes(result: MethodChannel.Result, embedMimeType: String, embedByteStream: InputStream) {
|
||||||
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
val embedFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||||
deleteOnExit()
|
deleteOnExit()
|
||||||
outputStream().use { outputStream ->
|
outputStream().use { outputStream ->
|
||||||
embedBytes.inputStream().use { inputStream ->
|
embedByteStream.use { inputStream ->
|
||||||
inputStream.copyTo(outputStream)
|
inputStream.copyTo(outputStream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,12 @@ object XMP {
|
||||||
|
|
||||||
fun isDataPath(path: String) = knownDataPaths.contains(path)
|
fun isDataPath(path: String) = knownDataPaths.contains(path)
|
||||||
|
|
||||||
|
// motion photo
|
||||||
|
|
||||||
|
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
||||||
|
|
||||||
|
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
|
||||||
|
|
||||||
// panorama
|
// panorama
|
||||||
// cf https://developers.google.com/streetview/spherical-metadata
|
// cf https://developers.google.com/streetview/spherical-metadata
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ object MimeTypes {
|
||||||
|
|
||||||
private const val MP2T = "video/mp2t"
|
private const val MP2T = "video/mp2t"
|
||||||
private const val MP2TS = "video/mp2ts"
|
private const val MP2TS = "video/mp2ts"
|
||||||
|
const val MP4 = "video/mp4"
|
||||||
private const val WEBM = "video/webm"
|
private const val WEBM = "video/webm"
|
||||||
|
|
||||||
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
|
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
|
||||||
|
|
|
@ -115,7 +115,9 @@ class VideoMetadataFormatter {
|
||||||
save('Channel Layout', _formatChannelLayout(value));
|
save('Channel Layout', _formatChannelLayout(value));
|
||||||
break;
|
break;
|
||||||
case Keys.codecName:
|
case Keys.codecName:
|
||||||
save('Format', _formatCodecName(value));
|
if (value != 'none') {
|
||||||
|
save('Format', _formatCodecName(value));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case Keys.codecPixelFormat:
|
case Keys.codecPixelFormat:
|
||||||
if (streamType == StreamTypes.video) {
|
if (streamType == StreamTypes.video) {
|
||||||
|
@ -296,6 +298,7 @@ class VideoMetadataFormatter {
|
||||||
}
|
}
|
||||||
|
|
||||||
class StreamTypes {
|
class StreamTypes {
|
||||||
|
static const attachment = 'attachment';
|
||||||
static const audio = 'audio';
|
static const audio = 'audio';
|
||||||
static const metadata = 'metadata';
|
static const metadata = 'metadata';
|
||||||
static const subtitle = 'subtitle';
|
static const subtitle = 'subtitle';
|
||||||
|
|
|
@ -16,6 +16,7 @@ class XMP {
|
||||||
'GettyImagesGIFT': 'Getty Images',
|
'GettyImagesGIFT': 'Getty Images',
|
||||||
'GIMP': 'GIMP',
|
'GIMP': 'GIMP',
|
||||||
'GCamera': 'Google Camera',
|
'GCamera': 'Google Camera',
|
||||||
|
'GCreations': 'Google Creations',
|
||||||
'GFocus': 'Google Focus',
|
'GFocus': 'Google Focus',
|
||||||
'GPano': 'Google Panorama',
|
'GPano': 'Google Panorama',
|
||||||
'illustrator': 'Illustrator',
|
'illustrator': 'Illustrator',
|
||||||
|
|
|
@ -24,6 +24,8 @@ abstract class MetadataService {
|
||||||
|
|
||||||
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map> extractMotionPhotoVideo(AvesEntry entry);
|
||||||
|
|
||||||
Future<Map> extractVideoEmbeddedPicture(String uri);
|
Future<Map> extractVideoEmbeddedPicture(String uri);
|
||||||
|
|
||||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
||||||
|
@ -167,6 +169,21 @@ class PlatformMetadataService implements MetadataService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map> extractMotionPhotoVideo(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('extractMotionPhotoVideo', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
'sizeBytes': entry.sizeBytes,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map> extractVideoEmbeddedPicture(String uri) async {
|
Future<Map> extractVideoEmbeddedPicture(String uri) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -121,6 +121,9 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
|
||||||
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
|
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
|
||||||
Map fields;
|
Map fields;
|
||||||
switch (notification.source) {
|
switch (notification.source) {
|
||||||
|
case EmbeddedDataSource.motionPhotoVideo:
|
||||||
|
fields = await metadataService.extractMotionPhotoVideo(entry);
|
||||||
|
break;
|
||||||
case EmbeddedDataSource.videoCover:
|
case EmbeddedDataSource.videoCover:
|
||||||
fields = await metadataService.extractVideoEmbeddedPicture(entry.uri);
|
fields = await metadataService.extractVideoEmbeddedPicture(entry.uri);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -193,6 +193,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
String getTypeText(Map stream) {
|
String getTypeText(Map stream) {
|
||||||
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
|
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case StreamTypes.attachment:
|
||||||
|
return 'Attachment';
|
||||||
case StreamTypes.audio:
|
case StreamTypes.audio:
|
||||||
return 'Audio';
|
return 'Audio';
|
||||||
case StreamTypes.metadata:
|
case StreamTypes.metadata:
|
||||||
|
@ -209,8 +211,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
}
|
}
|
||||||
|
|
||||||
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
|
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
|
||||||
final unknownStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.unknown).toList();
|
final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
|
||||||
final knownStreams = allStreams.whereNot(unknownStreams.contains);
|
final knownStreams = allStreams.whereNot(attachmentStreams.contains);
|
||||||
|
|
||||||
// display known streams as separate directories (e.g. video, audio, subs)
|
// display known streams as separate directories (e.g. video, audio, subs)
|
||||||
if (knownStreams.isNotEmpty) {
|
if (knownStreams.isNotEmpty) {
|
||||||
|
@ -228,18 +230,18 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// display unknown streams as attachments (e.g. fonts)
|
// group attachments by format (e.g. TTF fonts)
|
||||||
if (unknownStreams.isNotEmpty) {
|
if (attachmentStreams.isNotEmpty) {
|
||||||
final unknownCodecCount = <String, List<String>>{};
|
final formatCount = <String, List<String>>{};
|
||||||
for (final stream in unknownStreams) {
|
for (final stream in attachmentStreams) {
|
||||||
final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase();
|
final codec = (stream[Keys.codecName] as String ?? 'unknown').toUpperCase();
|
||||||
if (!unknownCodecCount.containsKey(codec)) {
|
if (!formatCount.containsKey(codec)) {
|
||||||
unknownCodecCount[codec] = [];
|
formatCount[codec] = [];
|
||||||
}
|
}
|
||||||
unknownCodecCount[codec].add(stream[Keys.filename]);
|
formatCount[codec].add(stream[Keys.filename]);
|
||||||
}
|
}
|
||||||
if (unknownCodecCount.isNotEmpty) {
|
if (formatCount.isNotEmpty) {
|
||||||
final rawTags = unknownCodecCount.map((key, value) {
|
final rawTags = formatCount.map((key, value) {
|
||||||
final count = value.length;
|
final count = value.length;
|
||||||
// remove duplicate names, so number of displayed names may not match displayed count
|
// remove duplicate names, so number of displayed names may not match displayed count
|
||||||
final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase);
|
final names = value.where((v) => v != null).toSet().toList()..sort(compareAsciiUpperCase);
|
||||||
|
|
|
@ -4,21 +4,61 @@ import 'package:aves/utils/constants.dart';
|
||||||
import 'package:aves/utils/string_utils.dart';
|
import 'package:aves/utils/string_utils.dart';
|
||||||
import 'package:aves/widgets/common/identity/highlight_title.dart';
|
import 'package:aves/widgets/common/identity/highlight_title.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart';
|
||||||
|
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:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class XmpNamespace {
|
class XmpNamespace {
|
||||||
final String namespace;
|
final String namespace;
|
||||||
|
final Map<String, String> rawProps;
|
||||||
|
|
||||||
const XmpNamespace(this.namespace);
|
const XmpNamespace(this.namespace, this.rawProps);
|
||||||
|
|
||||||
|
factory XmpNamespace.create(String namespace, Map<String, String> rawProps) {
|
||||||
|
switch (namespace) {
|
||||||
|
case XmpBasicNamespace.ns:
|
||||||
|
return XmpBasicNamespace(rawProps);
|
||||||
|
case XmpExifNamespace.ns:
|
||||||
|
return XmpExifNamespace(rawProps);
|
||||||
|
case XmpGAudioNamespace.ns:
|
||||||
|
return XmpGAudioNamespace(rawProps);
|
||||||
|
case XmpGCameraNamespace.ns:
|
||||||
|
return XmpGCameraNamespace(rawProps);
|
||||||
|
case XmpGDepthNamespace.ns:
|
||||||
|
return XmpGDepthNamespace(rawProps);
|
||||||
|
case XmpGImageNamespace.ns:
|
||||||
|
return XmpGImageNamespace(rawProps);
|
||||||
|
case XmpIptcCoreNamespace.ns:
|
||||||
|
return XmpIptcCoreNamespace(rawProps);
|
||||||
|
case XmpMgwRegionsNamespace.ns:
|
||||||
|
return XmpMgwRegionsNamespace(rawProps);
|
||||||
|
case XmpMMNamespace.ns:
|
||||||
|
return XmpMMNamespace(rawProps);
|
||||||
|
case XmpNoteNamespace.ns:
|
||||||
|
return XmpNoteNamespace(rawProps);
|
||||||
|
case XmpPhotoshopNamespace.ns:
|
||||||
|
return XmpPhotoshopNamespace(rawProps);
|
||||||
|
case XmpTiffNamespace.ns:
|
||||||
|
return XmpTiffNamespace(rawProps);
|
||||||
|
default:
|
||||||
|
return XmpNamespace(namespace, rawProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String get displayTitle => XMP.namespaces[namespace] ?? namespace;
|
String get displayTitle => XMP.namespaces[namespace] ?? namespace;
|
||||||
|
|
||||||
List<Widget> buildNamespaceSection({
|
Map<String, String> get buildProps => rawProps;
|
||||||
@required List<MapEntry<String, String>> rawProps,
|
|
||||||
}) {
|
List<Widget> buildNamespaceSection() {
|
||||||
final props = rawProps
|
final props = buildProps
|
||||||
|
.entries
|
||||||
.map((kv) {
|
.map((kv) {
|
||||||
final prop = XmpProp(kv.key, kv.value);
|
final prop = XmpProp(kv.key, kv.value);
|
||||||
return extractData(prop) ? null : prop;
|
return extractData(prop) ? null : prop;
|
||||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||||
class XmpExifNamespace extends XmpNamespace {
|
class XmpExifNamespace extends XmpNamespace {
|
||||||
static const ns = 'exif';
|
static const ns = 'exif';
|
||||||
|
|
||||||
XmpExifNamespace() : super(ns);
|
XmpExifNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'Exif';
|
String get displayTitle => 'Exif';
|
||||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
abstract class XmpGoogleNamespace extends XmpNamespace {
|
abstract class XmpGoogleNamespace extends XmpNamespace {
|
||||||
XmpGoogleNamespace(String ns) : super(ns);
|
XmpGoogleNamespace(String ns, Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
List<Tuple2<String, String>> get dataProps;
|
List<Tuple2<String, String>> get dataProps;
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
|
||||||
class XmpGAudioNamespace extends XmpGoogleNamespace {
|
class XmpGAudioNamespace extends XmpGoogleNamespace {
|
||||||
static const ns = 'GAudio';
|
static const ns = 'GAudio';
|
||||||
|
|
||||||
XmpGAudioNamespace() : super(ns);
|
XmpGAudioNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
|
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
|
||||||
|
@ -46,7 +46,7 @@ class XmpGAudioNamespace extends XmpGoogleNamespace {
|
||||||
class XmpGDepthNamespace extends XmpGoogleNamespace {
|
class XmpGDepthNamespace extends XmpGoogleNamespace {
|
||||||
static const ns = 'GDepth';
|
static const ns = 'GDepth';
|
||||||
|
|
||||||
XmpGDepthNamespace() : super(ns);
|
XmpGDepthNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Tuple2<String, String>> get dataProps => [
|
List<Tuple2<String, String>> get dataProps => [
|
||||||
|
@ -61,7 +61,7 @@ class XmpGDepthNamespace extends XmpGoogleNamespace {
|
||||||
class XmpGImageNamespace extends XmpGoogleNamespace {
|
class XmpGImageNamespace extends XmpGoogleNamespace {
|
||||||
static const ns = 'GImage';
|
static const ns = 'GImage';
|
||||||
|
|
||||||
XmpGImageNamespace() : super(ns);
|
XmpGImageNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
|
List<Tuple2<String, String>> get dataProps => [Tuple2('$ns:Data', '$ns:Mime')];
|
||||||
|
@ -69,3 +69,35 @@ class XmpGImageNamespace extends XmpGoogleNamespace {
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'Google Image';
|
String get displayTitle => 'Google Image';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class XmpGCameraNamespace extends XmpNamespace {
|
||||||
|
static const ns = 'GCamera';
|
||||||
|
static const videoOffsetKey = 'GCamera:MicroVideoOffset';
|
||||||
|
static const videoDataKey = 'Data';
|
||||||
|
|
||||||
|
bool _isMotionPhoto;
|
||||||
|
|
||||||
|
XmpGCameraNamespace(Map<String, String> rawProps) : super(ns, rawProps) {
|
||||||
|
_isMotionPhoto = rawProps.keys.any((key) => key == videoOffsetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, String> get buildProps {
|
||||||
|
return _isMotionPhoto
|
||||||
|
? Map.fromEntries({
|
||||||
|
MapEntry(videoDataKey, '[skipped]'),
|
||||||
|
...rawProps.entries,
|
||||||
|
})
|
||||||
|
: rawProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) {
|
||||||
|
return {
|
||||||
|
videoDataKey: InfoLinkHandler(
|
||||||
|
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
||||||
|
onTap: (context) => OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ class XmpIptcCoreNamespace extends XmpNamespace {
|
||||||
|
|
||||||
final creatorContactInfo = <String, String>{};
|
final creatorContactInfo = <String, String>{};
|
||||||
|
|
||||||
XmpIptcCoreNamespace() : super(ns);
|
XmpIptcCoreNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'IPTC Core';
|
String get displayTitle => 'IPTC Core';
|
||||||
|
|
|
@ -12,7 +12,7 @@ class XmpMgwRegionsNamespace extends XmpNamespace {
|
||||||
final dimensions = <String, String>{};
|
final dimensions = <String, String>{};
|
||||||
final regionList = <int, Map<String, String>>{};
|
final regionList = <int, Map<String, String>>{};
|
||||||
|
|
||||||
XmpMgwRegionsNamespace() : super(ns);
|
XmpMgwRegionsNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'Regions';
|
String get displayTitle => 'Regions';
|
||||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||||
class XmpPhotoshopNamespace extends XmpNamespace {
|
class XmpPhotoshopNamespace extends XmpNamespace {
|
||||||
static const ns = 'photoshop';
|
static const ns = 'photoshop';
|
||||||
|
|
||||||
XmpPhotoshopNamespace() : super(ns);
|
XmpPhotoshopNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'Photoshop';
|
String get displayTitle => 'Photoshop';
|
||||||
|
|
|
@ -8,7 +8,7 @@ class XmpTiffNamespace extends XmpNamespace {
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'TIFF';
|
String get displayTitle => 'TIFF';
|
||||||
|
|
||||||
XmpTiffNamespace() : super(ns);
|
XmpTiffNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String formatValue(XmpProp prop) {
|
String formatValue(XmpProp prop) {
|
||||||
|
|
|
@ -14,7 +14,7 @@ class XmpBasicNamespace extends XmpNamespace {
|
||||||
|
|
||||||
final thumbnails = <int, Map<String, String>>{};
|
final thumbnails = <int, Map<String, String>>{};
|
||||||
|
|
||||||
XmpBasicNamespace() : super(ns);
|
XmpBasicNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'Basic';
|
String get displayTitle => 'Basic';
|
||||||
|
@ -61,7 +61,7 @@ class XmpMMNamespace extends XmpNamespace {
|
||||||
final ingredients = <int, Map<String, String>>{};
|
final ingredients = <int, Map<String, String>>{};
|
||||||
final pantry = <int, Map<String, String>>{};
|
final pantry = <int, Map<String, String>>{};
|
||||||
|
|
||||||
XmpMMNamespace() : super(ns);
|
XmpMMNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get displayTitle => 'Media Management';
|
String get displayTitle => 'Media Management';
|
||||||
|
@ -114,7 +114,7 @@ class XmpNoteNamespace extends XmpNamespace {
|
||||||
// `xmpNote:HasExtendedXMP` is structural and should not be displayed to users
|
// `xmpNote:HasExtendedXMP` is structural and should not be displayed to users
|
||||||
static const hasExtendedXmp = '$ns:HasExtendedXMP';
|
static const hasExtendedXmp = '$ns:HasExtendedXMP';
|
||||||
|
|
||||||
XmpNoteNamespace() : super(ns);
|
XmpNoteNamespace(Map<String, String> rawProps) : super(ns, rawProps);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool extractData(XmpProp prop) {
|
bool extractData(XmpProp prop) {
|
||||||
|
|
|
@ -4,13 +4,6 @@ import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/xmp.dart';
|
import 'package:aves/ref/xmp.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.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';
|
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart';
|
|
||||||
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:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -36,40 +29,13 @@ class _XmpDirTileState extends State<XmpDirTile> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sections = SplayTreeMap<XmpNamespace, List<MapEntry<String, String>>>.of(
|
final sections = groupBy(widget.tags.entries, (kv) {
|
||||||
groupBy(widget.tags.entries, (kv) {
|
final fullKey = kv.key;
|
||||||
final fullKey = kv.key;
|
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
|
||||||
final i = fullKey.indexOf(XMP.propNamespaceSeparator);
|
final namespace = i == -1 ? '' : fullKey.substring(0, i);
|
||||||
final namespace = i == -1 ? '' : fullKey.substring(0, i);
|
return namespace;
|
||||||
switch (namespace) {
|
}).entries.map((kv) => XmpNamespace.create(kv.key, Map.fromEntries(kv.value))).toList()
|
||||||
case XmpBasicNamespace.ns:
|
..sort((a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle));
|
||||||
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 XmpMgwRegionsNamespace.ns:
|
|
||||||
return XmpMgwRegionsNamespace();
|
|
||||||
case XmpMMNamespace.ns:
|
|
||||||
return XmpMMNamespace();
|
|
||||||
case XmpNoteNamespace.ns:
|
|
||||||
return XmpNoteNamespace();
|
|
||||||
case XmpPhotoshopNamespace.ns:
|
|
||||||
return XmpPhotoshopNamespace();
|
|
||||||
case XmpTiffNamespace.ns:
|
|
||||||
return XmpTiffNamespace();
|
|
||||||
default:
|
|
||||||
return XmpNamespace(namespace);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
(a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle),
|
|
||||||
);
|
|
||||||
return AvesExpansionTile(
|
return AvesExpansionTile(
|
||||||
title: 'XMP',
|
title: 'XMP',
|
||||||
expandedNotifier: widget.expandedNotifier,
|
expandedNotifier: widget.expandedNotifier,
|
||||||
|
@ -79,11 +45,7 @@ class _XmpDirTileState extends State<XmpDirTile> {
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: sections.entries
|
children: sections.expand((section) => section.buildNamespaceSection()).toList(),
|
||||||
.expand((kv) => kv.key.buildNamespaceSection(
|
|
||||||
rawProps: kv.value,
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -28,7 +28,7 @@ class OpenTempEntryNotification extends Notification {
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
|
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EmbeddedDataSource { videoCover, xmp }
|
enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
|
||||||
|
|
||||||
class OpenEmbeddedDataNotification extends Notification {
|
class OpenEmbeddedDataNotification extends Notification {
|
||||||
final EmbeddedDataSource source;
|
final EmbeddedDataSource source;
|
||||||
|
@ -41,6 +41,10 @@ class OpenEmbeddedDataNotification extends Notification {
|
||||||
this.mimeType,
|
this.mimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
factory OpenEmbeddedDataNotification.motionPhotoVideo() => OpenEmbeddedDataNotification._private(
|
||||||
|
source: EmbeddedDataSource.motionPhotoVideo,
|
||||||
|
);
|
||||||
|
|
||||||
factory OpenEmbeddedDataNotification.videoCover() => OpenEmbeddedDataNotification._private(
|
factory OpenEmbeddedDataNotification.videoCover() => OpenEmbeddedDataNotification._private(
|
||||||
source: EmbeddedDataSource.videoCover,
|
source: EmbeddedDataSource.videoCover,
|
||||||
);
|
);
|
||||||
|
|
|
@ -197,7 +197,7 @@ packages:
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: aves
|
ref: aves
|
||||||
resolved-ref: "0f25874db46d1af6fcfbeb8722915cbc211a10fb"
|
resolved-ref: "0100934c469f35f575f3f84e17116f13c326f393"
|
||||||
url: "git://github.com/deckerst/fijkplayer.git"
|
url: "git://github.com/deckerst/fijkplayer.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.8.7"
|
version: "0.8.7"
|
||||||
|
|
Loading…
Reference in a new issue