From ee59b6ae7321dad249e77386ba7c53c01595cdd0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 14 Jul 2021 15:42:25 +0900 Subject: [PATCH] motion photos: handle definition from Container namespace --- .../aves/channel/calls/EmbeddedDataHandler.kt | 16 ++-- .../thibault/aves/metadata/MultiPage.kt | 19 ++++- .../deckers/thibault/aves/metadata/XMP.kt | 65 +++++++++++++-- lib/l10n/app_en.arb | 2 + lib/l10n/app_ko.arb | 1 + lib/model/actions/entry_actions.dart | 9 ++ .../viewer/viewer_actions_editor.dart | 1 - .../viewer/embedded/embedded_data_opener.dart | 83 +++++++++++++++++++ .../viewer/embedded/notifications.dart | 37 +++++++++ lib/widgets/viewer/entry_action_delegate.dart | 24 +++--- lib/widgets/viewer/entry_vertical_pager.dart | 2 +- lib/widgets/viewer/entry_viewer_stack.dart | 48 +++++------ lib/widgets/viewer/info/info_page.dart | 52 ++++-------- lib/widgets/viewer/info/info_search.dart | 23 +---- .../info/metadata/metadata_dir_tile.dart | 58 +------------ .../viewer/info/metadata/xmp_namespaces.dart | 2 - .../viewer/info/metadata/xmp_ns/google.dart | 34 +------- .../viewer/info/metadata/xmp_ns/xmp.dart | 2 +- lib/widgets/viewer/info/notifications.dart | 53 +----------- lib/widgets/viewer/overlay/top.dart | 48 +++++------ 20 files changed, 296 insertions(+), 283 deletions(-) create mode 100644 lib/widgets/viewer/embedded/embedded_data_opener.dart create mode 100644 lib/widgets/viewer/embedded/notifications.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index 6a663064e..6e0f1252e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -17,6 +17,7 @@ import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MultiPage import deckers.thibault.aves.metadata.XMP +import deckers.thibault.aves.metadata.XMP.getSafeStructField import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.provider.ContentImageProvider import deckers.thibault.aves.model.provider.ImageProvider @@ -157,18 +158,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { // which is returned as a second XMP directory val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) try { - val pathParts = dataPropPath.split('/') - - val embedBytes: ByteArray = if (pathParts.size == 1) { - val propName = pathParts[0] - val propNs = XMP.namespaceForPropPath(propName) - xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null } + val embedBytes: ByteArray = if (!dataPropPath.contains('/')) { + val propNs = XMP.namespaceForPropPath(dataPropPath) + xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first() } else { - val structName = pathParts[0] - val structNs = XMP.namespaceForPropPath(structName) - val fieldName = pathParts[1] - val fieldNs = XMP.namespaceForPropPath(fieldName) - xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let { + xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let { XMPUtils.decodeBase64(it.value) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index eb9597b7d..96bcbbbaa 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -10,6 +10,7 @@ import android.util.Log import com.drew.imaging.ImageMetadataReader import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.metadata.XMP.getSafeLong +import deckers.thibault.aves.metadata.XMP.getSafeStructField import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes @@ -140,7 +141,23 @@ object MultiPage { val metadata = ImageMetadataReader.readMetadata(input) for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { var offsetFromEnd: Long? = null - dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } + val xmpMeta = dir.xmpMeta + if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) { + // GCamera motion photo + xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it } + } else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) { + // Container motion photo + val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME) + if (count == 2) { + // expect the video to be the second item + val i = 2 + val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value + val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value + if (MimeTypes.isVideo(mime) && length != null) { + offsetFromEnd = length.toLong() + } + } + } return offsetFromEnd } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index d1d24bd87..e649bfacf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -4,7 +4,9 @@ import android.util.Log import com.adobe.internal.xmp.XMPError import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPMeta +import com.adobe.internal.xmp.properties.XMPProperty import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.MimeTypes import java.util.* object XMP { @@ -15,6 +17,15 @@ object XMP { const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" + private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" + + // other namespaces + private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" + const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/" + private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" + const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" + const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/" + private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/" const val SUBJECT_PROP_NAME = "dc:subject" const val TITLE_PROP_NAME = "dc:title" @@ -26,11 +37,13 @@ object XMP { private const val SPECIFIC_LANG = "en-US" private val schemas = hashMapOf( - "GAudio" to "http://ns.google.com/photos/1.0/audio/", - "GDepth" to "http://ns.google.com/photos/1.0/depthmap/", - "GImage" to "http://ns.google.com/photos/1.0/image/", + "Container" to CONTAINER_SCHEMA_NS, + "GAudio" to GAUDIO_SCHEMA_NS, + "GDepth" to GDEPTH_SCHEMA_NS, + "GImage" to GIMAGE_SCHEMA_NS, + "Item" to CONTAINER_ITEM_SCHEMA_NS, "xmp" to XMP_SCHEMA_NS, - "xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/", + "xmpGImg" to XMP_GIMG_SCHEMA_NS, ) fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]] @@ -44,9 +57,11 @@ object XMP { // motion photo - const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/" - const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset" + const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory" + const val CONTAINER_ITEM_PROP_NAME = "Container:Item" + const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length" + const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime" // panorama // cf https://developers.google.com/streetview/spherical-metadata @@ -79,7 +94,26 @@ object XMP { fun XMPMeta.isMotionPhoto(): Boolean { try { - return doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME) + // GCamera motion photo + if (doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true + + // Container motion photo + if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) { + val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME) + if (count == 2) { + var hasImage = false + var hasVideo = false + for (i in 1 until count + 1) { + val mime = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_MIME_PROP_NAME")?.value + val length = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_LENGTH_PROP_NAME")?.value + hasImage = hasImage || MimeTypes.isImage(mime) && length != null + hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null + } + if (hasImage && hasVideo) return true + } + } + + return false } catch (e: XMPException) { if (e.errorCode != XMPError.BADSCHEMA) { // `BADSCHEMA` code is reported when we check a property @@ -188,4 +222,21 @@ object XMP { Log.w(LOG_TAG, "failed to get date for XMP schema=$schema, propName=$propName", e) } } + + // e.g. 'Container:Directory[42]/Container:Item/Item:Mime' + fun XMPMeta.getSafeStructField(path: String): XMPProperty? { + val separator = path.lastIndexOf("/") + if (separator != -1) { + val structName = path.substring(0, separator) + val structNs = namespaceForPropPath(structName) + val fieldName = path.substring(separator + 1) + val fieldNs = namespaceForPropPath(fieldName) + try { + return getStructField(structNs, structName, fieldNs, fieldName) + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e) + } + } + return null + } } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9a64af079..2345a0a46 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -82,6 +82,8 @@ "@entryActionShare": {}, "entryActionViewSource": "View source", "@entryActionViewSource": {}, + "entryActionViewMotionPhotoVideo": "Open Motion Photo", + "@entryActionViewMotionPhotoVideo": {}, "entryActionEdit": "Edit with…", "@entryActionEdit": {}, "entryActionOpen": "Open with…", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 96def8ff9..b847c2b92 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -41,6 +41,7 @@ "entryActionPrint": "인쇄", "entryActionShare": "공유", "entryActionViewSource": "소스 코드 보기", + "entryActionViewMotionPhotoVideo": "모션 포토 보기", "entryActionEdit": "편집…", "entryActionOpen": "다른 앱에서 열기…", "entryActionSetAs": "다음 용도로 사용…", diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index e24dfbec4..b8e90c626 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -16,6 +16,8 @@ enum EntryAction { flip, // vector viewSource, + // motion photo, + viewMotionPhotoVideo, // external edit, open, @@ -42,6 +44,7 @@ class EntryActions { EntryAction.export, EntryAction.print, EntryAction.viewSource, + EntryAction.viewMotionPhotoVideo, EntryAction.rotateScreen, ]; @@ -87,6 +90,9 @@ extension ExtraEntryAction on EntryAction { // vector case EntryAction.viewSource: return context.l10n.entryActionViewSource; + // motion photo + case EntryAction.viewMotionPhotoVideo: + return context.l10n.entryActionViewMotionPhotoVideo; // external case EntryAction.edit: return context.l10n.entryActionEdit; @@ -132,6 +138,9 @@ extension ExtraEntryAction on EntryAction { // vector case EntryAction.viewSource: return AIcons.vector; + // motion photo + case EntryAction.viewMotionPhotoVideo: + return AIcons.motionPhoto; // external case EntryAction.edit: case EntryAction.open: diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index 34002df71..33eebc9ce 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -38,7 +38,6 @@ class ViewerActionEditorPage extends StatelessWidget { EntryAction.export, EntryAction.print, EntryAction.rotateScreen, - EntryAction.viewSource, EntryAction.flip, EntryAction.rotateCCW, EntryAction.rotateCW, diff --git a/lib/widgets/viewer/embedded/embedded_data_opener.dart b/lib/widgets/viewer/embedded/embedded_data_opener.dart new file mode 100644 index 000000000..a621d1ba9 --- /dev/null +++ b/lib/widgets/viewer/embedded/embedded_data_opener.dart @@ -0,0 +1,83 @@ +import 'package:aves/model/entry.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/utils/pedantic.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/viewer/embedded/notifications.dart'; +import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin { + final AvesEntry entry; + final Widget child; + + const EmbeddedDataOpener({ + Key? key, + required this.entry, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (notification) { + _openEmbeddedData(context, notification); + return true; + }, + child: child, + ); + } + + Future _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async { + late Map fields; + switch (notification.source) { + case EmbeddedDataSource.motionPhotoVideo: + fields = await embeddedDataService.extractMotionPhotoVideo(entry); + break; + case EmbeddedDataSource.videoCover: + fields = await embeddedDataService.extractVideoEmbeddedPicture(entry); + break; + case EmbeddedDataSource.xmp: + fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType); + break; + } + if (!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; + } + + _openTempEntry(context, AvesEntry.fromMap(fields)); + } + + void _openTempEntry(BuildContext context, AvesEntry tempEntry) { + Navigator.push( + context, + TransparentMaterialPageRoute( + settings: const RouteSettings(name: EntryViewerPage.routeName), + pageBuilder: (c, a, sa) => EntryViewerPage( + initialEntry: tempEntry, + ), + ), + ); + } +} diff --git a/lib/widgets/viewer/embedded/notifications.dart b/lib/widgets/viewer/embedded/notifications.dart new file mode 100644 index 000000000..d58aba26b --- /dev/null +++ b/lib/widgets/viewer/embedded/notifications.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp } + +class OpenEmbeddedDataNotification extends Notification { + final EmbeddedDataSource source; + final String? propPath; + final String? mimeType; + + const OpenEmbeddedDataNotification._private({ + required this.source, + this.propPath, + this.mimeType, + }); + + factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private( + source: EmbeddedDataSource.motionPhotoVideo, + ); + + factory OpenEmbeddedDataNotification.videoCover() => const OpenEmbeddedDataNotification._private( + source: EmbeddedDataSource.videoCover, + ); + + factory OpenEmbeddedDataNotification.xmp({ + required String propPath, + required String mimeType, + }) => + OpenEmbeddedDataNotification._private( + source: EmbeddedDataSource.xmp, + propPath: propPath, + mimeType: mimeType, + ); + + @override + String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}'; +} diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index fa5b8ba86..36f034436 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -23,23 +23,15 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; +import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/printer.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { - final CollectionLens? collection; - final VoidCallback showInfo; - - EntryActionDelegate({ - required this.collection, - required this.showInfo, - }); - void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) { switch (action) { case EntryAction.toggleFavourite: @@ -52,7 +44,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix _showExportDialog(context, entry); break; case EntryAction.info: - showInfo(); + ShowInfoNotification().dispatch(context); break; case EntryAction.rename: _showRenameDialog(context, entry); @@ -100,6 +92,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.viewSource: _goToSourceViewer(context, entry); break; + case EntryAction.viewMotionPhotoVideo: + OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); + break; case EntryAction.debug: _goToDebug(context, entry); break; @@ -158,8 +153,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await entry.delete()) { showFeedback(context, context.l10n.genericFailureFeedback); } else { - if (collection != null) { - await collection!.source.removeEntries({entry.uri}); + final source = context.read(); + if (source.initialized) { + await source.removeEntries({entry.uri}); } EntryDeletedNotification(entry).dispatch(context); } @@ -212,8 +208,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix onDone: (processed) { final movedOps = processed.where((e) => e.success); final movedCount = movedOps.length; - final _collection = collection; - final showAction = _collection != null && movedCount > 0 + final isMainMode = context.read>().value == AppMode.main; + final showAction = isMainMode && movedCount > 0 ? SnackBarAction( label: context.l10n.showButtonLabel, onPressed: () async { diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 211c17220..c278ad88a 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -98,7 +98,7 @@ class _ViewerVerticalPageViewState extends State { ) : const SizedBox(); - final infoPage = NotificationListener( + final infoPage = NotificationListener( onNotification: (notification) { widget.onImagePageRequested(); return true; diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index e2f38a1e4..982e7a315 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; @@ -12,8 +11,9 @@ import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/viewer/entry_action_delegate.dart'; +import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; @@ -49,7 +49,7 @@ class EntryViewerStack extends StatefulWidget { _EntryViewerStackState createState() => _EntryViewerStackState(); } -class _EntryViewerStackState extends State with SingleTickerProviderStateMixin, WidgetsBindingObserver { +class _EntryViewerStackState extends State with FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { final ValueNotifier _entryNotifier = ValueNotifier(null); late int _currentHorizontalPage; late ValueNotifier _currentVerticalPage; @@ -60,7 +60,6 @@ class _EntryViewerStackState extends State with SingleTickerPr late Animation _topOverlayScale, _bottomOverlayScale; late Animation _bottomOverlayOffset; EdgeInsets? _frozenViewInsets, _frozenViewPadding; - late EntryActionDelegate _entryActionDelegate; late VideoActionDelegate _videoActionDelegate; final List>> _viewStateNotifiers = []; final ValueNotifier _heroInfoNotifier = ValueNotifier(null); @@ -109,10 +108,6 @@ class _EntryViewerStackState extends State with SingleTickerPr curve: Curves.easeOutQuad, )); _overlayVisible.addListener(_onOverlayVisibleChange); - _entryActionDelegate = EntryActionDelegate( - collection: collection, - showInfo: () => _goToVerticalPage(infoPage), - ); _videoActionDelegate = VideoActionDelegate( collection: collection, ); @@ -229,27 +224,22 @@ class _EntryViewerStackState extends State with SingleTickerPr builder: (context, mainEntry, child) { if (mainEntry == null) return const SizedBox.shrink(); - return ViewerTopOverlay( - mainEntry: mainEntry, - scale: _topOverlayScale, - canToggleFavourite: hasCollection, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - onActionSelected: (action) { - var targetEntry = mainEntry; - if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) { - final multiPageController = context.read().getController(mainEntry); - if (multiPageController != null) { - final multiPageInfo = multiPageController.info; - final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page); - if (pageEntry != null) { - targetEntry = pageEntry; - } - } - } - _entryActionDelegate.onActionSelected(context, targetEntry, action); + return NotificationListener( + onNotification: (notification) { + _goToVerticalPage(infoPage); + return true; }, - viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2, + child: EmbeddedDataOpener( + entry: mainEntry, + child: ViewerTopOverlay( + mainEntry: mainEntry, + scale: _topOverlayScale, + canToggleFavourite: hasCollection, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2, + ), + ), ); }, ); @@ -421,7 +411,7 @@ class _EntryViewerStackState extends State with SingleTickerPr void _onVerticalPageChanged(int page) { _currentVerticalPage.value = page; if (page == transitionPage) { - _entryActionDelegate.dismissFeedback(context); + dismissFeedback(context); _popVisual(); } else if (page == infoPage) { // prevent hero when viewer is offscreen diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 055b658cf..0e6eda22f 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -3,9 +3,8 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +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/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; @@ -47,31 +46,28 @@ class _InfoPageState extends State { bottom: false, child: NotificationListener( onNotification: _handleTopScroll, - child: NotificationListener( - onNotification: (notification) { - _openTempEntry(notification.entry); - return true; - }, - child: Selector( - selector: (c, mq) => mq.size.width, - builder: (c, mqWidth, child) { - return ValueListenableBuilder( - valueListenable: widget.entryNotifier, - builder: (context, entry, child) { - return entry != null - ? _InfoPageContent( + child: Selector( + selector: (c, mq) => mq.size.width, + builder: (c, mqWidth, child) { + return ValueListenableBuilder( + valueListenable: widget.entryNotifier, + builder: (context, entry, child) { + return entry != null + ? EmbeddedDataOpener( + entry: entry, + child: _InfoPageContent( collection: collection, entry: entry, isScrollingNotifier: widget.isScrollingNotifier, scrollController: _scrollController, split: mqWidth > 600, goToViewer: _goToViewer, - ) - : const SizedBox.shrink(); - }, - ); - }, - ), + ), + ) + : const SizedBox.shrink(); + }, + ); + }, ), ), ), @@ -102,25 +98,13 @@ class _InfoPageState extends State { } void _goToViewer() { - BackUpNotification().dispatch(context); + ShowImageNotification().dispatch(context); _scrollController.animateTo( 0, duration: Durations.viewerVerticalPageScrollAnimation, curve: Curves.easeInOut, ); } - - void _openTempEntry(AvesEntry tempEntry) { - Navigator.push( - context, - TransparentMaterialPageRoute( - settings: const RouteSettings(name: EntryViewerPage.routeName), - pageBuilder: (c, a, sa) => EntryViewerPage( - initialEntry: tempEntry, - ), - ), - ); - } } class _InfoPageContent extends StatefulWidget { diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 6bc972e68..445535c8b 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -1,12 +1,10 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/viewer/entry_viewer_page.dart'; +import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -112,11 +110,8 @@ class InfoSearchDelegate extends SearchDelegate { icon: AIcons.info, text: context.l10n.viewerInfoSearchEmpty, ) - : NotificationListener( - onNotification: (notification) { - _openTempEntry(context, notification.entry); - return true; - }, + : EmbeddedDataOpener( + entry: entry, child: ListView.builder( padding: const EdgeInsets.all(8), itemBuilder: (context, index) => tiles[index], @@ -125,16 +120,4 @@ class InfoSearchDelegate extends SearchDelegate { ), ); } - - void _openTempEntry(BuildContext context, AvesEntry tempEntry) { - Navigator.push( - context, - TransparentMaterialPageRoute( - settings: const RouteSettings(name: EntryViewerPage.routeName), - pageBuilder: (c, a, sa) => EntryViewerPage( - initialEntry: tempEntry, - ), - ), - ); - } } diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 476c43afa..7961b253e 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -2,27 +2,21 @@ import 'dart:collection'; import 'package:aves/model/entry.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/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/utils/pedantic.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/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/viewer/embedded/notifications.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_thumbnail.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:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class MetadataDirTile extends StatelessWidget with FeedbackMixin { +class MetadataDirTile extends StatelessWidget { final AvesEntry entry; final String title; final MetadataDirectory dir; @@ -45,9 +39,8 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin { if (tags.isEmpty) return const SizedBox.shrink(); final dirName = dir.name; - Widget tile; if (dirName == MetadataDirectory.xmpDirectory) { - tile = XmpDirTile( + return XmpDirTile( entry: entry, tags: tags, expandedNotifier: expandedDirectoryNotifier, @@ -64,7 +57,7 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin { break; } - tile = AvesExpansionTile( + return AvesExpansionTile( title: title, color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName), expandedNotifier: expandedDirectoryNotifier, @@ -82,13 +75,6 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin { ], ); } - return NotificationListener( - onNotification: (notification) { - _openEmbeddedData(context, notification); - return true; - }, - child: tile, - ); } static Map getSvgLinkHandlers(SplayTreeMap tags) { @@ -118,40 +104,4 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin { ), }; } - - Future _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async { - late Map fields; - switch (notification.source) { - case EmbeddedDataSource.motionPhotoVideo: - fields = await embeddedDataService.extractMotionPhotoVideo(entry); - break; - case EmbeddedDataSource.videoCover: - fields = await embeddedDataService.extractVideoEmbeddedPicture(entry); - break; - case EmbeddedDataSource.xmp: - fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType); - break; - } - if (!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); - } } diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 2eb7b2c87..c4c056260 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -29,8 +29,6 @@ class XmpNamespace { return XmpExifNamespace(rawProps); case XmpGAudioNamespace.ns: return XmpGAudioNamespace(rawProps); - case XmpGCameraNamespace.ns: - return XmpGCameraNamespace(rawProps); case XmpGDepthNamespace.ns: return XmpGDepthNamespace(rawProps); case XmpGImageNamespace.ns: diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index 61a5435bd..fa4f8784b 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -1,7 +1,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:collection/collection.dart'; import 'package:tuple/tuple.dart'; @@ -70,35 +70,3 @@ class XmpGImageNamespace extends XmpGoogleNamespace { @override String get displayTitle => 'Google Image'; } - -class XmpGCameraNamespace extends XmpNamespace { - static const ns = 'GCamera'; - static const videoOffsetKey = 'GCamera:MicroVideoOffset'; - static const videoDataKey = 'Data'; - - late bool _isMotionPhoto; - - XmpGCameraNamespace(Map rawProps) : super(ns, rawProps) { - _isMotionPhoto = rawProps.keys.any((key) => key == videoOffsetKey); - } - - @override - Map get buildProps { - return _isMotionPhoto - ? Map.fromEntries({ - const MapEntry(videoDataKey, '[skipped]'), - ...rawProps.entries, - }) - : rawProps; - } - - @override - Map linkifyValues(List props) { - return { - videoDataKey: InfoLinkHandler( - linkText: (context) => context.l10n.viewerInfoOpenLinkText, - onTap: (context) => OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context), - ), - }; - } -} diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart index 6d007bcdb..51dd671e4 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart @@ -1,9 +1,9 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; -import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:flutter/material.dart'; class XmpBasicNamespace extends XmpNamespace { diff --git a/lib/widgets/viewer/info/notifications.dart b/lib/widgets/viewer/info/notifications.dart index 54a0ccfa3..1e97fafa8 100644 --- a/lib/widgets/viewer/info/notifications.dart +++ b/lib/widgets/viewer/info/notifications.dart @@ -1,9 +1,10 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; -class BackUpNotification extends Notification {} +class ShowImageNotification extends Notification {} + +class ShowInfoNotification extends Notification {} class FilterSelectedNotification extends Notification { final CollectionFilter filter; @@ -16,49 +17,3 @@ class EntryDeletedNotification extends Notification { const EntryDeletedNotification(this.entry); } - -class OpenTempEntryNotification extends Notification { - final AvesEntry entry; - - const OpenTempEntryNotification({ - required this.entry, - }); - - @override - String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}'; -} - -enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp } - -class OpenEmbeddedDataNotification extends Notification { - final EmbeddedDataSource source; - final String? propPath; - final String? mimeType; - - const OpenEmbeddedDataNotification._private({ - required this.source, - this.propPath, - this.mimeType, - }); - - factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private( - source: EmbeddedDataSource.motionPhotoVideo, - ); - - factory OpenEmbeddedDataNotification.videoCover() => const OpenEmbeddedDataNotification._private( - source: EmbeddedDataSource.videoCover, - ); - - factory OpenEmbeddedDataNotification.xmp({ - required String propPath, - required String mimeType, - }) => - OpenEmbeddedDataNotification._private( - source: EmbeddedDataSource.xmp, - propPath: propPath, - mimeType: mimeType, - ); - - @override - String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}'; -} diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index d0e109526..1c4e9941d 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -8,6 +8,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; +import 'package:aves/widgets/viewer/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart'; @@ -21,7 +22,6 @@ class ViewerTopOverlay extends StatelessWidget { final AvesEntry mainEntry; final Animation scale; final EdgeInsets? viewInsets, viewPadding; - final Function(EntryAction value) onActionSelected; final bool canToggleFavourite; final ValueNotifier? viewStateNotifier; @@ -35,7 +35,6 @@ class ViewerTopOverlay extends StatelessWidget { required this.canToggleFavourite, required this.viewInsets, required this.viewPadding, - required this.onActionSelected, required this.viewStateNotifier, }) : super(key: key); @@ -99,6 +98,8 @@ class ViewerTopOverlay extends StatelessWidget { return targetEntry.hasGps; case EntryAction.viewSource: return targetEntry.isSvg; + case EntryAction.viewMotionPhotoVideo: + return targetEntry.isMotionPhoto; case EntryAction.rotateScreen: return settings.isRotationLocked; case EntryAction.share: @@ -125,7 +126,6 @@ class ViewerTopOverlay extends StatelessWidget { scale: scale, mainEntry: mainEntry, pageEntry: pageEntry!, - onActionSelected: onActionSelected, ); }, ); @@ -153,7 +153,6 @@ class _TopOverlayRow extends StatelessWidget { final List quickActions, inAppActions, externalAppActions; final Animation scale; final AvesEntry mainEntry, pageEntry; - final Function(EntryAction value) onActionSelected; const _TopOverlayRow({ Key? key, @@ -163,7 +162,6 @@ class _TopOverlayRow extends StatelessWidget { required this.scale, required this.mainEntry, required this.pageEntry, - required this.onActionSelected, }) : super(key: key); @override @@ -192,7 +190,7 @@ class _TopOverlayRow extends StatelessWidget { ], onSelected: (action) { // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action)); + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); }, ), ), @@ -202,7 +200,7 @@ class _TopOverlayRow extends StatelessWidget { Widget _buildOverlayButton(BuildContext context, EntryAction action) { Widget? child; - void onPressed() => onActionSelected(action); + void onPressed() => _onActionSelected(context, action); switch (action) { case EntryAction.toggleFavourite: child = _FavouriteToggler( @@ -221,6 +219,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.share: case EntryAction.rotateScreen: case EntryAction.viewSource: + case EntryAction.viewMotionPhotoVideo: child = IconButton( icon: Icon(action.getIcon()), onPressed: onPressed, @@ -255,27 +254,9 @@ class _TopOverlayRow extends StatelessWidget { isMenuItem: true, ); break; - case EntryAction.delete: - case EntryAction.export: - case EntryAction.flip: - case EntryAction.info: - case EntryAction.print: - case EntryAction.rename: - case EntryAction.rotateCCW: - case EntryAction.rotateCW: - case EntryAction.share: - case EntryAction.rotateScreen: - case EntryAction.viewSource: - case EntryAction.debug: + default: child = MenuRow(text: action.getText(context), icon: action.getIcon()); break; - // external app actions - case EntryAction.edit: - case EntryAction.open: - case EntryAction.setAs: - case EntryAction.openMap: - child = Text(action.getText(context)); - break; } return PopupMenuItem( value: action, @@ -316,6 +297,21 @@ class _TopOverlayRow extends StatelessWidget { ), ); } + + void _onActionSelected(BuildContext context, EntryAction action) { + var targetEntry = mainEntry; + if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) { + final multiPageController = context.read().getController(mainEntry); + if (multiPageController != null) { + final multiPageInfo = multiPageController.info; + final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page); + if (pageEntry != null) { + targetEntry = pageEntry; + } + } + } + EntryActionDelegate().onActionSelected(context, targetEntry, action); + } } class _FavouriteToggler extends StatefulWidget {