From e127a5ebca35d8241ff47ac5cbaccd26b1eef149 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 28 Dec 2020 12:50:10 +0900 Subject: [PATCH 01/10] info: added metadata for Spherical Video V1 --- .../aves/channel/calls/MetadataHandler.kt | 14 ++- .../thibault/aves/metadata/Metadata.kt | 3 - .../thibault/aves/metadata/SphericalVideo.kt | 108 ++++++++++++++++++ 3 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 0d2d41135..22752003f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -21,17 +21,15 @@ import com.drew.metadata.iptc.IptcDirectory import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.xmp.XmpDirectory +import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDouble import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational -import deckers.thibault.aves.metadata.Geotiff -import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt -import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean @@ -40,7 +38,6 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff -import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.isPanorama @@ -142,6 +139,13 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // remove this stat as it is not actual XMP data dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT)) } + + if (dir is Mp4UuidBoxDirectory) { + if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) { + val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) + metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) + } + } } } } catch (e: Exception) { @@ -348,7 +352,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { // identification of spherical video (aka 360° video) if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any { - it.getString(Mp4UuidBoxDirectory.TAG_UUID) == Metadata.SPHERICAL_VIDEO_V1_UUID + it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID }) { flags = flags or MASK_IS_360 } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index fb6750232..47b649f64 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -19,9 +19,6 @@ object Metadata { // "+51.3328-000.7053+113.474/" (Apple) val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*") - // cf https://github.com/google/spatial-media - const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd" - // directory names, as shown when listing all metadata const val DIR_GPS = "GPS" // from metadata-extractor const val DIR_XMP = "XMP" // from metadata-extractor diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt new file mode 100644 index 000000000..6bba753d9 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt @@ -0,0 +1,108 @@ +package deckers.thibault.aves.metadata + +import android.util.Log +import android.util.Xml +import deckers.thibault.aves.utils.LogUtils +import org.xmlpull.v1.XmlPullParser +import java.io.ByteArrayInputStream + +class GSpherical(bytes: ByteArray) { + var spherical: Boolean = false + var stitched: Boolean = false + var stitchingSoftware: String = "" + var projectionType: String = "" + var stereoMode: String? = null + var sourceCount: Int? = null + var initialViewHeadingDegrees: Int? = null + var initialViewPitchDegrees: Int? = null + var initialViewRollDegrees: Int? = null + var timestamp: Int? = null + var fullPanoWidthPixels: Int? = null + var fullPanoHeightPixels: Int? = null + var croppedAreaImageWidthPixels: Int? = null + var croppedAreaImageHeightPixels: Int? = null + var croppedAreaLeftPixels: Int? = null + var croppedAreaTopPixels: Int? = null + + init { + try { + ByteArrayInputStream(bytes).use { + val parser = Xml.newPullParser().apply { + setInput(it, null) + nextTag() + require(XmlPullParser.START_TAG, RDF_NS, "SphericalVideo") + } + while (parser.next() != XmlPullParser.END_TAG) { + if (parser.eventType != XmlPullParser.START_TAG) continue + if (parser.namespace == GSPHERICAL_NS) { + when (val tag = parser.name) { + "Spherical" -> spherical = readTag(parser, tag) == "true" + "Stitched" -> stitched = readTag(parser, tag) == "true" + "StitchingSoftware" -> stitchingSoftware = readTag(parser, tag) + "ProjectionType" -> projectionType = readTag(parser, tag) + "StereoMode" -> stereoMode = readTag(parser, tag) + "SourceCount" -> sourceCount = Integer.parseInt(readTag(parser, tag)) + "InitialViewHeadingDegrees" -> initialViewHeadingDegrees = Integer.parseInt(readTag(parser, tag)) + "InitialViewPitchDegrees" -> initialViewPitchDegrees = Integer.parseInt(readTag(parser, tag)) + "InitialViewRollDegrees" -> initialViewRollDegrees = Integer.parseInt(readTag(parser, tag)) + "Timestamp" -> timestamp = Integer.parseInt(readTag(parser, tag)) + "FullPanoWidthPixels" -> fullPanoWidthPixels = Integer.parseInt(readTag(parser, tag)) + "FullPanoHeightPixels" -> fullPanoHeightPixels = Integer.parseInt(readTag(parser, tag)) + "CroppedAreaImageWidthPixels" -> croppedAreaImageWidthPixels = Integer.parseInt(readTag(parser, tag)) + "CroppedAreaImageHeightPixels" -> croppedAreaImageHeightPixels = Integer.parseInt(readTag(parser, tag)) + "CroppedAreaLeftPixels" -> croppedAreaLeftPixels = Integer.parseInt(readTag(parser, tag)) + "CroppedAreaTopPixels" -> croppedAreaTopPixels = Integer.parseInt(readTag(parser, tag)) + } + } + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to parse XML", e) + } + } + + fun describe(): Map = hashMapOf( + "Spherical" to spherical.toString(), + "Stitched" to stitched.toString(), + "Stitching Software" to stitchingSoftware, + "Projection Type" to projectionType, + "Stereo Mode" to stereoMode, + "Source Count" to sourceCount?.toString(), + "Initial View Heading Degrees" to initialViewHeadingDegrees?.toString(), + "Initial View Pitch Degrees" to initialViewPitchDegrees?.toString(), + "Initial View Roll Degrees" to initialViewRollDegrees?.toString(), + "Timestamp" to timestamp?.toString(), + "Full Panorama Width Pixels" to fullPanoWidthPixels?.toString(), + "Full Panorama Height Pixels" to fullPanoHeightPixels?.toString(), + "Cropped Area Image Width Pixels" to croppedAreaImageWidthPixels?.toString(), + "Cropped Area Image Height Pixels" to croppedAreaImageHeightPixels?.toString(), + "Cropped Area Left Pixels" to croppedAreaLeftPixels?.toString(), + "Cropped Area Top Pixels" to croppedAreaTopPixels?.toString(), + ).filterValues { it != null } + + companion object SphericalVideo { + private val LOG_TAG = LogUtils.createTag(SphericalVideo::class.java) + + // cf https://github.com/google/spatial-media + const val SPHERICAL_VIDEO_V1_UUID = "ffcc8263-f855-4a93-8814-587a02521fdd" + + const val RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + const val GSPHERICAL_NS = "http://ns.google.com/videos/1.0/spherical/" + + private fun readText(parser: XmlPullParser): String { + var text = "" + if (parser.next() == XmlPullParser.TEXT) { + text = parser.text + parser.nextTag() + } + return text + } + + private fun readTag(parser: XmlPullParser, tag: String): String { + parser.require(XmlPullParser.START_TAG, GSPHERICAL_NS, tag) + val text = readText(parser) + parser.require(XmlPullParser.END_TAG, GSPHERICAL_NS, tag) + return text + } + } +} \ No newline at end of file From 13a8e230348ea55de73c2090004958a74028700a Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 28 Dec 2020 21:51:49 +0900 Subject: [PATCH 02/10] minor changes --- .../aves/channel/calls/MetadataHandler.kt | 1 + .../thibault/aves/metadata/SphericalVideo.kt | 5 +++-- .../fullscreen/info/metadata/xmp_ns/xmp.dart | 18 +++++++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 22752003f..33fc09a69 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -144,6 +144,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) { val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) + metadataMap.remove(dirName) } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt index 6bba753d9..d3e07061e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/SphericalVideo.kt @@ -6,7 +6,8 @@ import deckers.thibault.aves.utils.LogUtils import org.xmlpull.v1.XmlPullParser import java.io.ByteArrayInputStream -class GSpherical(bytes: ByteArray) { +// `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec +class GSpherical(xmlBytes: ByteArray) { var spherical: Boolean = false var stitched: Boolean = false var stitchingSoftware: String = "" @@ -26,7 +27,7 @@ class GSpherical(bytes: ByteArray) { init { try { - ByteArrayInputStream(bytes).use { + ByteArrayInputStream(xmlBytes).use { val parser = Xml.newPullParser().apply { setInput(it, null) nextTag() diff --git a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart index 1a5dd4f28..2c73826c6 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_ns/xmp.dart @@ -51,9 +51,13 @@ class XmpMMNamespace extends XmpNamespace { static final derivedFromPattern = RegExp(r'xmpMM:DerivedFrom/(.*)'); static final historyPattern = RegExp(r'xmpMM:History\[(\d+)\]/(.*)'); + static final ingredientsPattern = RegExp(r'xmpMM:Ingredients\[(\d+)\]/(.*)'); + static final pantryPattern = RegExp(r'xmpMM:Pantry\[(\d+)\]/(.*)'); final derivedFrom = {}; final history = >{}; + final ingredients = >{}; + final pantry = >{}; XmpMMNamespace() : super(ns); @@ -63,7 +67,9 @@ class XmpMMNamespace extends XmpNamespace { @override bool extractData(XmpProp prop) { final hasStructs = extractStruct(prop, derivedFromPattern, derivedFrom); - final hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history); + var hasIndexedStructs = extractIndexedStruct(prop, historyPattern, history); + hasIndexedStructs |= extractIndexedStruct(prop, ingredientsPattern, ingredients); + hasIndexedStructs |= extractIndexedStruct(prop, pantryPattern, pantry); return hasStructs || hasIndexedStructs; } @@ -79,6 +85,16 @@ class XmpMMNamespace extends XmpNamespace { title: 'History', structByIndex: history, ), + if (ingredients.isNotEmpty) + XmpStructArrayCard( + title: 'Ingredients', + structByIndex: ingredients, + ), + if (pantry.isNotEmpty) + XmpStructArrayCard( + title: 'Pantry', + structByIndex: pantry, + ), ]; @override From 3a18f16d7c98764c6cb2ee2cbb2fb1cea75b5537 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 29 Dec 2020 18:39:53 +0900 Subject: [PATCH 03/10] #4 collection: long press and move to select multiple entries & scroll the grid when close to edge --- CHANGELOG.md | 3 + .../collection/grid/list_section_layout.dart | 23 +++ lib/widgets/collection/grid/list_sliver.dart | 5 - lib/widgets/collection/grid/selector.dart | 169 ++++++++++++++++++ .../collection/thumbnail_collection.dart | 15 +- .../common/identity/aves_filter_chip.dart | 2 + .../filter_grids/common/filter_nav_page.dart | 1 + 7 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 lib/widgets/collection/grid/selector.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad950ee9..94e856e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- Collection: long press and move to select/deselect multiple entries +- Info: show Spherical Video V1 metadata ## [v1.3.0] - 2020-12-26 ### Added diff --git a/lib/widgets/collection/grid/list_section_layout.dart b/lib/widgets/collection/grid/list_section_layout.dart index 34665e056..368b3bbeb 100644 --- a/lib/widgets/collection/grid/list_section_layout.dart +++ b/lib/widgets/collection/grid/list_section_layout.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/grid/header_generic.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -143,6 +144,25 @@ class SectionedListLayout { final top = sectionLayout.indexToLayoutOffset(listIndex); return Rect.fromLTWH(left, top, tileExtent, tileExtent); } + + ImageEntry getEntryAt(Offset position) { + var dy = position.dy; + final sectionLayout = sectionLayouts.firstWhere((sl) => dy < sl.maxOffset, orElse: () => null); + if (sectionLayout == null) return null; + + final section = collection.sections[sectionLayout.sectionKey]; + if (section == null) return null; + + dy -= sectionLayout.minOffset + sectionLayout.headerExtent; + if (dy < 0) return null; + + final row = dy ~/ tileExtent; + final column = position.dx ~/ tileExtent; + final index = row * columnCount + column; + if (index >= section.length) return null; + + return section[index]; + } } class SectionLayout { @@ -184,4 +204,7 @@ class SectionLayout { scrollOffset -= minOffset + headerExtent; return firstIndex + (scrollOffset < 0 ? 0 : (scrollOffset / tileExtent).ceil() - 1); } + + @override + String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent}'; } diff --git a/lib/widgets/collection/grid/list_sliver.dart b/lib/widgets/collection/grid/list_sliver.dart index d01c7800e..9d3189382 100644 --- a/lib/widgets/collection/grid/list_sliver.dart +++ b/lib/widgets/collection/grid/list_sliver.dart @@ -65,11 +65,6 @@ class GridThumbnail extends StatelessWidget { ViewerService.pick(entry.uri); } }, - onLongPress: () { - if (AvesApp.mode == AppMode.main) { - collection.toggleSelection(entry); - } - }, child: MetaData( metaData: ScalerMetadata(entry), child: DecoratedThumbnail( diff --git a/lib/widgets/collection/grid/selector.dart b/lib/widgets/collection/grid/selector.dart new file mode 100644 index 000000000..8e7b5e4a3 --- /dev/null +++ b/lib/widgets/collection/grid/selector.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/utils/math_utils.dart'; +import 'package:aves/widgets/collection/grid/list_section_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +class GridSelectionGestureDetector extends StatefulWidget { + final bool selectable; + final CollectionLens collection; + final ScrollController scrollController; + final ValueNotifier appBarHeightNotifier; + final Widget child; + + const GridSelectionGestureDetector({ + this.selectable = true, + @required this.collection, + @required this.scrollController, + @required this.appBarHeightNotifier, + @required this.child, + }); + + @override + _GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState(); +} + +class _GridSelectionGestureDetectorState extends State { + bool _pressing, _selecting; + int _fromIndex, _lastToIndex; + Offset _localPosition; + EdgeInsets _scrollableInsets; + double _scrollSpeedFactor; + Timer _updateTimer; + + CollectionLens get collection => widget.collection; + + List get entries => collection.sortedEntries; + + ScrollController get scrollController => widget.scrollController; + + double get appBarHeight => widget.appBarHeightNotifier.value; + + static const double scrollEdgeRatio = .15; + static const double scrollMaxPixelPerSecond = 600.0; + static const Duration scrollUpdateInterval = Duration(milliseconds: 100); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPressStart: widget.selectable + ? (details) { + final fromEntry = _getEntryAt(details.localPosition); + if (fromEntry == null) return; + + collection.toggleSelection(fromEntry); + _selecting = collection.isSelected([fromEntry]); + _fromIndex = entries.indexOf(fromEntry); + _lastToIndex = _fromIndex; + _scrollableInsets = EdgeInsets.only( + top: appBarHeight, + bottom: context.read().viewInsets.bottom, + ); + _scrollSpeedFactor = 0; + _pressing = true; + } + : null, + onLongPressMoveUpdate: widget.selectable + ? (details) { + if (!_pressing) return; + _localPosition = details.localPosition; + _onLongPressUpdate(); + } + : null, + onLongPressEnd: widget.selectable + ? (details) { + if (!_pressing) return; + _setScrollSpeed(0); + _pressing = false; + } + : null, + child: widget.child, + ); + } + + void _onLongPressUpdate() { + final dy = _localPosition.dy; + + final height = scrollController.position.viewportDimension; + final top = dy < height / 2; + + final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom); + final threshold = height * scrollEdgeRatio; + if (distanceToEdge < threshold) { + _setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1)); + } else { + _setScrollSpeed(0); + } + + final toEntry = _getEntryAt(_localPosition); + _toggleSelectionToIndex(entries.indexOf(toEntry)); + } + + void _setScrollSpeed(double speedFactor) { + if (speedFactor == _scrollSpeedFactor) return; + _scrollSpeedFactor = speedFactor; + _updateTimer?.cancel(); + + final current = scrollController.offset; + if (speedFactor == 0) { + scrollController.jumpTo(current); + return; + } + + final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0; + if (target != current) { + final distance = target - current; + final millis = distance * 1000 / scrollMaxPixelPerSecond / speedFactor; + scrollController.animateTo( + target, + duration: Duration(milliseconds: millis.round()), + curve: Curves.linear, + ); + // use a timer to update the entry selection, because `onLongPressMoveUpdate` + // is not called when the pointer stays still while the view is scrolling + _updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate()); + } + } + + ImageEntry _getEntryAt(Offset localPosition) { + // as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static, + // but when it is scrolling (through controller animation), result is incomplete and children are missing, + // so we use custom layout computation instead to find the entry. + final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition; + return context.read().getEntryAt(offset); + } + + void _toggleSelectionToIndex(int toIndex) { + if (toIndex == -1) return; + + if (_selecting) { + if (toIndex <= _fromIndex) { + if (toIndex < _lastToIndex) { + collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex))); + if (_fromIndex < _lastToIndex) { + collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1)); + } + } else if (_lastToIndex < toIndex) { + collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex)); + } + } else if (_fromIndex < toIndex) { + if (_lastToIndex < toIndex) { + collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1)); + if (_lastToIndex < _fromIndex) { + collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex)); + } + } else if (toIndex < _lastToIndex) { + collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1)); + } + } + _lastToIndex = toIndex; + } else { + collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1)); + } + } +} diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index 59343b08b..d58c2a8ca 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/main.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/highlight.dart'; @@ -13,6 +14,7 @@ import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/grid/list_section_layout.dart'; import 'package:aves/widgets/collection/grid/list_sliver.dart'; +import 'package:aves/widgets/collection/grid/selector.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; @@ -53,6 +55,7 @@ class ThumbnailCollection extends StatelessWidget { spacing: spacing, )..applyTileExtent(viewportSize: viewportSize); final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; + final scrollController = PrimaryScrollController.of(context); // do not replace by Provider.of // so that view updates on collection filter changes @@ -67,7 +70,7 @@ class ThumbnailCollection extends StatelessWidget { ), appBarHeightNotifier: _appBarHeightNotifier, isScrollingNotifier: _isScrollingNotifier, - scrollController: PrimaryScrollController.of(context), + scrollController: scrollController, cacheExtent: cacheExtent, ); @@ -102,6 +105,14 @@ class ThumbnailCollection extends StatelessWidget { child: scrollView, ); + final selector = GridSelectionGestureDetector( + selectable: AvesApp.mode == AppMode.main, + collection: collection, + scrollController: scrollController, + appBarHeightNotifier: _appBarHeightNotifier, + child: scaler, + ); + final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: _tileExtentNotifier, builder: (context, tileExtent, child) => SectionedListLayoutProvider( @@ -116,7 +127,7 @@ class ThumbnailCollection extends StatelessWidget { tileExtent: tileExtent, isScrollingNotifier: _isScrollingNotifier, ), - child: scaler, + child: selector, ), ); return sectionedListLayoutProvider; diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 50fd94e47..5351522a1 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -168,6 +168,8 @@ class _AvesFilterChipState extends State { borderRadius: borderRadius, ), child: InkWell( + // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, + // so we get the long press details from the tap instead onTapDown: (details) => _tapPosition = details.globalPosition, onTap: widget.onTap != null ? () { diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index ee10b06af..6640f8d89 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -85,6 +85,7 @@ class FilterNavigationPage extends StatelessWidget { Future _showMenu(BuildContext context, T filter, Offset tapPosition) async { final RenderBox overlay = Overlay.of(context).context.findRenderObject(); final touchArea = Size(40, 40); + // TODO TLAD show menu within safe area final selectedAction = await showMenu( context: context, position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), From 69349e2b2cb315133fdeae88684c9c7aee4280b7 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 30 Dec 2020 12:47:27 +0900 Subject: [PATCH 04/10] info: metadata search --- lib/widgets/collection/app_bar.dart | 4 +- lib/widgets/common/basic/query_bar.dart | 79 +++++++++++ .../common/identity/aves_expansion_tile.dart | 9 +- lib/widgets/filter_grids/album_pick.dart | 74 +--------- .../filter_grids/common/filter_nav_page.dart | 4 +- lib/widgets/fullscreen/info/info_app_bar.dart | 53 ++++++++ lib/widgets/fullscreen/info/info_page.dart | 32 ++--- lib/widgets/fullscreen/info/info_search.dart | 109 +++++++++++++++ .../info/metadata/metadata_dir_tile.dart | 113 ++++++++++++++++ .../info/metadata/metadata_section.dart | 127 +++++------------- .../fullscreen/info/metadata/xmp_structs.dart | 2 +- .../fullscreen/info/metadata/xmp_tile.dart | 3 + lib/widgets/home_page.dart | 2 +- lib/widgets/search/search_button.dart | 6 +- lib/widgets/search/search_delegate.dart | 6 +- lib/widgets/search/search_page.dart | 4 +- 16 files changed, 431 insertions(+), 196 deletions(-) create mode 100644 lib/widgets/common/basic/query_bar.dart create mode 100644 lib/widgets/fullscreen/info/info_app_bar.dart create mode 100644 lib/widgets/fullscreen/info/info_search.dart create mode 100644 lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index ff4601a4c..2beeb4360 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -172,7 +172,7 @@ class _CollectionAppBarState extends State with SingleTickerPr List _buildActions() { return [ if (collection.isBrowsing) - SearchButton( + CollectionSearchButton( source, parentCollection: collection, ), @@ -361,7 +361,7 @@ class _CollectionAppBarState extends State with SingleTickerPr Navigator.push( context, SearchPageRoute( - delegate: ImageSearchDelegate( + delegate: CollectionSearchDelegate( source: collection.source, parentCollection: collection, ), diff --git a/lib/widgets/common/basic/query_bar.dart b/lib/widgets/common/basic/query_bar.dart new file mode 100644 index 000000000..570e4589f --- /dev/null +++ b/lib/widgets/common/basic/query_bar.dart @@ -0,0 +1,79 @@ +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/debouncer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class QueryBar extends StatefulWidget { + final ValueNotifier filterNotifier; + + const QueryBar({@required this.filterNotifier}); + + @override + _QueryBarState createState() => _QueryBarState(); +} + +class _QueryBarState extends State { + final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + TextEditingController _controller; + + ValueNotifier get filterNotifier => widget.filterNotifier; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: filterNotifier.value); + } + + @override + Widget build(BuildContext context) { + final clearButton = IconButton( + icon: Icon(AIcons.clear), + onPressed: () { + _controller.clear(); + filterNotifier.value = ''; + }, + tooltip: 'Clear', + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + icon: Padding( + padding: EdgeInsetsDirectional.only(start: 16), + child: Icon(AIcons.search), + ), + hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, + ), + textInputAction: TextInputAction.search, + onChanged: (s) => _debouncer(() => filterNotifier.value = s), + ), + ), + ConstrainedBox( + constraints: BoxConstraints(minWidth: 16), + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) => AnimatedSwitcher( + duration: Durations.appBarActionChangeAnimation, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + child: child, + ), + ), + child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(), + ), + ), + ) + ], + ); + } +} diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index 554d02e9b..e82b64955 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -5,13 +5,15 @@ import 'package:flutter/material.dart'; class AvesExpansionTile extends StatelessWidget { final String title; final Color color; - final List children; final ValueNotifier expandedNotifier; + final bool initiallyExpanded; + final List children; const AvesExpansionTile({ @required this.title, this.color, this.expandedNotifier, + this.initiallyExpanded = false, @required this.children, }); @@ -33,6 +35,9 @@ class AvesExpansionTile extends StatelessWidget { enabled: enabled, ), expandable: enabled, + initiallyExpanded: initiallyExpanded, + baseColor: Colors.grey[900], + expandedColor: Colors.grey[850], child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -41,8 +46,6 @@ class AvesExpansionTile extends StatelessWidget { if (enabled) ...children, ], ), - baseColor: Colors.grey[900], - expandedColor: Colors.grey[850], ), ); } diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index c1ce9da15..965d9841e 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -3,10 +3,9 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; @@ -116,84 +115,25 @@ class AlbumPickAppBar extends StatelessWidget { } } -class AlbumFilterBar extends StatefulWidget implements PreferredSizeWidget { +class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget { final ValueNotifier filterNotifier; static const preferredHeight = kToolbarHeight; - const AlbumFilterBar({@required this.filterNotifier}); + const AlbumFilterBar({ + @required this.filterNotifier, + }); @override Size get preferredSize => Size.fromHeight(preferredHeight); - @override - _AlbumFilterBarState createState() => _AlbumFilterBarState(); -} - -class _AlbumFilterBarState extends State { - final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); - TextEditingController _controller; - - ValueNotifier get filterNotifier => widget.filterNotifier; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: filterNotifier.value); - } - @override Widget build(BuildContext context) { - final clearButton = IconButton( - icon: Icon(AIcons.clear), - onPressed: () { - _controller.clear(); - filterNotifier.value = ''; - }, - tooltip: 'Clear', - ); return Container( height: AlbumFilterBar.preferredHeight, alignment: Alignment.topCenter, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Icon(AIcons.search), - Expanded( - child: TextField( - controller: _controller, - decoration: InputDecoration( - icon: Padding( - padding: EdgeInsetsDirectional.only(start: 16), - child: Icon(AIcons.search), - ), - // border: OutlineInputBorder(), - hintText: MaterialLocalizations.of(context).searchFieldLabel, - hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, - ), - textInputAction: TextInputAction.search, - onChanged: (s) => _debouncer(() => filterNotifier.value = s), - ), - ), - ConstrainedBox( - constraints: BoxConstraints(minWidth: 16), - child: ValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, child) => AnimatedSwitcher( - duration: Durations.appBarActionChangeAnimation, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: SizeTransition( - axis: Axis.horizontal, - sizeFactor: animation, - child: child, - ), - ), - child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(), - ), - ), - ) - ], + child: QueryBar( + filterNotifier: filterNotifier, ), ); } diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 6640f8d89..525a5bc5d 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -104,7 +104,7 @@ class FilterNavigationPage extends StatelessWidget { List _buildActions(BuildContext context) { return [ - SearchButton(source), + CollectionSearchButton(source), PopupMenuButton( key: Key('appbar-menu-button'), itemBuilder: (context) { @@ -137,7 +137,7 @@ class FilterNavigationPage extends StatelessWidget { Navigator.push( context, SearchPageRoute( - delegate: ImageSearchDelegate( + delegate: CollectionSearchDelegate( source: source, ), )); diff --git a/lib/widgets/fullscreen/info/info_app_bar.dart b/lib/widgets/fullscreen/info/info_app_bar.dart new file mode 100644 index 000000000..a993b40cb --- /dev/null +++ b/lib/widgets/fullscreen/info/info_app_bar.dart @@ -0,0 +1,53 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/app_bar_title.dart'; +import 'package:aves/widgets/fullscreen/info/info_search.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:flutter/material.dart'; + +class InfoAppBar extends StatelessWidget { + final ImageEntry entry; + final ValueNotifier> metadataNotifier; + final VoidCallback onBackPressed; + + const InfoAppBar({ + @required this.entry, + @required this.metadataNotifier, + @required this.onBackPressed, + }); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + leading: IconButton( + key: Key('back-button'), + icon: Icon(AIcons.goUp), + onPressed: onBackPressed, + tooltip: 'Back to viewer', + ), + title: TappableAppBarTitle( + onTap: () => _goToSearch(context), + child: Text('Info'), + ), + actions: [ + IconButton( + icon: Icon(AIcons.search), + onPressed: () => _goToSearch(context), + tooltip: 'Search', + ), + ], + titleSpacing: 0, + floating: true, + ); + } + + void _goToSearch(BuildContext context) { + showSearch( + context: context, + delegate: InfoSearchDelegate( + entry: entry, + metadataNotifier: metadataNotifier, + ), + ); + } +} diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index bf9f10780..8117cdea2 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -2,9 +2,9 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/fullscreen/info/basic_section.dart'; +import 'package:aves/widgets/fullscreen/info/info_app_bar.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart'; @@ -38,17 +38,6 @@ class InfoPageState extends State { @override Widget build(BuildContext context) { - final appBar = SliverAppBar( - leading: IconButton( - key: Key('back-button'), - icon: Icon(AIcons.goUp), - onPressed: _goToImage, - tooltip: 'Back to viewer', - ), - title: Text('Info'), - floating: true, - ); - return MediaQueryDataProvider( child: Scaffold( body: SafeArea( @@ -68,9 +57,9 @@ class InfoPageState extends State { entry: entry, visibleNotifier: widget.visibleNotifier, scrollController: _scrollController, - appBar: appBar, split: mqWidth > 400, mqViewInsetsBottom: mqViewInsetsBottom, + goToViewer: _goToViewer, ) : SizedBox.shrink(); }, @@ -97,7 +86,7 @@ class InfoPageState extends State { _scrollStartFromTop = false; } else if (notification is OverscrollNotification) { if (notification.overscroll < 0) { - _goToImage(); + _goToViewer(); _scrollStartFromTop = false; } } @@ -106,7 +95,7 @@ class InfoPageState extends State { return false; } - void _goToImage() { + void _goToViewer() { BackUpNotification().dispatch(context); _scrollController.animateTo( 0, @@ -121,9 +110,9 @@ class _InfoPageContent extends StatefulWidget { final ImageEntry entry; final ValueNotifier visibleNotifier; final ScrollController scrollController; - final SliverAppBar appBar; final bool split; final double mqViewInsetsBottom; + final VoidCallback goToViewer; const _InfoPageContent({ Key key, @@ -131,9 +120,9 @@ class _InfoPageContent extends StatefulWidget { @required this.entry, @required this.visibleNotifier, @required this.scrollController, - @required this.appBar, @required this.split, @required this.mqViewInsetsBottom, + @required this.goToViewer, }) : super(key: key); @override @@ -143,6 +132,8 @@ class _InfoPageContent extends StatefulWidget { class _InfoPageContentState extends State<_InfoPageContent> { static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); + final ValueNotifier> _metadataNotifier = ValueNotifier({}); + CollectionLens get collection => widget.collection; ImageEntry get entry => widget.entry; @@ -178,13 +169,18 @@ class _InfoPageContentState extends State<_InfoPageContent> { ); final metadataSliver = MetadataSectionSliver( entry: entry, + metadataNotifier: _metadataNotifier, visibleNotifier: widget.visibleNotifier, ); return CustomScrollView( controller: widget.scrollController, slivers: [ - widget.appBar, + InfoAppBar( + entry: entry, + metadataNotifier: _metadataNotifier, + onBackPressed: widget.goToViewer, + ), SliverPadding( padding: horizontalPadding + EdgeInsets.only(top: 8), sliver: basicAndLocationSliver, diff --git a/lib/widgets/fullscreen/info/info_search.dart b/lib/widgets/fullscreen/info/info_search.dart new file mode 100644 index 000000000..6533af12d --- /dev/null +++ b/lib/widgets/fullscreen/info/info_search.dart @@ -0,0 +1,109 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:flutter/material.dart'; + +class InfoSearchDelegate extends SearchDelegate { + final ImageEntry entry; + final ValueNotifier> metadataNotifier; + + Map get metadata => metadataNotifier.value; + + static const suggestions = { + 'Date & time': 'date or time or when', + 'Description': 'abtract or description or comment', + 'Dimensions': 'width or height or dimension or framesize', + 'Resolution': 'resolution', + 'Rights': 'rights or copyright or artist or creator or by-line or credit', + }; + + InfoSearchDelegate({ + @required this.entry, + @required this.metadataNotifier, + }) : super( + searchFieldLabel: 'Search metadata', + ); + + @override + ThemeData appBarTheme(BuildContext context) { + return Theme.of(context); + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: transitionAnimation, + ), + onPressed: () => Navigator.pop(context), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ); + } + + @override + List buildActions(BuildContext context) { + return [ + if (query.isNotEmpty) + IconButton( + icon: Icon(AIcons.clear), + onPressed: () { + query = ''; + showSuggestions(context); + }, + tooltip: 'Clear', + ), + ]; + } + + @override + Widget buildSuggestions(BuildContext context) => ListView( + children: suggestions.entries + .map((kv) => ListTile( + title: Text(kv.key), + onTap: () { + query = kv.value; + showResults(context); + }, + )) + .toList(), + ); + + @override + Widget buildResults(BuildContext context) { + if (query.isEmpty) { + showSuggestions(context); + return SizedBox(); + } + + final effectiveQueries = query.toUpperCase().split(' OR ').map((query) => query.trim()); + bool testKey(String key) => effectiveQueries.any(key.toUpperCase().contains); + final filteredMetadata = Map.fromEntries(metadata.entries.map((kv) { + final filteredDir = kv.value.filterKeys(testKey); + return MapEntry(kv.key, filteredDir); + })); + + final tiles = filteredMetadata.entries + .where((kv) => kv.value.tags.isNotEmpty) + .map((kv) => MetadataDirTile( + entry: entry, + title: kv.key, + dir: kv.value, + initiallyExpanded: true, + showPrefixChildren: false, + )) + .toList(); + return tiles.isEmpty + ? EmptyContent( + icon: AIcons.info, + text: 'No matching keys', + ) + : ListView.builder( + padding: EdgeInsets.all(8), + itemBuilder: (context, index) => tiles[index], + itemCount: tiles.length, + ); + } +} diff --git a/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart b/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart new file mode 100644 index 000000000..af980220f --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart @@ -0,0 +1,113 @@ +import 'dart:collection'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/services/svg_metadata_service.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/color_utils.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; +import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MetadataDirTile extends StatelessWidget { + final ImageEntry entry; + final String title; + final MetadataDirectory dir; + final ValueNotifier expandedDirectoryNotifier; + final bool initiallyExpanded, showPrefixChildren; + + const MetadataDirTile({ + @required this.entry, + @required this.title, + @required this.dir, + this.expandedDirectoryNotifier, + this.initiallyExpanded = false, + this.showPrefixChildren = true, + }); + + @override + Widget build(BuildContext context) { + final tags = dir.tags; + if (tags.isEmpty) return SizedBox.shrink(); + + final dirName = dir.name; + if (dirName == MetadataDirectory.xmpDirectory) { + return XmpDirTile( + entry: entry, + tags: tags, + expandedNotifier: expandedDirectoryNotifier, + initiallyExpanded: initiallyExpanded, + ); + } + + Widget thumbnail; + final prefixChildren = []; + if (showPrefixChildren) { + switch (dirName) { + case MetadataDirectory.exifThumbnailDirectory: + thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); + break; + case MetadataDirectory.mediaDirectory: + thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); + Widget builder(IconData data) => Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Icon(data), + ); + if (tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video)); + if (tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio)); + if (tags['Has Image'] == 'yes') { + int count; + if (tags.containsKey('Image Count')) { + count = int.tryParse(tags['Image Count']); + } + prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image))); + } + break; + } + } + + return AvesExpansionTile( + title: title, + color: BrandColors.get(dirName) ?? stringToColor(dirName), + expandedNotifier: expandedDirectoryNotifier, + initiallyExpanded: initiallyExpanded, + children: [ + if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren), + if (thumbnail != null) thumbnail, + Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: InfoRowGroup( + tags, + maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(tags) : null, + ), + ), + ], + ); + } + + static Map getSvgLinkHandlers(SplayTreeMap tags) { + return { + 'Metadata': InfoLinkHandler( + linkText: 'View XML', + onTap: (context) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: SourceViewerPage.routeName), + builder: (context) => SourceViewerPage( + loader: () => SynchronousFuture(tags['Metadata']), + ), + ), + ); + }, + ), + }; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index e12c8822d..96a6475b5 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -1,18 +1,12 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/color_utils.dart'; -import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; -import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -21,10 +15,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; class MetadataSectionSliver extends StatefulWidget { final ImageEntry entry; final ValueNotifier visibleNotifier; + final ValueNotifier> metadataNotifier; const MetadataSectionSliver({ @required this.entry, @required this.visibleNotifier, + @required this.metadataNotifier, }); @override @@ -32,7 +28,6 @@ class MetadataSectionSliver extends StatefulWidget { } class _MetadataSectionSliverState extends State with AutomaticKeepAliveClientMixin { - Map _metadata = {}; final ValueNotifier _loadedMetadataUri = ValueNotifier(null); final ValueNotifier _expandedDirectoryNotifier = ValueNotifier(null); @@ -40,10 +35,9 @@ class _MetadataSectionSliverState extends State with Auto bool get isVisible => widget.visibleNotifier.value; - // special directory names - static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor - static const xmpDirectory = 'XMP'; // from metadata-extractor - static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory + ValueNotifier> get metadataNotifier => widget.metadataNotifier; + + Map get metadata => metadataNotifier.value; // directory names may contain the name of their parent directory // if so, they are separated by this character @@ -53,6 +47,7 @@ class _MetadataSectionSliverState extends State with Auto void initState() { super.initState(); _registerWidget(widget); + metadataNotifier.value = {}; _getMetadata(); } @@ -96,7 +91,7 @@ class _MetadataSectionSliverState extends State with Auto valueListenable: _loadedMetadataUri, builder: (context, uri, child) { Widget content; - if (_metadata.isEmpty) { + if (metadata.isEmpty) { content = SizedBox.shrink(); } else { content = Column( @@ -111,7 +106,12 @@ class _MetadataSectionSliverState extends State with Auto ), children: [ SectionRow(AIcons.info), - ..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)), + ...metadata.entries.map((kv) => MetadataDirTile( + entry: entry, + title: kv.key, + dir: kv.value, + expandedDirectoryNotifier: _expandedDirectoryNotifier, + )), ], ), ); @@ -128,64 +128,9 @@ class _MetadataSectionSliverState extends State with Auto ); } - Widget _buildDirTile(String title, _MetadataDirectory dir) { - if (dir.tags.isEmpty) return SizedBox.shrink(); - - final dirName = dir.name; - if (dirName == xmpDirectory) { - return XmpDirTile( - entry: entry, - tags: dir.tags, - expandedNotifier: _expandedDirectoryNotifier, - ); - } - - Widget thumbnail; - final prefixChildren = []; - switch (dirName) { - case exifThumbnailDirectory: - thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); - break; - case mediaDirectory: - thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); - Widget builder(IconData data) => Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Icon(data), - ); - if (dir.tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video)); - if (dir.tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio)); - if (dir.tags['Has Image'] == 'yes') { - int count; - if (dir.tags.containsKey('Image Count')) { - count = int.tryParse(dir.tags['Image Count']); - } - prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image))); - } - break; - } - - return AvesExpansionTile( - title: title, - color: BrandColors.get(dirName) ?? stringToColor(dirName), - expandedNotifier: _expandedDirectoryNotifier, - children: [ - if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren), - if (thumbnail != null) thumbnail, - Padding( - padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: InfoRowGroup( - dir.tags, - maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null, - ), - ), - ], - ); - } - void _onMetadataChanged() { _loadedMetadataUri.value = null; - _metadata = {}; + metadataNotifier.value = {}; _getMetadata(); } @@ -211,7 +156,7 @@ class _MetadataSectionSliverState extends State with Auto final tagName = tagKV.key as String ?? ''; return MapEntry(tagName, value); }).where((kv) => kv != null))); - return _MetadataDirectory(directoryName, parent, tags); + return MetadataDirectory(directoryName, parent, tags); }).toList(); final titledDirectories = directories.map((dir) { @@ -222,42 +167,36 @@ class _MetadataSectionSliverState extends State with Auto return MapEntry(title, dir); }).toList() ..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); - _metadata = Map.fromEntries(titledDirectories); + metadataNotifier.value = Map.fromEntries(titledDirectories); _loadedMetadataUri.value = entry.uri; } else { - _metadata = {}; + metadataNotifier.value = {}; _loadedMetadataUri.value = null; } _expandedDirectoryNotifier.value = null; } - static Map getSvgLinkHandlers(SplayTreeMap tags) { - return { - 'Metadata': InfoLinkHandler( - linkText: 'View XML', - onTap: (context) { - Navigator.push( - context, - MaterialPageRoute( - settings: RouteSettings(name: SourceViewerPage.routeName), - builder: (context) => SourceViewerPage( - loader: () => SynchronousFuture(tags['Metadata']), - ), - ), - ); - }, - ), - }; - } - @override bool get wantKeepAlive => true; } -class _MetadataDirectory { +class MetadataDirectory { final String name; final String parent; + final SplayTreeMap allTags; final SplayTreeMap tags; - const _MetadataDirectory(this.name, this.parent, this.tags); + // special directory names + static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor + static const xmpDirectory = 'XMP'; // from metadata-extractor + static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory + + const MetadataDirectory(this.name, this.parent, SplayTreeMap allTags, {SplayTreeMap tags}) + : allTags = allTags, + 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, parent, tags, tags: filteredTags); + } } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart index 836dab6ec..efb9aceba 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart @@ -89,7 +89,7 @@ class _XmpStructArrayCardState extends State { // without clipping the text padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup( - structs[_index], + structs[_index] ?? {}, maxValueLength: Constants.infoGroupMaxValueLength, linkHandlers: widget.linkifier?.call(_index + 1), ), diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 8a7338b2c..b0f8f0bf5 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -25,11 +25,13 @@ class XmpDirTile extends StatefulWidget { final ImageEntry entry; final SplayTreeMap tags; final ValueNotifier expandedNotifier; + final bool initiallyExpanded; const XmpDirTile({ @required this.entry, @required this.tags, @required this.expandedNotifier, + @required this.initiallyExpanded, }); @override @@ -76,6 +78,7 @@ class _XmpDirTileState extends State with FeedbackMixin { return AvesExpansionTile( title: 'XMP', expandedNotifier: widget.expandedNotifier, + initiallyExpanded: widget.initiallyExpanded, children: [ NotificationListener( onNotification: (notification) { diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index e848d0a0b..d2d19c90d 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -142,7 +142,7 @@ class _HomePageState extends State { ); case SearchPage.routeName: return SearchPageRoute( - delegate: ImageSearchDelegate(source: _mediaStore), + delegate: CollectionSearchDelegate(source: _mediaStore), ); case CollectionPage.routeName: default: diff --git a/lib/widgets/search/search_button.dart b/lib/widgets/search/search_button.dart index 70fbefb06..4e99cc457 100644 --- a/lib/widgets/search/search_button.dart +++ b/lib/widgets/search/search_button.dart @@ -4,11 +4,11 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; -class SearchButton extends StatelessWidget { +class CollectionSearchButton extends StatelessWidget { final CollectionSource source; final CollectionLens parentCollection; - const SearchButton(this.source, {this.parentCollection}); + const CollectionSearchButton(this.source, {this.parentCollection}); @override Widget build(BuildContext context) { @@ -24,7 +24,7 @@ class SearchButton extends StatelessWidget { Navigator.push( context, SearchPageRoute( - delegate: ImageSearchDelegate( + delegate: CollectionSearchDelegate( source: source, parentCollection: parentCollection, ), diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index febeec7b5..f1d008e86 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -20,14 +20,14 @@ import 'package:aves/widgets/search/search_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -class ImageSearchDelegate { +class CollectionSearchDelegate { final CollectionSource source; final CollectionLens parentCollection; final ValueNotifier expandedSectionNotifier = ValueNotifier(null); static const searchHistoryCount = 10; - ImageSearchDelegate({@required this.source, this.parentCollection}); + CollectionSearchDelegate({@required this.source, this.parentCollection}); ThemeData appBarTheme(BuildContext context) { return Theme.of(context); @@ -289,7 +289,7 @@ class SearchPageRoute extends PageRoute { delegate.route = this; } - final ImageSearchDelegate delegate; + final CollectionSearchDelegate delegate; @override Color get barrierColor => null; diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index 171f0a8b6..e0e464e51 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; class SearchPage extends StatefulWidget { static const routeName = '/search'; - final ImageSearchDelegate delegate; + final CollectionSearchDelegate delegate; final Animation animation; const SearchPage({ @@ -115,7 +115,7 @@ class _SearchPageState extends State { onSubmitted: (_) => widget.delegate.showResults(context), decoration: InputDecoration( border: InputBorder.none, - hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintText: 'Search collection', hintStyle: theme.inputDecorationTheme.hintStyle, ), ), From 0f87ca436ada230dc3bb408670172e4ac0cf4162 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 31 Dec 2020 09:54:22 +0900 Subject: [PATCH 05/10] info: metadata search fixes --- lib/widgets/fullscreen/info/info_search.dart | 21 ++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/widgets/fullscreen/info/info_search.dart b/lib/widgets/fullscreen/info/info_search.dart index 6533af12d..b0496780a 100644 --- a/lib/widgets/fullscreen/info/info_search.dart +++ b/lib/widgets/fullscreen/info/info_search.dart @@ -3,6 +3,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class InfoSearchDelegate extends SearchDelegate { @@ -12,11 +13,11 @@ class InfoSearchDelegate extends SearchDelegate { Map get metadata => metadataNotifier.value; static const suggestions = { - 'Date & time': 'date or time or when', - 'Description': 'abtract or description or comment', - 'Dimensions': 'width or height or dimension or framesize', + 'Date & time': 'date or time or when -timer -uptime -exposure', + 'Description': 'abstract or description or comment', + 'Dimensions': 'width or height or dimension or framesize or imagelength', 'Resolution': 'resolution', - 'Rights': 'rights or copyright or artist or creator or by-line or credit', + 'Rights': 'rights or copyright or artist or creator or by-line or credit -tool', }; InfoSearchDelegate({ @@ -78,8 +79,16 @@ class InfoSearchDelegate extends SearchDelegate { return SizedBox(); } - final effectiveQueries = query.toUpperCase().split(' OR ').map((query) => query.trim()); - bool testKey(String key) => effectiveQueries.any(key.toUpperCase().contains); + final queryParts = query.toUpperCase().split(' ')..removeWhere((s) => s.isEmpty); + final queryExcludeIncludeGroups = groupBy(queryParts, (s) => s.startsWith('-')); + final queryExcludeAll = (queryExcludeIncludeGroups[true] ?? []).map((s) => s.substring(1)); + final queryIncludeAny = (queryExcludeIncludeGroups[false] ?? []).join(' ').split(' OR '); + + bool testKey(String key) { + key = key.toUpperCase(); + return queryIncludeAny.any(key.contains) && queryExcludeAll.every((q) => !key.contains(q)); + } + final filteredMetadata = Map.fromEntries(metadata.entries.map((kv) { final filteredDir = kv.value.filterKeys(testKey); return MapEntry(kv.key, filteredDir); From 035b0f861b6f03419b799184a7efdc1291d0d6e6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 31 Dec 2020 09:58:57 +0900 Subject: [PATCH 06/10] viewer: fixed panning inertia failure when only scaling by double tap first --- lib/widgets/common/magnifier/core/core.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index 735434a42..441fe2c99 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -43,7 +43,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi Offset _startFocalPoint, _lastViewportFocalPosition; double _startScale, _quickScaleLastY, _quickScaleLastDistance; bool _doubleTap, _quickScaleMoved; - DateTime _lastScaleGestureDate; + DateTime _lastScaleGestureDate = DateTime.now(); AnimationController _scaleAnimationController; Animation _scaleAnimation; From 7281eaff0628de73361f1c454ded916c6505d31c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 31 Dec 2020 10:09:14 +0900 Subject: [PATCH 07/10] updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e856e3a..5af81b8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. ### Added - Collection: long press and move to select/deselect multiple entries - Info: show Spherical Video V1 metadata +- Info: metadata search + +### Fixed +- Viewer: fixed panning inertia following double-tap scaling ## [v1.3.0] - 2020-12-26 ### Added From 276743bc64bc8748080d499647b5130e516d3e5e Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 4 Jan 2021 20:55:52 +0900 Subject: [PATCH 08/10] fixed crash when loading TIFF on Android 11 --- .../aves/channel/calls/DebugHandler.kt | 39 ++++++++++-------- .../aves/channel/calls/TiffRegionFetcher.kt | 28 +++++++------ .../channel/streams/ImageByteStreamHandler.kt | 41 +++++++++++-------- .../aves/decoder/TiffThumbnailGlideModule.kt | 41 +++++++++++-------- .../thibault/aves/model/SourceImageEntry.kt | 13 +++--- 5 files changed, 88 insertions(+), 74 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 91f0486d0..1119e62e3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -240,26 +240,29 @@ class DebugHandler(private val context: Context) : MethodCallHandler { try { val metadataMap = HashMap() - var dirCount: Int? = null - context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true - } - TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) - metadataMap["0"] = tiffOptionsToMap(options) - dirCount = options.outDirectoryCount + var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + result.error("getTiffStructure-fd", "failed to get file descriptor", null) + return } - if (dirCount != null) { - for (i in 1 until dirCount!!) { - context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true - inDirectoryNumber = i - } - TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) - metadataMap["$i"] = tiffOptionsToMap(options) - } + var options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + metadataMap["0"] = tiffOptionsToMap(options) + val dirCount = options.outDirectoryCount + for (i in 1 until dirCount) { + fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + result.error("getTiffStructure-fd", "failed to get file descriptor", null) + return } + options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + inDirectoryNumber = i + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + metadataMap["$i"] = tiffOptionsToMap(options) } result.success(metadataMap) } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt index 1c88245bb..2d17f62d6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TiffRegionFetcher.kt @@ -18,20 +18,22 @@ class TiffRegionFetcher internal constructor( page: Int = 0, result: MethodChannel.Result, ) { - val resolver = context.contentResolver try { - resolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inDirectoryNumber = page - inSampleSize = sampleSize - inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height()) - } - val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) - if (bitmap != null) { - result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) - } else { - result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null) - } + val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + result.error("getRegion-tiff-fd", "failed to get file descriptor for uri=$uri", null) + return + } + val options = TiffBitmapFactory.Options().apply { + inDirectoryNumber = page + inSampleSize = sampleSize + inDecodeArea = DecodeArea(regionRect.left, regionRect.top, regionRect.width(), regionRect.height()) + } + val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) + if (bitmap != null) { + result.success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) + } else { + result.error("getRegion-tiff-null", "failed to decode region for uri=$uri page=$page regionRect=$regionRect", null) } } catch (e: Exception) { result.error("getRegion-tiff-read-exception", "failed to read from uri=$uri page=$page regionRect=$regionRect", e.message) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index eceb88bce..d14ab3a4e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -139,28 +139,33 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen private fun streamTiffImage(uri: Uri, page: Int = 0) { val resolver = activity.contentResolver try { - var dirCount = 0 - resolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true - } - TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) - dirCount = options.outDirectoryCount + var fd = resolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null) + return } + var options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + val dirCount = options.outDirectoryCount // TODO TLAD handle multipage TIFF if (dirCount > page) { - resolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = false - inDirectoryNumber = page - } - val bitmap = TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) - if (bitmap != null) { - success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) - } else { - error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null) - } + fd = resolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null) + return + } + options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = false + inDirectoryNumber = page + } + val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) + if (bitmap != null) { + success(bitmap.getBytes(canHaveAlpha = true, recycle = true)) + } else { + error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null) } } } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt index 060b312f6..4ee15e9b4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/TiffThumbnailGlideModule.kt @@ -48,30 +48,35 @@ internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, va val uri = model.uri // determine sample size + var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + callback.onLoadFailed(Exception("null file descriptor")) + return + } var sampleSize = 1 - context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true - } - TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) - val imageWidth = options.outWidth - val imageHeight = options.outHeight - if (imageHeight > height || imageWidth > width) { - while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) { - sampleSize *= 2 - } + var options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + val imageWidth = options.outWidth + val imageHeight = options.outHeight + if (imageHeight > height || imageWidth > width) { + while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) { + sampleSize *= 2 } } // decode - val bitmap = context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = false - inSampleSize = sampleSize - } - TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) + fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() + if (fd == null) { + callback.onLoadFailed(Exception("null file descriptor")) + return } - + options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = false + inSampleSize = sampleSize + } + val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options) if (bitmap == null) { callback.onLoadFailed(Exception("null bitmap")) } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index 304ff9acb..2e963ae6a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -249,14 +249,13 @@ class SourceImageEntry { private fun fillByTiffDecode(context: Context) { try { - context.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true - } - TiffBitmapFactory.decodeFileDescriptor(descriptor.fd, options) - width = options.outWidth - height = options.outHeight + val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + width = options.outWidth + height = options.outHeight } catch (e: Exception) { // ignore } From 708bf19f6e4deb84fcab8d8c0ddc02dbfab5ec04 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 4 Jan 2021 20:56:02 +0900 Subject: [PATCH 09/10] minor change --- lib/widgets/fullscreen/info/info_search.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/fullscreen/info/info_search.dart b/lib/widgets/fullscreen/info/info_search.dart index b0496780a..d28c49a6a 100644 --- a/lib/widgets/fullscreen/info/info_search.dart +++ b/lib/widgets/fullscreen/info/info_search.dart @@ -13,7 +13,7 @@ class InfoSearchDelegate extends SearchDelegate { Map get metadata => metadataNotifier.value; static const suggestions = { - 'Date & time': 'date or time or when -timer -uptime -exposure', + 'Date & time': 'date or time or when -timer -uptime -exposure -timeline', 'Description': 'abstract or description or comment', 'Dimensions': 'width or height or dimension or framesize or imagelength', 'Resolution': 'resolution', From a23a402dd5a91ef247e0921cc6daa53ca8dcf52b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 4 Jan 2021 21:00:39 +0900 Subject: [PATCH 10/10] version bump --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- whatsnew/whatsnew-en-US | 7 ++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af81b8b5..2dfec2fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] + +## [v1.3.1] - 2021-01-04 ### Added - Collection: long press and move to select/deselect multiple entries - Info: show Spherical Video V1 metadata @@ -9,6 +11,7 @@ All notable changes to this project will be documented in this file. ### Fixed - Viewer: fixed panning inertia following double-tap scaling +- Collection: fixed crash when loading TIFF files on Android 11 ## [v1.3.0] - 2020-12-26 ### Added diff --git a/pubspec.yaml b/pubspec.yaml index 7a6aeed7f..6fddcb04f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.3.0+36 +version: 1.3.1+37 # brendan-duncan/image (as of v2.1.19): # - does not support TIFF with JPEG compression (issue #184) diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index b6ade0151..55c3fd7e3 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,5 +1,6 @@ Thanks for using Aves! -v1.3.0: -- added quick scale (aka one finger zoom) gesture to the viewer -- fixed zoom focus with double-tap or pinch-to-zoom gestures +v1.3.1: +- long press and move to select/deselect multiple entries +- metadata search in the Info page +- fixed crash when opening a collection with TIFF files on Android 11 Full changelog available on Github \ No newline at end of file