info: present video cover like XMP embedded images
This commit is contained in:
parent
8e10dc7bda
commit
484baaaccb
11 changed files with 219 additions and 163 deletions
|
@ -88,8 +88,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||||
"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) }
|
||||||
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
|
|
||||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
|
"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) }
|
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
@ -217,10 +217,17 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
if (isVideo(mimeType)) {
|
if (isVideo(mimeType)) {
|
||||||
// this is used as fallback when the video metadata cannot be found on the Dart side
|
// 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
|
// do not include HEIC here
|
||||||
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
|
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
|
||||||
if (mediaDir.isNotEmpty()) {
|
if (mediaDir.isNotEmpty()) {
|
||||||
metadataMap[Metadata.DIR_MEDIA] = mediaDir
|
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
|
// Android's `MediaExtractor` and `MediaPlayer` cannot be used for details
|
||||||
// about embedded images as they do not list them as separate tracks
|
// 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) {
|
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
|
||||||
retriever.getSafeDescription(code) { dirMap[name] = it }
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=$uri", e)
|
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=$uri", e)
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -734,28 +745,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(value?.toString())
|
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) {
|
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
@ -785,6 +774,39 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(thumbnails)
|
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) {
|
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
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 {
|
copyEmbeddedBytes(embedBytes, embedMimeType, result)
|
||||||
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)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
} catch (e: XMPException) {
|
} 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
|
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)
|
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 isMultiPageTiff(uri: Uri) = getTiffPageInfo(uri, 0)?.outDirectoryCount ?: 1 > 1
|
||||||
|
|
||||||
private fun getTiffPageInfo(uri: Uri, page: Int): TiffBitmapFactory.Options? {
|
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_EXPOSURE_TIME = "exposureTime"
|
||||||
private const val KEY_FOCAL_LENGTH = "focalLength"
|
private const val KEY_FOCAL_LENGTH = "focalLength"
|
||||||
private const val KEY_ISO = "iso"
|
private const val KEY_ISO = "iso"
|
||||||
|
|
||||||
|
// additional media key
|
||||||
|
private const val KEY_HAS_EMBEDDED_PICTURE = "Has Embedded Picture"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -25,7 +25,8 @@ object Metadata {
|
||||||
// directory names, as shown when listing all metadata
|
// directory names, as shown when listing all metadata
|
||||||
const val DIR_GPS = "GPS" // from metadata-extractor
|
const val DIR_GPS = "GPS" // from metadata-extractor
|
||||||
const val DIR_XMP = "XMP" // 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)
|
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
||||||
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
||||||
|
|
|
@ -22,10 +22,10 @@ abstract class MetadataService {
|
||||||
|
|
||||||
Future<String> getContentResolverProp(AvesEntry entry, String prop);
|
Future<String> getContentResolverProp(AvesEntry entry, String prop);
|
||||||
|
|
||||||
Future<List<Uint8List>> getEmbeddedPictures(String uri);
|
|
||||||
|
|
||||||
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<Map> extractVideoEmbeddedPicture(String uri);
|
||||||
|
|
||||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,19 +152,6 @@ class PlatformMetadataService implements MetadataService {
|
||||||
return null;
|
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
|
@override
|
||||||
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
|
@ -180,6 +167,19 @@ class PlatformMetadataService implements MetadataService {
|
||||||
return [];
|
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
|
@override
|
||||||
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -216,8 +216,9 @@ class _OwnerPropState extends State<OwnerProp> {
|
||||||
Future<void> _getOwner() async {
|
Future<void> _getOwner() async {
|
||||||
if (entry == null) return;
|
if (entry == null) return;
|
||||||
if (_loadedUri.value == entry.uri) return;
|
if (_loadedUri.value == entry.uri) return;
|
||||||
if (isVisible) {
|
final isMediaContent = entry.uri.startsWith('content://media/external/');
|
||||||
_ownerPackage = await metadataService.getContentResolverProp(widget.entry, 'owner_package_name');
|
if (isVisible && isMediaContent) {
|
||||||
|
_ownerPackage = await metadataService.getContentResolverProp(entry, 'owner_package_name');
|
||||||
_loadedUri.value = entry.uri;
|
_loadedUri.value = entry.uri;
|
||||||
} else {
|
} else {
|
||||||
_ownerPackage = null;
|
_ownerPackage = null;
|
||||||
|
|
|
@ -2,20 +2,28 @@ import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/brand_colors.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/services/svg_metadata_service.dart';
|
||||||
import 'package:aves/utils/color_utils.dart';
|
import 'package:aves/utils/color_utils.dart';
|
||||||
import 'package:aves/utils/constants.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/extensions/build_context.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/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.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_section.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.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/metadata/xmp_tile.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.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 AvesEntry entry;
|
||||||
final String title;
|
final String title;
|
||||||
final MetadataDirectory dir;
|
final MetadataDirectory dir;
|
||||||
|
@ -37,43 +45,49 @@ class MetadataDirTile extends StatelessWidget {
|
||||||
if (tags.isEmpty) return SizedBox.shrink();
|
if (tags.isEmpty) return SizedBox.shrink();
|
||||||
|
|
||||||
final dirName = dir.name;
|
final dirName = dir.name;
|
||||||
|
Widget tile;
|
||||||
if (dirName == MetadataDirectory.xmpDirectory) {
|
if (dirName == MetadataDirectory.xmpDirectory) {
|
||||||
return XmpDirTile(
|
tile = XmpDirTile(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
expandedNotifier: expandedDirectoryNotifier,
|
expandedNotifier: expandedDirectoryNotifier,
|
||||||
initiallyExpanded: initiallyExpanded,
|
initiallyExpanded: initiallyExpanded,
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
Map<String, InfoLinkHandler> linkHandlers;
|
||||||
Widget thumbnail;
|
|
||||||
if (showThumbnails) {
|
|
||||||
switch (dirName) {
|
switch (dirName) {
|
||||||
case MetadataDirectory.exifThumbnailDirectory:
|
case SvgMetadataService.metadataDirectory:
|
||||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry);
|
linkHandlers = getSvgLinkHandlers(tags);
|
||||||
break;
|
break;
|
||||||
case MetadataDirectory.mediaDirectory:
|
case MetadataDirectory.coverDirectory:
|
||||||
thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry);
|
linkHandlers = getVideoCoverLinkHandlers(tags);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return AvesExpansionTile(
|
tile = AvesExpansionTile(
|
||||||
title: title,
|
title: title,
|
||||||
color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName),
|
color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName),
|
||||||
expandedNotifier: expandedDirectoryNotifier,
|
expandedNotifier: expandedDirectoryNotifier,
|
||||||
initiallyExpanded: initiallyExpanded,
|
initiallyExpanded: initiallyExpanded,
|
||||||
children: [
|
children: [
|
||||||
if (thumbnail != null) thumbnail,
|
if (showThumbnails && dirName == MetadataDirectory.exifThumbnailDirectory) MetadataThumbnails(entry: entry),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
child: InfoRowGroup(
|
child: InfoRowGroup(
|
||||||
tags,
|
tags,
|
||||||
maxValueLength: Constants.infoGroupMaxValueLength,
|
maxValueLength: Constants.infoGroupMaxValueLength,
|
||||||
linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(tags) : null,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -276,7 +276,8 @@ class MetadataDirectory {
|
||||||
// special directory names
|
// special directory names
|
||||||
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
||||||
static const xmpDirectory = 'XMP'; // 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})
|
const MetadataDirectory(this.name, this.parent, SplayTreeMap<String, String> allTags, {SplayTreeMap<String, String> tags, this.color})
|
||||||
: allTags = allTags,
|
: allTags = allTags,
|
||||||
|
|
|
@ -6,15 +6,11 @@ import 'package:aves/services/services.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
enum MetadataThumbnailSource { embedded, exif }
|
|
||||||
|
|
||||||
class MetadataThumbnails extends StatefulWidget {
|
class MetadataThumbnails extends StatefulWidget {
|
||||||
final MetadataThumbnailSource source;
|
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
const MetadataThumbnails({
|
const MetadataThumbnails({
|
||||||
Key key,
|
Key key,
|
||||||
@required this.source,
|
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@ -32,14 +28,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
switch (widget.source) {
|
_loader = metadataService.getExifThumbnails(entry);
|
||||||
case MetadataThumbnailSource.embedded:
|
|
||||||
_loader = metadataService.getEmbeddedPictures(uri);
|
|
||||||
break;
|
|
||||||
case MetadataThumbnailSource.exif:
|
|
||||||
_loader = metadataService.getExifThumbnails(entry);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -117,15 +117,33 @@ class XmpProp {
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum EmbeddedDataSource { videoCover, xmp }
|
||||||
|
|
||||||
class OpenEmbeddedDataNotification extends Notification {
|
class OpenEmbeddedDataNotification extends Notification {
|
||||||
|
final EmbeddedDataSource source;
|
||||||
final String propPath;
|
final String propPath;
|
||||||
final String mimeType;
|
final String mimeType;
|
||||||
|
|
||||||
const OpenEmbeddedDataNotification({
|
const OpenEmbeddedDataNotification._private({
|
||||||
@required this.propPath,
|
@required this.source,
|
||||||
@required this.mimeType,
|
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
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}';
|
String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
|
||||||
dataProp.displayKey,
|
dataProp.displayKey,
|
||||||
InfoLinkHandler(
|
InfoLinkHandler(
|
||||||
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
||||||
onTap: (context) => OpenEmbeddedDataNotification(
|
onTap: (context) => OpenEmbeddedDataNotification.xmp(
|
||||||
propPath: dataProp.path,
|
propPath: dataProp.path,
|
||||||
mimeType: mimeProp.value,
|
mimeType: mimeProp.value,
|
||||||
).dispatch(context),
|
).dispatch(context),
|
||||||
|
|
|
@ -33,7 +33,7 @@ class XmpBasicNamespace extends XmpNamespace {
|
||||||
if (struct.containsKey(thumbnailDataDisplayKey))
|
if (struct.containsKey(thumbnailDataDisplayKey))
|
||||||
thumbnailDataDisplayKey: InfoLinkHandler(
|
thumbnailDataDisplayKey: InfoLinkHandler(
|
||||||
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
||||||
onTap: (context) => OpenEmbeddedDataNotification(
|
onTap: (context) => OpenEmbeddedDataNotification.xmp(
|
||||||
propPath: 'xmp:Thumbnails[$index]/xmpGImg:image',
|
propPath: 'xmp:Thumbnails[$index]/xmpGImg:image',
|
||||||
mimeType: MimeTypes.jpeg,
|
mimeType: MimeTypes.jpeg,
|
||||||
).dispatch(context),
|
).dispatch(context),
|
||||||
|
|
|
@ -1,14 +1,8 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
|
||||||
import 'package:aves/ref/xmp.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/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_namespaces.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.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/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/photoshop.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.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/metadata/xmp_ns/xmp.dart';
|
||||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
|
||||||
|
|
||||||
class XmpDirTile extends StatefulWidget {
|
class XmpDirTile extends StatefulWidget {
|
||||||
final AvesEntry entry;
|
final AvesEntry entry;
|
||||||
|
@ -39,7 +31,7 @@ class XmpDirTile extends StatefulWidget {
|
||||||
_XmpDirTileState createState() => _XmpDirTileState();
|
_XmpDirTileState createState() => _XmpDirTileState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
class _XmpDirTileState extends State<XmpDirTile> {
|
||||||
AvesEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -83,49 +75,18 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
||||||
expandedNotifier: widget.expandedNotifier,
|
expandedNotifier: widget.expandedNotifier,
|
||||||
initiallyExpanded: widget.initiallyExpanded,
|
initiallyExpanded: widget.initiallyExpanded,
|
||||||
children: [
|
children: [
|
||||||
NotificationListener<OpenEmbeddedDataNotification>(
|
Padding(
|
||||||
onNotification: (notification) {
|
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||||
_openEmbeddedData(notification.propPath, notification.mimeType);
|
child: Column(
|
||||||
return true;
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
},
|
children: sections.entries
|
||||||
child: Padding(
|
.expand((kv) => kv.key.buildNamespaceSection(
|
||||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
rawProps: kv.value,
|
||||||
child: Column(
|
))
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
.toList(),
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue