Auto-detect: on aircraft that can sense subjects themselves, the ActiveTrack panel offers Auto-detect; the subjects it finds show as dashed boxes on the video, and tapping one then GO follows it by its index. Trace gets a Backward switch (fly ahead of a subject walking towards the aircraft, obstacle-sensing aircraft only) and TapFly Free a Reset heading button. Settings > Vision gains Gesture mode and Palm control where the flight assistant supports them, and the HUD shows when palm control is flying the aircraft. The live view used to stretch the feed over the screen. It now fits the picture to the decoder's own size with black bars, and reports that size, so the ActiveTrack / TapFly overlays, tap-to-focus / meter and the grid all map into the picture and ignore taps on the bars. Not yet run on a simulator or aircraft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.7 KiB
Dart
189 lines
6.7 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../../dji_service.dart';
|
||
import '../../../fly/fly_prefs.dart';
|
||
import '../../../fly/hud_features.dart';
|
||
import '../../../fly/modes/vision_modes.dart' show VisionGeometry;
|
||
import '../../../fly/product_profile.dart';
|
||
import '../../../fly/state/camera_state.dart';
|
||
import '../../../theme.dart';
|
||
import '../hud_kit.dart';
|
||
import '../panels/camera_write.dart';
|
||
|
||
/// Tap-to-focus and tap-to-meter on the live view (plan P4).
|
||
///
|
||
/// One tap focuses there — or, on a fixed-focus camera, spot-meters there. A
|
||
/// long press does what the camera menu's "Long press on video" says (GO 4's
|
||
/// Long Press setting; spot metering by default). Spot metering leaves an
|
||
/// "Exposure" box with a × that returns metering to centre-weighted; the box
|
||
/// stays while the camera reports SPOT metering.
|
||
///
|
||
/// Positions are sent as 0..1 of the picture: the native view letterboxes the
|
||
/// feed to [video]'s aspect ratio, so taps are mapped through
|
||
/// [VisionGeometry.contentRect], and a tap on a black bar does nothing.
|
||
class CameraTouchLayer extends StatefulWidget {
|
||
const CameraTouchLayer({
|
||
super.key,
|
||
required this.camera,
|
||
required this.dji,
|
||
required this.connected,
|
||
required this.onError,
|
||
this.video,
|
||
});
|
||
|
||
final CameraState camera;
|
||
final DjiService dji;
|
||
final bool connected;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
/// The feed's picture size; null fills the layer.
|
||
final ValueListenable<Size?>? video;
|
||
|
||
@override
|
||
State<CameraTouchLayer> createState() => _CameraTouchLayerState();
|
||
}
|
||
|
||
enum _Mark { focus, meter }
|
||
|
||
class _CameraTouchLayerState extends State<CameraTouchLayer> {
|
||
Offset? _focusAt;
|
||
Offset? _meterAt;
|
||
Timer? _focusFade;
|
||
|
||
@override
|
||
void dispose() {
|
||
_focusFade?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _act(_Mark mark, Offset local, Size size) async {
|
||
final Offset? at = VisionGeometry.normalise(local, VisionGeometry.contentRect(size, widget.video?.value));
|
||
if (at == null) return; // on a letterbox bar
|
||
final double x = at.dx, y = at.dy;
|
||
setState(() {
|
||
if (mark == _Mark.focus) {
|
||
_focusAt = local;
|
||
_focusFade?.cancel();
|
||
_focusFade = Timer(const Duration(milliseconds: 1500), () {
|
||
if (mounted) setState(() => _focusAt = null);
|
||
});
|
||
} else {
|
||
_meterAt = local;
|
||
}
|
||
});
|
||
if (!widget.connected) return;
|
||
try {
|
||
if (mark == _Mark.focus) {
|
||
await widget.dji.setFocusTarget(x, y);
|
||
} else {
|
||
await widget.dji.setSpotMeteringTarget(x, y);
|
||
}
|
||
} catch (e) {
|
||
if (mark == _Mark.meter && mounted) setState(() => _meterAt = null);
|
||
widget.onError(mark == _Mark.focus ? 'Focus' : 'Spot metering', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _cancelMeter() async {
|
||
setState(() => _meterAt = null);
|
||
if (!widget.connected) return;
|
||
await writeCameraSetting(
|
||
widget.camera,
|
||
label: 'Metering',
|
||
pending: const <String, Object?>{'meteringMode': 'CENTER'},
|
||
send: () => widget.dji.setCameraValue('meteringMode', 'CENTER'),
|
||
onError: widget.onError,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ProductProfile p = ProductProfileScope.of(context);
|
||
// Disconnected, the layer previews both; connected, it follows the camera.
|
||
final bool canFocus = !p.connected || HudFeatures.tapFocus.enabledFor(p);
|
||
final bool canMeter = !p.connected || HudFeatures.spotMetering.enabledFor(p);
|
||
if (!canFocus && !canMeter) return const SizedBox.shrink();
|
||
final _Mark tap = canFocus ? _Mark.focus : _Mark.meter;
|
||
|
||
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
|
||
final Size size = c.biggest;
|
||
return ValueListenableBuilder<LongPressAction>(
|
||
valueListenable: FlyPrefs.longPress,
|
||
builder: (BuildContext context, LongPressAction long, _) {
|
||
final _Mark? longMark = switch (long) {
|
||
LongPressAction.meter when canMeter => _Mark.meter,
|
||
LongPressAction.focus when canFocus => _Mark.focus,
|
||
_ => null,
|
||
};
|
||
return GestureDetector(
|
||
key: const ValueKey<String>('camera-touch'),
|
||
behavior: HitTestBehavior.translucent,
|
||
onTapUp: (TapUpDetails d) => _act(tap, d.localPosition, size),
|
||
// Registered even when set to Nothing, so a long press never ends as a tap.
|
||
onLongPressStart: (LongPressStartDetails d) {
|
||
if (longMark != null) _act(longMark, d.localPosition, size);
|
||
},
|
||
child: ListenableBuilder(
|
||
listenable: widget.camera,
|
||
builder: (BuildContext context, _) {
|
||
// The camera left SPOT (menu, RC, reset): the box goes too.
|
||
final String? metering = widget.camera.meteringMode;
|
||
final Offset? meter = metering != null && metering != 'SPOT' && !widget.camera.isPending('meteringMode') && widget.connected
|
||
? null
|
||
: _meterAt;
|
||
return Stack(clipBehavior: Clip.none, children: <Widget>[
|
||
if (_focusAt != null) _box(_focusAt!, 64, HudColors.ready, null),
|
||
if (meter != null) _box(meter, 76, Glass.ink, 'Exposure'),
|
||
]);
|
||
},
|
||
),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
Widget _box(Offset at, double side, Color color, String? label) {
|
||
return Positioned(
|
||
left: at.dx - side / 2,
|
||
top: at.dy - side / 2,
|
||
width: side,
|
||
height: side,
|
||
child: Stack(clipBehavior: Clip.none, children: <Widget>[
|
||
Positioned.fill(
|
||
child: IgnorePointer(
|
||
child: DecoratedBox(decoration: BoxDecoration(border: Border.all(color: color, width: 1.5))),
|
||
),
|
||
),
|
||
if (label != null) ...<Widget>[
|
||
Positioned(
|
||
left: 0,
|
||
bottom: -16,
|
||
child: IgnorePointer(child: Text(label, style: HudText.label.copyWith(color: Glass.ink))),
|
||
),
|
||
// Inside the box: a child outside its parent's bounds is not hit-tested.
|
||
Positioned(
|
||
right: 2,
|
||
top: 2,
|
||
child: GestureDetector(
|
||
key: const ValueKey<String>('meter-cancel'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: _cancelMeter,
|
||
child: Container(
|
||
width: 22,
|
||
height: 22,
|
||
decoration: const BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle),
|
||
alignment: Alignment.center,
|
||
child: Text('×', style: HudText.value.copyWith(fontSize: 13)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
]),
|
||
);
|
||
}
|
||
}
|