panorama: fixed cropped area, added sensor control on overlay

This commit is contained in:
Thibault Deckers 2021-01-12 10:52:40 +09:00
parent 3b3d3b581e
commit 80d7de43ed
7 changed files with 266 additions and 28 deletions

View file

@ -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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("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<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {

View file

@ -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,
)

40
lib/model/panorama.dart Normal file
View file

@ -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}';
}

View file

@ -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<PanoramaInfo> 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', <String, dynamic>{
'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<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{

View file

@ -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;

View file

@ -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,
),
),
);
));
},
)
],

View file

@ -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<PanoramaPage> {
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
final ValueNotifier<SensorControl> _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<SensorControl>(
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<bool>(
valueListenable: _overlayVisible,
builder: (context, overlayVisible, child) {
return Visibility(
visible: overlayVisible,
child: Selector<MediaQueryData, EdgeInsets>(
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<SensorControl>(
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<void> _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<void> _onOverlayVisibleChange() async {
if (_overlayVisible.value) {
_showSystemUI();
} else {
_hideSystemUI();
}
}
}