Files
PilotVault/Fly App/lib/ui/fly/overlays/camera_touch_layer.dart
T
tajniak81andClaude Opus 5.5 c1770fff73 Give the Fly App GO 4-style camera controls
One settings table in CameraBridge watches every camera value into the
telemetry tick and backs a generic setCameraValue, so the tabbed camera
menu (Exposure / Photo / Video / Other) is built from row specs and the
camera's own capability ranges. Writes show at once and fall back to what
the camera reports. Adds the quick exposure panel (Auto/A/S/M, ISO slider,
shutter and aperture wheels, EV strip, metered EV in M), a new camera
column with record timer and interval/pano progress, a zoom column for
zoom cameras, tap-to-focus / tap-to-meter, and a live histogram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:57:38 +02:00

175 lines
6.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../fly/fly_prefs.dart';
import '../../../fly/hud_features.dart';
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 across this layer, which covers the full-bleed
/// video; with a letterboxed feed the edges are slightly off, which the
/// camera's coarse metering grid absorbs.
class CameraTouchLayer extends StatefulWidget {
const CameraTouchLayer({super.key, required this.camera, required this.dji, required this.connected, required this.onError});
final CameraState camera;
final DjiService dji;
final bool connected;
final void Function(String label, Object error) onError;
@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 double x = (local.dx / size.width).clamp(0.0, 1.0), y = (local.dy / size.height).clamp(0.0, 1.0);
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)),
),
),
),
],
]),
);
}
}