panorama: fixed cropped area, added sensor control on overlay
This commit is contained in:
parent
3b3d3b581e
commit
80d7de43ed
7 changed files with 266 additions and 28 deletions
|
@ -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) {
|
||||
|
|
|
@ -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
40
lib/model/panorama.dart
Normal 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}';
|
||||
}
|
|
@ -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>{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
));
|
||||
},
|
||||
)
|
||||
],
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue