#403 info: export metadata to text file
This commit is contained in:
parent
85ffd6843b
commit
2605a8ab53
13 changed files with 302 additions and 185 deletions
|
@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Info: export metadata to text file
|
||||||
- Accessibility: apply bold font system setting
|
- Accessibility: apply bold font system setting
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
@ -216,7 +216,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
(it.tagCount > 0 || it.errorCount > 0)
|
(it.tagCount > 0 || it.errorCount > 0)
|
||||||
&& it !is FileTypeDirectory
|
&& it !is FileTypeDirectory
|
||||||
&& it !is AviDirectory
|
&& it !is AviDirectory
|
||||||
&& !(it is XmpDirectory && it.tagCount == 1 && it.containsTag(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
|
||||||
}.groupBy { dir -> dir.name }
|
}.groupBy { dir -> dir.name }
|
||||||
|
|
||||||
for (dirEntry in dirByName) {
|
for (dirEntry in dirByName) {
|
||||||
|
@ -386,8 +385,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
fun fallbackProcessXmp(xmpMeta: XMPMeta) {
|
fun fallbackProcessXmp(xmpMeta: XMPMeta) {
|
||||||
val thisDirName = XmpDirectory().name
|
val thisDirName = XmpDirectory().name
|
||||||
val dirMap = metadataMap[thisDirName] ?: HashMap()
|
val dirMap = metadataMap[thisDirName] ?: HashMap()
|
||||||
metadataMap[thisDirName] = dirMap
|
|
||||||
processXmp(xmpMeta, dirMap)
|
processXmp(xmpMeta, dirMap)
|
||||||
|
if (dirMap.isNotEmpty()) {
|
||||||
|
metadataMap[thisDirName] = dirMap
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp)
|
XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp)
|
||||||
|
|
|
@ -122,6 +122,7 @@
|
||||||
"entryInfoActionEditRating": "Edit rating",
|
"entryInfoActionEditRating": "Edit rating",
|
||||||
"entryInfoActionEditTags": "Edit tags",
|
"entryInfoActionEditTags": "Edit tags",
|
||||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||||
|
"entryInfoActionExportMetadata": "Export metadata",
|
||||||
|
|
||||||
"filterBinLabel": "Recycle bin",
|
"filterBinLabel": "Recycle bin",
|
||||||
"filterFavouriteLabel": "Favorite",
|
"filterFavouriteLabel": "Favorite",
|
||||||
|
|
|
@ -11,6 +11,7 @@ enum EntryInfoAction {
|
||||||
editRating,
|
editRating,
|
||||||
editTags,
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
|
exportMetadata,
|
||||||
// GeoTIFF
|
// GeoTIFF
|
||||||
showGeoTiffOnMap,
|
showGeoTiffOnMap,
|
||||||
// motion photo
|
// motion photo
|
||||||
|
@ -28,6 +29,7 @@ class EntryInfoActions {
|
||||||
EntryInfoAction.editRating,
|
EntryInfoAction.editRating,
|
||||||
EntryInfoAction.editTags,
|
EntryInfoAction.editTags,
|
||||||
EntryInfoAction.removeMetadata,
|
EntryInfoAction.removeMetadata,
|
||||||
|
EntryInfoAction.exportMetadata,
|
||||||
];
|
];
|
||||||
|
|
||||||
static const formatSpecific = [
|
static const formatSpecific = [
|
||||||
|
@ -53,6 +55,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
return context.l10n.entryInfoActionEditTags;
|
return context.l10n.entryInfoActionEditTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return context.l10n.entryInfoActionRemoveMetadata;
|
return context.l10n.entryInfoActionRemoveMetadata;
|
||||||
|
case EntryInfoAction.exportMetadata:
|
||||||
|
return context.l10n.entryInfoActionExportMetadata;
|
||||||
// GeoTIFF
|
// GeoTIFF
|
||||||
case EntryInfoAction.showGeoTiffOnMap:
|
case EntryInfoAction.showGeoTiffOnMap:
|
||||||
return context.l10n.entryActionShowGeoTiffOnMap;
|
return context.l10n.entryActionShowGeoTiffOnMap;
|
||||||
|
@ -96,6 +100,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
return AIcons.editTags;
|
return AIcons.editTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return AIcons.clear;
|
return AIcons.clear;
|
||||||
|
case EntryInfoAction.exportMetadata:
|
||||||
|
return AIcons.fileExport;
|
||||||
// GeoTIFF
|
// GeoTIFF
|
||||||
case EntryInfoAction.showGeoTiffOnMap:
|
case EntryInfoAction.showGeoTiffOnMap:
|
||||||
return AIcons.map;
|
return AIcons.map;
|
||||||
|
|
154
lib/model/entry_info.dart
Normal file
154
lib/model/entry_info.dart
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/video/keys.dart';
|
||||||
|
import 'package:aves/model/video/metadata.dart';
|
||||||
|
import 'package:aves/ref/mime_types.dart';
|
||||||
|
import 'package:aves/services/common/services.dart';
|
||||||
|
import 'package:aves/services/metadata/svg_metadata_service.dart';
|
||||||
|
import 'package:aves/theme/colors.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
extension ExtraAvesEntryInfo on AvesEntry {
|
||||||
|
// directory names may contain the name of their parent directory (as prefix + '/')
|
||||||
|
// directory names may contain an index (as suffix in '[]')
|
||||||
|
static final directoryNamePattern = RegExp(r'^((?<parent>.*?)/)?(?<name>.*?)(\[(?<index>\d+)\])?$');
|
||||||
|
|
||||||
|
Future<List<MapEntry<String, MetadataDirectory>>> getMetadataDirectories(BuildContext context) async {
|
||||||
|
final rawMetadata = await (isSvg ? SvgMetadataService.getAllMetadata(this) : metadataFetchService.getAllMetadata(this));
|
||||||
|
final directories = rawMetadata.entries.map((dirKV) {
|
||||||
|
var directoryName = dirKV.key as String;
|
||||||
|
|
||||||
|
String? parent;
|
||||||
|
int? index;
|
||||||
|
final match = directoryNamePattern.firstMatch(directoryName);
|
||||||
|
if (match != null) {
|
||||||
|
parent = match.namedGroup('parent');
|
||||||
|
final nameMatch = match.namedGroup('name');
|
||||||
|
if (nameMatch != null) {
|
||||||
|
directoryName = nameMatch;
|
||||||
|
}
|
||||||
|
final indexMatch = match.namedGroup('index');
|
||||||
|
if (indexMatch != null) {
|
||||||
|
index = int.tryParse(indexMatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawTags = dirKV.value as Map;
|
||||||
|
return MetadataDirectory(
|
||||||
|
directoryName,
|
||||||
|
_toSortedTags(rawTags),
|
||||||
|
parent: parent,
|
||||||
|
index: index,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (isVideo || (mimeType == MimeTypes.heif && isMultiPage)) {
|
||||||
|
directories.addAll(await _getStreamDirectories(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
final titledDirectories = directories.map((dir) {
|
||||||
|
var title = dir.name;
|
||||||
|
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
|
||||||
|
title = '${dir.parent}/$title';
|
||||||
|
}
|
||||||
|
if (dir.index != null) {
|
||||||
|
title += ' ${dir.index}';
|
||||||
|
}
|
||||||
|
return MapEntry(title, dir);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
|
||||||
|
|
||||||
|
return titledDirectories;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<MetadataDirectory>> _getStreamDirectories(BuildContext context) async {
|
||||||
|
final directories = <MetadataDirectory>[];
|
||||||
|
final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(this);
|
||||||
|
|
||||||
|
final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo);
|
||||||
|
if (formattedMediaTags.isNotEmpty) {
|
||||||
|
// overwrite generic directory found from the platform side
|
||||||
|
directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaInfo.containsKey(Keys.streams)) {
|
||||||
|
String getTypeText(Map stream) {
|
||||||
|
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
|
||||||
|
switch (type) {
|
||||||
|
case StreamTypes.attachment:
|
||||||
|
return 'Attachment';
|
||||||
|
case StreamTypes.audio:
|
||||||
|
return 'Audio';
|
||||||
|
case StreamTypes.metadata:
|
||||||
|
return 'Metadata';
|
||||||
|
case StreamTypes.subtitle:
|
||||||
|
case StreamTypes.timedText:
|
||||||
|
return 'Text';
|
||||||
|
case StreamTypes.video:
|
||||||
|
return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
|
||||||
|
case StreamTypes.unknown:
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
|
||||||
|
final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
|
||||||
|
final knownStreams = allStreams.whereNot(attachmentStreams.contains);
|
||||||
|
|
||||||
|
// display known streams as separate directories (e.g. video, audio, subs)
|
||||||
|
if (knownStreams.isNotEmpty) {
|
||||||
|
final indexDigits = knownStreams.length.toString().length;
|
||||||
|
|
||||||
|
final colors = context.read<AvesColorsData>();
|
||||||
|
for (final stream in knownStreams) {
|
||||||
|
final index = (stream[Keys.index] ?? 0) + 1;
|
||||||
|
final typeText = getTypeText(stream);
|
||||||
|
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText';
|
||||||
|
final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
|
||||||
|
if (formattedStreamTags.isNotEmpty) {
|
||||||
|
final color = colors.fromString(typeText);
|
||||||
|
directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// group attachments by format (e.g. TTF fonts)
|
||||||
|
if (attachmentStreams.isNotEmpty) {
|
||||||
|
final formatCount = <String, List<String?>>{};
|
||||||
|
for (final stream in attachmentStreams) {
|
||||||
|
final codec = (stream[Keys.codecName] as String? ?? 'unknown').toUpperCase();
|
||||||
|
if (!formatCount.containsKey(codec)) {
|
||||||
|
formatCount[codec] = [];
|
||||||
|
}
|
||||||
|
formatCount[codec]!.add(stream[Keys.filename]);
|
||||||
|
}
|
||||||
|
if (formatCount.isNotEmpty) {
|
||||||
|
final rawTags = formatCount.map((key, value) {
|
||||||
|
final count = value.length;
|
||||||
|
// remove duplicate names, so number of displayed names may not match displayed count
|
||||||
|
final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase);
|
||||||
|
return MapEntry(key, '$count items: ${names.join(', ')}');
|
||||||
|
});
|
||||||
|
directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return directories;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplayTreeMap<String, String> _toSortedTags(Map rawTags) {
|
||||||
|
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
|
||||||
|
var value = (tagKV.value as String? ?? '').trim();
|
||||||
|
if (value.isEmpty) return null;
|
||||||
|
final tagName = tagKV.key as String;
|
||||||
|
return MapEntry(tagName, value);
|
||||||
|
}).whereNotNull()));
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,14 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:aves/model/actions/entry_info_actions.dart';
|
import 'package:aves/model/actions/entry_info_actions.dart';
|
||||||
import 'package:aves/model/actions/events.dart';
|
import 'package:aves/model/actions/events.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_info.dart';
|
||||||
import 'package:aves/model/entry_metadata_edition.dart';
|
import 'package:aves/model/entry_metadata_edition.dart';
|
||||||
import 'package:aves/model/geotiff.dart';
|
import 'package:aves/model/geotiff.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
|
@ -39,6 +42,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
case EntryInfoAction.editRating:
|
case EntryInfoAction.editRating:
|
||||||
case EntryInfoAction.editTags:
|
case EntryInfoAction.editTags:
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
|
case EntryInfoAction.exportMetadata:
|
||||||
return true;
|
return true;
|
||||||
// GeoTIFF
|
// GeoTIFF
|
||||||
case EntryInfoAction.showGeoTiffOnMap:
|
case EntryInfoAction.showGeoTiffOnMap:
|
||||||
|
@ -68,6 +72,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
return entry.canEditTags;
|
return entry.canEditTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return entry.canRemoveMetadata;
|
return entry.canRemoveMetadata;
|
||||||
|
case EntryInfoAction.exportMetadata:
|
||||||
|
return true;
|
||||||
// GeoTIFF
|
// GeoTIFF
|
||||||
case EntryInfoAction.showGeoTiffOnMap:
|
case EntryInfoAction.showGeoTiffOnMap:
|
||||||
return true;
|
return true;
|
||||||
|
@ -104,6 +110,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
await _removeMetadata(context);
|
await _removeMetadata(context);
|
||||||
break;
|
break;
|
||||||
|
case EntryInfoAction.exportMetadata:
|
||||||
|
await _exportMetadata(context);
|
||||||
|
break;
|
||||||
// GeoTIFF
|
// GeoTIFF
|
||||||
case EntryInfoAction.showGeoTiffOnMap:
|
case EntryInfoAction.showGeoTiffOnMap:
|
||||||
await _showGeoTiffOnMap(context);
|
await _showGeoTiffOnMap(context);
|
||||||
|
@ -169,6 +178,38 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
||||||
await edit(context, () => entry.removeMetadata(types));
|
await edit(context, () => entry.removeMetadata(types));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _exportMetadata(BuildContext context) async {
|
||||||
|
final lines = <String>[];
|
||||||
|
final padding = ' ' * 2;
|
||||||
|
final titledDirectories = await entry.getMetadataDirectories(context);
|
||||||
|
titledDirectories.forEach((kv) {
|
||||||
|
final title = kv.key;
|
||||||
|
final dir = kv.value;
|
||||||
|
|
||||||
|
lines.add('[$title]');
|
||||||
|
final dirContent = dir.allTags;
|
||||||
|
final tags = dirContent.keys.toList()..sort();
|
||||||
|
tags.forEach((tag) {
|
||||||
|
final value = dirContent[tag];
|
||||||
|
lines.add('$padding$tag: $value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
final metadataString = lines.join('\n');
|
||||||
|
|
||||||
|
final success = await storageService.createFile(
|
||||||
|
'${entry.filenameWithoutExtension}-metadata.txt',
|
||||||
|
MimeTypes.plainText,
|
||||||
|
Uint8List.fromList(utf8.encode(metadataString)),
|
||||||
|
);
|
||||||
|
if (success != null) {
|
||||||
|
if (success) {
|
||||||
|
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||||
|
} else {
|
||||||
|
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _convertMotionPhotoToStillImage(BuildContext context) async {
|
Future<void> _convertMotionPhotoToStillImage(BuildContext context) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
@ -3,12 +3,12 @@ import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
|
import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
|
||||||
|
import 'package:aves/widgets/common/app_bar/sliver_app_bar_title.dart';
|
||||||
import 'package:aves/widgets/common/basic/menu.dart';
|
import 'package:aves/widgets/common/basic/menu.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/app_bar/sliver_app_bar_title.dart';
|
|
||||||
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
|
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_search.dart';
|
import 'package:aves/widgets/viewer/info/info_search.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
|
@ -14,6 +14,7 @@ import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||||
import 'package:aves/widgets/viewer/info/basic_section.dart';
|
import 'package:aves/widgets/viewer/info/basic_section.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
||||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.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/multipage/conductor.dart';
|
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||||
import 'package:aves/widgets/viewer/notifications.dart';
|
import 'package:aves/widgets/viewer/notifications.dart';
|
||||||
|
|
|
@ -4,8 +4,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/empty.dart';
|
import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
40
lib/widgets/viewer/info/metadata/metadata_dir.dart
Normal file
40
lib/widgets/viewer/info/metadata/metadata_dir.dart
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class MetadataDirectory {
|
||||||
|
final String name;
|
||||||
|
final Color? color;
|
||||||
|
final String? parent;
|
||||||
|
final int? index;
|
||||||
|
final SplayTreeMap<String, String> allTags;
|
||||||
|
final SplayTreeMap<String, String> tags;
|
||||||
|
|
||||||
|
// special directory names
|
||||||
|
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
||||||
|
static const xmpDirectory = 'XMP'; // from metadata-extractor
|
||||||
|
static const mediaDirectory = 'Media'; // custom
|
||||||
|
static const coverDirectory = 'Cover'; // custom
|
||||||
|
static const geoTiffDirectory = 'GeoTIFF'; // custom
|
||||||
|
|
||||||
|
const MetadataDirectory(
|
||||||
|
this.name,
|
||||||
|
this.allTags, {
|
||||||
|
SplayTreeMap<String, String>? tags,
|
||||||
|
this.color,
|
||||||
|
this.parent,
|
||||||
|
this.index,
|
||||||
|
}) : tags = tags ?? allTags;
|
||||||
|
|
||||||
|
MetadataDirectory filterKeys(bool Function(String key) testKey) {
|
||||||
|
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
|
||||||
|
return MetadataDirectory(
|
||||||
|
name,
|
||||||
|
tags,
|
||||||
|
tags: filteredTags,
|
||||||
|
color: color,
|
||||||
|
parent: parent,
|
||||||
|
index: index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/geotiff.dart';
|
import 'package:aves/widgets/viewer/info/metadata/geotiff.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.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_tile.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart';
|
||||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/video/keys.dart';
|
import 'package:aves/model/entry_info.dart';
|
||||||
import 'package:aves/model/video/metadata.dart';
|
|
||||||
import 'package:aves/ref/mime_types.dart';
|
|
||||||
import 'package:aves/services/common/services.dart';
|
|
||||||
import 'package:aves/services/metadata/svg_metadata_service.dart';
|
|
||||||
import 'package:aves/theme/colors.dart';
|
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.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_dir.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
@ -39,10 +33,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
|
||||||
|
|
||||||
ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
|
ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
|
||||||
|
|
||||||
// directory names may contain the name of their parent directory (as prefix + '/')
|
|
||||||
// directory names may contain an index (as suffix in '[]')
|
|
||||||
static final directoryNamePattern = RegExp(r'^((?<parent>.*?)/)?(?<name>.*?)(\[(?<index>\d+)\])?$');
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -132,173 +122,8 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _getMetadata() async {
|
Future<void> _getMetadata() async {
|
||||||
final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataFetchService.getAllMetadata(entry));
|
final titledDirectories = await entry.getMetadataDirectories(context);
|
||||||
final directories = rawMetadata.entries.map((dirKV) {
|
|
||||||
var directoryName = dirKV.key as String;
|
|
||||||
|
|
||||||
String? parent;
|
|
||||||
int? index;
|
|
||||||
final match = directoryNamePattern.firstMatch(directoryName);
|
|
||||||
if (match != null) {
|
|
||||||
parent = match.namedGroup('parent');
|
|
||||||
final nameMatch = match.namedGroup('name');
|
|
||||||
if (nameMatch != null) {
|
|
||||||
directoryName = nameMatch;
|
|
||||||
}
|
|
||||||
final indexMatch = match.namedGroup('index');
|
|
||||||
if (indexMatch != null) {
|
|
||||||
index = int.tryParse(indexMatch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final rawTags = dirKV.value as Map;
|
|
||||||
return MetadataDirectory(
|
|
||||||
directoryName,
|
|
||||||
_toSortedTags(rawTags),
|
|
||||||
parent: parent,
|
|
||||||
index: index,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) {
|
|
||||||
directories.addAll(await _getStreamDirectories());
|
|
||||||
}
|
|
||||||
|
|
||||||
final titledDirectories = directories.map((dir) {
|
|
||||||
var title = dir.name;
|
|
||||||
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
|
|
||||||
title = '${dir.parent}/$title';
|
|
||||||
}
|
|
||||||
if (dir.index != null) {
|
|
||||||
title += ' ${dir.index}';
|
|
||||||
}
|
|
||||||
return MapEntry(title, dir);
|
|
||||||
}).toList()
|
|
||||||
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
|
|
||||||
metadataNotifier.value = Map.fromEntries(titledDirectories);
|
metadataNotifier.value = Map.fromEntries(titledDirectories);
|
||||||
_expandedDirectoryNotifier.value = null;
|
_expandedDirectoryNotifier.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<MetadataDirectory>> _getStreamDirectories() async {
|
|
||||||
final directories = <MetadataDirectory>[];
|
|
||||||
final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(entry);
|
|
||||||
|
|
||||||
final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo);
|
|
||||||
if (formattedMediaTags.isNotEmpty) {
|
|
||||||
// overwrite generic directory found from the platform side
|
|
||||||
directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaInfo.containsKey(Keys.streams)) {
|
|
||||||
String getTypeText(Map stream) {
|
|
||||||
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
|
|
||||||
switch (type) {
|
|
||||||
case StreamTypes.attachment:
|
|
||||||
return 'Attachment';
|
|
||||||
case StreamTypes.audio:
|
|
||||||
return 'Audio';
|
|
||||||
case StreamTypes.metadata:
|
|
||||||
return 'Metadata';
|
|
||||||
case StreamTypes.subtitle:
|
|
||||||
case StreamTypes.timedText:
|
|
||||||
return 'Text';
|
|
||||||
case StreamTypes.video:
|
|
||||||
return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
|
|
||||||
case StreamTypes.unknown:
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
|
|
||||||
final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
|
|
||||||
final knownStreams = allStreams.whereNot(attachmentStreams.contains);
|
|
||||||
|
|
||||||
// display known streams as separate directories (e.g. video, audio, subs)
|
|
||||||
if (knownStreams.isNotEmpty) {
|
|
||||||
final indexDigits = knownStreams.length.toString().length;
|
|
||||||
|
|
||||||
final colors = context.read<AvesColorsData>();
|
|
||||||
for (final stream in knownStreams) {
|
|
||||||
final index = (stream[Keys.index] ?? 0) + 1;
|
|
||||||
final typeText = getTypeText(stream);
|
|
||||||
final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText';
|
|
||||||
final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
|
|
||||||
if (formattedStreamTags.isNotEmpty) {
|
|
||||||
final color = colors.fromString(typeText);
|
|
||||||
directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// group attachments by format (e.g. TTF fonts)
|
|
||||||
if (attachmentStreams.isNotEmpty) {
|
|
||||||
final formatCount = <String, List<String?>>{};
|
|
||||||
for (final stream in attachmentStreams) {
|
|
||||||
final codec = (stream[Keys.codecName] as String? ?? 'unknown').toUpperCase();
|
|
||||||
if (!formatCount.containsKey(codec)) {
|
|
||||||
formatCount[codec] = [];
|
|
||||||
}
|
|
||||||
formatCount[codec]!.add(stream[Keys.filename]);
|
|
||||||
}
|
|
||||||
if (formatCount.isNotEmpty) {
|
|
||||||
final rawTags = formatCount.map((key, value) {
|
|
||||||
final count = value.length;
|
|
||||||
// remove duplicate names, so number of displayed names may not match displayed count
|
|
||||||
final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase);
|
|
||||||
return MapEntry(key, '$count items: ${names.join(', ')}');
|
|
||||||
});
|
|
||||||
directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return directories;
|
|
||||||
}
|
|
||||||
|
|
||||||
SplayTreeMap<String, String> _toSortedTags(Map rawTags) {
|
|
||||||
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
|
|
||||||
var value = (tagKV.value as String? ?? '').trim();
|
|
||||||
if (value.isEmpty) return null;
|
|
||||||
final tagName = tagKV.key as String;
|
|
||||||
return MapEntry(tagName, value);
|
|
||||||
}).whereNotNull()));
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MetadataDirectory {
|
|
||||||
final String name;
|
|
||||||
final Color? color;
|
|
||||||
final String? parent;
|
|
||||||
final int? index;
|
|
||||||
final SplayTreeMap<String, String> allTags;
|
|
||||||
final SplayTreeMap<String, String> tags;
|
|
||||||
|
|
||||||
// special directory names
|
|
||||||
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
|
|
||||||
static const xmpDirectory = 'XMP'; // from metadata-extractor
|
|
||||||
static const mediaDirectory = 'Media'; // custom
|
|
||||||
static const coverDirectory = 'Cover'; // custom
|
|
||||||
static const geoTiffDirectory = 'GeoTIFF'; // custom
|
|
||||||
|
|
||||||
const MetadataDirectory(
|
|
||||||
this.name,
|
|
||||||
this.allTags, {
|
|
||||||
SplayTreeMap<String, String>? tags,
|
|
||||||
this.color,
|
|
||||||
this.parent,
|
|
||||||
this.index,
|
|
||||||
}) : tags = tags ?? allTags;
|
|
||||||
|
|
||||||
MetadataDirectory filterKeys(bool Function(String key) testKey) {
|
|
||||||
final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key))));
|
|
||||||
return MetadataDirectory(
|
|
||||||
name,
|
|
||||||
tags,
|
|
||||||
tags: filteredTags,
|
|
||||||
color: color,
|
|
||||||
parent: parent,
|
|
||||||
index: index,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,17 @@
|
||||||
{
|
{
|
||||||
|
"de": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
"el": [
|
"el": [
|
||||||
|
"entryInfoActionExportMetadata",
|
||||||
"tagEditorSectionPlaceholders"
|
"tagEditorSectionPlaceholders"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"es": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
"fa": [
|
"fa": [
|
||||||
"appName",
|
"appName",
|
||||||
"welcomeMessage",
|
"welcomeMessage",
|
||||||
|
@ -89,6 +98,7 @@
|
||||||
"entryInfoActionEditRating",
|
"entryInfoActionEditRating",
|
||||||
"entryInfoActionEditTags",
|
"entryInfoActionEditTags",
|
||||||
"entryInfoActionRemoveMetadata",
|
"entryInfoActionRemoveMetadata",
|
||||||
|
"entryInfoActionExportMetadata",
|
||||||
"filterBinLabel",
|
"filterBinLabel",
|
||||||
"filterFavouriteLabel",
|
"filterFavouriteLabel",
|
||||||
"filterNoDateLabel",
|
"filterNoDateLabel",
|
||||||
|
@ -585,7 +595,12 @@
|
||||||
"filePickerUseThisFolder"
|
"filePickerUseThisFolder"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"fr": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
"gl": [
|
"gl": [
|
||||||
|
"entryInfoActionExportMetadata",
|
||||||
"accessibilityAnimationsRemove",
|
"accessibilityAnimationsRemove",
|
||||||
"accessibilityAnimationsKeep",
|
"accessibilityAnimationsKeep",
|
||||||
"displayRefreshRatePreferHighest",
|
"displayRefreshRatePreferHighest",
|
||||||
|
@ -1034,14 +1049,28 @@
|
||||||
"filePickerUseThisFolder"
|
"filePickerUseThisFolder"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"id": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
|
"it": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
"ja": [
|
"ja": [
|
||||||
"chipActionFilterIn"
|
"chipActionFilterIn",
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ko": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
],
|
],
|
||||||
|
|
||||||
"nb": [
|
"nb": [
|
||||||
"videoActionCaptureFrame",
|
"videoActionCaptureFrame",
|
||||||
"videoActionSelectStreams",
|
"videoActionSelectStreams",
|
||||||
"entryInfoActionEditLocation",
|
"entryInfoActionEditLocation",
|
||||||
|
"entryInfoActionExportMetadata",
|
||||||
"coordinateFormatDms",
|
"coordinateFormatDms",
|
||||||
"mapStyleHuaweiNormal",
|
"mapStyleHuaweiNormal",
|
||||||
"mapStyleHuaweiTerrain",
|
"mapStyleHuaweiTerrain",
|
||||||
|
@ -1151,12 +1180,17 @@
|
||||||
"tagPlaceholderPlace"
|
"tagPlaceholderPlace"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"nl": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
"pl": [
|
"pl": [
|
||||||
"itemCount",
|
"itemCount",
|
||||||
"timeSeconds",
|
"timeSeconds",
|
||||||
"timeMinutes",
|
"timeMinutes",
|
||||||
"timeDays",
|
"timeDays",
|
||||||
"focalLength",
|
"focalLength",
|
||||||
|
"entryInfoActionExportMetadata",
|
||||||
"filterTypeRawLabel",
|
"filterTypeRawLabel",
|
||||||
"filterTypeSphericalVideoLabel",
|
"filterTypeSphericalVideoLabel",
|
||||||
"filterTypeGeotiffLabel",
|
"filterTypeGeotiffLabel",
|
||||||
|
@ -1640,7 +1674,20 @@
|
||||||
"filePickerUseThisFolder"
|
"filePickerUseThisFolder"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"pt": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
|
"tr": [
|
||||||
|
"entryInfoActionExportMetadata"
|
||||||
|
],
|
||||||
|
|
||||||
"zh": [
|
"zh": [
|
||||||
|
"entryInfoActionExportMetadata",
|
||||||
"editEntryLocationDialogSetCustom",
|
"editEntryLocationDialogSetCustom",
|
||||||
"settingsAllowMediaManagement",
|
"settingsAllowMediaManagement",
|
||||||
"tagEditorSectionPlaceholders",
|
"tagEditorSectionPlaceholders",
|
||||||
|
|
Loading…
Reference in a new issue