From 80d7de43ed86f36510012190ed354bac26b26a22 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 12 Jan 2021 10:52:40 +0900 Subject: [PATCH] panorama: fixed cropped area, added sensor control on overlay --- .../aves/channel/calls/MetadataHandler.kt | 41 +++++ .../deckers/thibault/aves/metadata/XMP.kt | 18 +- lib/model/panorama.dart | 40 +++++ lib/services/metadata_service.dart | 18 ++ lib/theme/icons.dart | 2 + lib/widgets/viewer/overlay/panorama.dart | 14 +- lib/widgets/viewer/panorama_page.dart | 161 ++++++++++++++++-- 7 files changed, 266 insertions(+), 28 deletions(-) create mode 100644 lib/model/panorama.dart 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 f2e0960a8..a6ba66dea 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 @@ -70,6 +70,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) } "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) } + "getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { getPanoramaInfo(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) } "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) } @@ -539,6 +540,46 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(pages) } + private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null) { + result.error("getPanoramaInfo-args", "failed because of missing arguments", null) + return + } + + if (isSupportedByMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) + try { + fun getProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null } + val fields: FieldMap = hashMapOf( + "croppedAreaLeft" to getProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME), + "croppedAreaTop" to getProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME), + "croppedAreaWidth" to getProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME), + "croppedAreaHeight" to getProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME), + "fullPanoWidth" to getProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME), + "fullPanoHeight" to getProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME), + ) + result.success(fields) + return + } catch (e: XMPException) { + result.error("getPanoramaInfo-args", "failed to read XMP for uri=$uri", e.message) + return + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to read XMP", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to read XMP", e) + } + } + result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null) + } + private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { 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 48c027ba6..1e67bdfa4 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 @@ -42,15 +42,15 @@ object XMP { // panorama // cf https://developers.google.com/streetview/spherical-metadata - private const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/" + const val GPANO_SCHEMA_NS = "http://ns.google.com/photos/1.0/panorama/" private const val PMTM_SCHEMA_NS = "http://www.hdrsoft.com/photomatix_settings01" - private const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels" - private const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels" - private const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels" - private const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels" - private const val GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME = "GPano:FullPanoHeightPixels" - private const val GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME = "GPano:FullPanoWidthPixels" + const val GPANO_CROPPED_AREA_HEIGHT_PROP_NAME = "GPano:CroppedAreaImageHeightPixels" + const val GPANO_CROPPED_AREA_WIDTH_PROP_NAME = "GPano:CroppedAreaImageWidthPixels" + const val GPANO_CROPPED_AREA_LEFT_PROP_NAME = "GPano:CroppedAreaLeftPixels" + const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels" + const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels" + const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels" private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType" private const val PMTM_IS_PANO360 = "pmtm:IsPano360" @@ -60,8 +60,8 @@ object XMP { GPANO_CROPPED_AREA_WIDTH_PROP_NAME, GPANO_CROPPED_AREA_LEFT_PROP_NAME, GPANO_CROPPED_AREA_TOP_PROP_NAME, - GPANO_FULL_PANO_HEIGHT_PIXELS_PROP_NAME, - GPANO_FULL_PANO_WIDTH_PIXELS_PROP_NAME, + GPANO_FULL_PANO_HEIGHT_PROP_NAME, + GPANO_FULL_PANO_WIDTH_PROP_NAME, GPANO_PROJECTION_TYPE_PROP_NAME, ) diff --git a/lib/model/panorama.dart b/lib/model/panorama.dart new file mode 100644 index 000000000..0bebe9501 --- /dev/null +++ b/lib/model/panorama.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class PanoramaInfo { + final Rect croppedAreaRect; + final Size fullPanoSize; + + PanoramaInfo({ + this.croppedAreaRect, + this.fullPanoSize, + }); + + factory PanoramaInfo.fromMap(Map map) { + final cLeft = map['croppedAreaLeft'] as int; + final cTop = map['croppedAreaTop'] as int; + final cWidth = map['croppedAreaWidth'] as int; + final cHeight = map['croppedAreaHeight'] as int; + Rect croppedAreaRect; + if (cLeft != null && cTop != null && cWidth != null && cHeight != null) { + croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble()); + } + + final fWidth = map['fullPanoWidth'] as int; + final fHeight = map['fullPanoHeight'] as int; + Size fullPanoSize; + if (fWidth != null && fHeight != null) { + fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble()); + } + + return PanoramaInfo( + croppedAreaRect: croppedAreaRect, + fullPanoSize: fullPanoSize, + ); + } + + bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null; + + @override + String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize}'; +} diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 930713b29..9a53587c6 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/multipage.dart'; +import 'package:aves/model/panorama.dart'; import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -94,6 +95,23 @@ class MetadataService { return null; } + static Future getPanoramaInfo(ImageEntry entry) async { + try { + // return map with values for: + // 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int), + // 'fullPanoWidth' (int), 'fullPanoHeight' (int) + final result = await platform.invokeMethod('getPanoramaInfo', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }) as Map; + return PanoramaInfo.fromMap(result); + } on PlatformException catch (e) { + debugPrint('PanoramaInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } + static Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index be3ba1ed9..5cdf7f2b5 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -18,6 +18,8 @@ class AIcons { static const IconData raw = Icons.camera_outlined; static const IconData shooting = Icons.camera_outlined; static const IconData removableStorage = Icons.sd_storage_outlined; + static const IconData sensorControl = Icons.explore_outlined; + static const IconData sensorControlOff = Icons.explore_off_outlined; static const IconData settings = Icons.settings_outlined; static const IconData text = Icons.format_quote_outlined; static const IconData tag = Icons.local_offer_outlined; diff --git a/lib/widgets/viewer/overlay/panorama.dart b/lib/widgets/viewer/overlay/panorama.dart index 7c1d47936..9356a8206 100644 --- a/lib/widgets/viewer/overlay/panorama.dart +++ b/lib/widgets/viewer/overlay/panorama.dart @@ -1,7 +1,9 @@ import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/metadata_service.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/panorama_page.dart'; import 'package:flutter/material.dart'; +import 'package:pedantic/pedantic.dart'; class PanoramaOverlay extends StatelessWidget { final ImageEntry entry; @@ -21,14 +23,18 @@ class PanoramaOverlay extends StatelessWidget { OverlayTextButton( scale: scale, text: 'Open Panorama', - onPressed: () { - Navigator.push( + onPressed: () async { + final info = await MetadataService.getPanoramaInfo(entry); + unawaited(Navigator.push( context, MaterialPageRoute( settings: RouteSettings(name: PanoramaPage.routeName), - builder: (context) => PanoramaPage(entry: entry), + builder: (context) => PanoramaPage( + entry: entry, + info: info, + ), ), - ); + )); }, ) ], diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart index b5a2d410a..4391c3f83 100644 --- a/lib/widgets/viewer/panorama_page.dart +++ b/lib/widgets/viewer/panorama_page.dart @@ -1,38 +1,169 @@ import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/panorama.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:panorama/panorama.dart'; +import 'package:provider/provider.dart'; -class PanoramaPage extends StatelessWidget { +class PanoramaPage extends StatefulWidget { static const routeName = '/viewer/panorama'; final ImageEntry entry; - final int page; + final PanoramaInfo info; const PanoramaPage({ @required this.entry, this.page = 0, + @required this.info, }); + @override + _PanoramaPageState createState() => _PanoramaPageState(); +} + +class _PanoramaPageState extends State { + final ValueNotifier _overlayVisible = ValueNotifier(true); + final ValueNotifier _sensorControl = ValueNotifier(SensorControl.None); + + ImageEntry get entry => widget.entry; + + PanoramaInfo get info => widget.info; + + @override + void initState() { + super.initState(); + _overlayVisible.addListener(_onOverlayVisibleChange); + WidgetsBinding.instance.addPostFrameCallback((_) => _initOverlay()); + } + + @override + void dispose() { + _overlayVisible.removeListener(_onOverlayVisibleChange); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Scaffold( - body: Panorama( - child: Image( - image: UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - page: page, - rotationDegrees: entry.rotationDegrees, - isFlipped: entry.isFlipped, - expectedContentLength: entry.sizeBytes, + return WillPopScope( + onWillPop: () { + _onLeave(); + return SynchronousFuture(true); + }, + child: MediaQueryDataProvider( + child: Scaffold( + body: Stack( + children: [ + ValueListenableBuilder( + valueListenable: _sensorControl, + builder: (context, sensorControl, child) { + return Panorama( + sensorControl: sensorControl, + croppedArea: info.hasCroppedArea ? info.croppedAreaRect : Rect.fromLTWH(0.0, 0.0, 1.0, 1.0), + croppedFullWidth: info.hasCroppedArea ? info.fullPanoSize.width : 1.0, + croppedFullHeight: info.hasCroppedArea ? info.fullPanoSize.height : 1.0, + onTap: (longitude, latitude, tilt) => _overlayVisible.value = !_overlayVisible.value, + child: child, + ); + }, + child: Image( + image: UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + page: widget.page, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + expectedContentLength: entry.sizeBytes, + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: ValueListenableBuilder( + valueListenable: _overlayVisible, + builder: (context, overlayVisible, child) { + return Visibility( + visible: overlayVisible, + child: Selector( + selector: (c, mq) => mq.padding + mq.viewInsets, + builder: (c, mqViewInsets, child) { + return Padding( + padding: EdgeInsets.all(8) + EdgeInsets.only(right: mqViewInsets.right, bottom: mqViewInsets.bottom), + child: OverlayButton( + scale: kAlwaysCompleteAnimation, + child: ValueListenableBuilder( + valueListenable: _sensorControl, + builder: (context, sensorControl, child) { + return IconButton( + icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControl : AIcons.sensorControlOff), + onPressed: _toggleSensor, + tooltip: sensorControl == SensorControl.None ? 'Enable sensor control' : 'Disable sensor control', + ); + }), + ), + ); + }, + ), + ); + }, + ), + ), + ), + ], ), + resizeToAvoidBottomInset: false, ), - // TODO TLAD toggle sensor control - sensorControl: SensorControl.None, ), - resizeToAvoidBottomInset: false, ); } + + void _toggleSensor() { + switch (_sensorControl.value) { + case SensorControl.None: + _sensorControl.value = SensorControl.AbsoluteOrientation; + break; + case SensorControl.AbsoluteOrientation: + case SensorControl.Orientation: + _sensorControl.value = SensorControl.None; + break; + } + } + + void _onLeave() { + _showSystemUI(); + } + + // system UI + + static void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); + + static void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]); + + // overlay + + Future _initOverlay() async { + // wait for MaterialPageRoute.transitionDuration + // to show overlay after page animation is complete + await Future.delayed(ModalRoute.of(context).transitionDuration * timeDilation); + await _onOverlayVisibleChange(); + } + + Future _onOverlayVisibleChange() async { + if (_overlayVisible.value) { + _showSystemUI(); + } else { + _hideSystemUI(); + } + } }