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>
175 lines
6.3 KiB
Dart
175 lines
6.3 KiB
Dart
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)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
]),
|
||
);
|
||
}
|
||
}
|