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>
319 lines
11 KiB
Dart
319 lines
11 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../dji_service.dart';
|
|
import '../../../flight_model.dart';
|
|
import '../../../fly/camera_menu.dart';
|
|
import '../../../fly/hud_features.dart';
|
|
import '../../../fly/state/camera_state.dart';
|
|
import '../../../theme.dart';
|
|
import '../../pv_icons.dart';
|
|
import '../hud_kit.dart';
|
|
import '../panels/camera_write.dart';
|
|
|
|
/// The right camera column (plan P4): AE lock · photo / video switch ·
|
|
/// shutter (photo, record with its timer, interval / panorama progress ring) ·
|
|
/// MENU · playback and capture modes.
|
|
///
|
|
/// The shutter follows the camera's own state: while an interval, burst or
|
|
/// panorama is running it shows a stop square and the press stops it.
|
|
class CameraColumn extends StatefulWidget {
|
|
const CameraColumn({
|
|
super.key,
|
|
required this.model,
|
|
required this.dji,
|
|
required this.onCameraMenu,
|
|
required this.onPlayback,
|
|
required this.onCaptureModes,
|
|
required this.onError,
|
|
});
|
|
|
|
final FlightModel model;
|
|
final DjiService dji;
|
|
final VoidCallback onCameraMenu;
|
|
final VoidCallback onPlayback;
|
|
final VoidCallback onCaptureModes;
|
|
final void Function(String label, Object error) onError;
|
|
|
|
@override
|
|
State<CameraColumn> createState() => _CameraColumnState();
|
|
}
|
|
|
|
class _CameraColumnState extends State<CameraColumn> {
|
|
FlightModel get _m => widget.model;
|
|
CameraState get _cam => _m.camera;
|
|
|
|
/// The camera's own mode once it has reported one; the switch's choice before that.
|
|
bool get _video => _cam.mode == null ? _m.captureMode == CaptureMode.video : _cam.isVideoMode;
|
|
|
|
Future<void> _run(String label, Future<void> Function() action) async {
|
|
try {
|
|
await action();
|
|
} catch (e) {
|
|
widget.onError(label, e);
|
|
}
|
|
}
|
|
|
|
Future<void> _shutter() async {
|
|
if (_video) {
|
|
await _run(_cam.isRecording ? 'Stop recording' : 'Start recording',
|
|
_cam.isRecording ? widget.dji.stopRecordVideo : widget.dji.startRecordVideo);
|
|
} else if (_cam.shootingContinuous) {
|
|
await _run('Stop shooting', widget.dji.stopShootPhoto);
|
|
} else {
|
|
await _run('Shoot photo', widget.dji.startShootPhoto);
|
|
}
|
|
}
|
|
|
|
Future<void> _setMode(bool video) async {
|
|
if (video == _video) return;
|
|
setState(() => _m.captureMode = video ? CaptureMode.video : CaptureMode.photo);
|
|
_cam.setModeOptimistic(video ? 'RECORD_VIDEO' : 'SHOOT_PHOTO');
|
|
await _run('Set camera mode', () => widget.dji.setCameraMode(video ? 'video' : 'photo'));
|
|
}
|
|
|
|
Future<void> _toggleAeLock() {
|
|
final bool next = !(_cam.aeLock ?? false);
|
|
return writeCameraSetting(
|
|
_cam,
|
|
label: 'AE lock',
|
|
pending: <String, Object?>{'aeLock': next},
|
|
send: () => widget.dji.setCameraValue('aeLock', next),
|
|
onError: widget.onError,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: _cam,
|
|
builder: (BuildContext context, _) {
|
|
final bool video = _video;
|
|
final bool locked = _cam.settings['aeLock'] == true;
|
|
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
_Pill(
|
|
key: const ValueKey<String>('ae-lock'),
|
|
label: 'AE',
|
|
color: locked ? Glass.accent : Glass.pillStrong,
|
|
width: 44,
|
|
height: 26,
|
|
onTap: _toggleAeLock,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(3),
|
|
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
|
|
child: Column(children: <Widget>[
|
|
_modeBtn('image', active: !video, onTap: () => _setMode(false), key: const ValueKey<String>('mode-photo')),
|
|
_modeBtn('video', active: video, onTap: () => _setMode(true), key: const ValueKey<String>('mode-video')),
|
|
]),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ShutterButton(camera: _cam, video: video, onTap: _shutter),
|
|
SizedBox(
|
|
height: 14,
|
|
child: Center(child: Text(_caption(video), style: HudText.label.copyWith(color: Glass.ink))),
|
|
),
|
|
const SizedBox(height: 6),
|
|
_Pill(key: const ValueKey<String>('camera-menu'), label: 'MENU', width: 44, height: 28, onTap: widget.onCameraMenu),
|
|
const SizedBox(height: 8),
|
|
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
Requires(
|
|
HudFeatures.playback,
|
|
child: _Round(key: const ValueKey<String>('playback'), icon: 'play', onTap: widget.onPlayback),
|
|
),
|
|
const SizedBox(width: 4),
|
|
_Round(key: const ValueKey<String>('capture-modes'), icon: 'aperture', onTap: widget.onCaptureModes),
|
|
]),
|
|
]);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Under the shutter: the record timer, or the photo mode.
|
|
String _caption(bool video) {
|
|
if (video) return _cam.isRecording ? _clock(_cam.recordSeconds) : '';
|
|
if (_cam.shootingPanorama) return 'PANO ${_cam.panoramaProgress}%';
|
|
final String? mode = _cam.shootPhotoMode;
|
|
if (mode == null || mode == 'SINGLE') return '';
|
|
final String label = CameraLabels.of(mode).toUpperCase();
|
|
return switch (mode) {
|
|
'INTERVAL' when _cam.intervalSeconds != null => '$label ${_cam.intervalSeconds}s',
|
|
'BURST' when _cam.setting('burstCount') is String => '$label ${CameraLabels.of(_cam.setting('burstCount')! as String)}',
|
|
'AEB' when _cam.setting('aebCount') is String => '$label ${CameraLabels.of(_cam.setting('aebCount')! as String)}',
|
|
_ => label,
|
|
};
|
|
}
|
|
|
|
static String _clock(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}';
|
|
|
|
Widget _modeBtn(String icon, {required bool active, required VoidCallback onTap, Key? key}) => GestureDetector(
|
|
key: key,
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 38,
|
|
height: 26,
|
|
margin: const EdgeInsets.symmetric(vertical: 1),
|
|
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
|
|
alignment: Alignment.center,
|
|
child: PVIcon(icon, size: 15, stroke: 1.8, color: Glass.ink),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The shutter: white disc for photo, red for video, a red square while
|
|
/// recording, and a stop square inside a progress ring while an interval or
|
|
/// panorama runs (interval: one turn per interval; panorama: its progress).
|
|
class ShutterButton extends StatefulWidget {
|
|
const ShutterButton({super.key, required this.camera, required this.video, required this.onTap});
|
|
|
|
final CameraState camera;
|
|
final bool video;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
State<ShutterButton> createState() => _ShutterButtonState();
|
|
}
|
|
|
|
class _ShutterButtonState extends State<ShutterButton> with SingleTickerProviderStateMixin {
|
|
late final AnimationController _interval = AnimationController(vsync: this);
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ShutterButton old) {
|
|
super.didUpdateWidget(old);
|
|
_syncInterval();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_syncInterval();
|
|
}
|
|
|
|
void _syncInterval() {
|
|
final CameraState c = widget.camera;
|
|
final int seconds = c.intervalSeconds ?? 0;
|
|
if (!widget.video && c.shootingInterval && seconds > 0) {
|
|
final Duration d = Duration(seconds: seconds);
|
|
if (!_interval.isAnimating || _interval.duration != d) {
|
|
_interval.duration = d;
|
|
_interval.repeat();
|
|
}
|
|
} else if (_interval.isAnimating) {
|
|
_interval.stop();
|
|
_interval.value = 0;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_interval.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final CameraState c = widget.camera;
|
|
final bool recording = widget.video && c.isRecording;
|
|
final bool running = !widget.video && c.shootingContinuous;
|
|
final double? ring = !running
|
|
? null
|
|
: c.shootingPanorama
|
|
? c.panoramaProgress / 100
|
|
: null; // interval: animated below; burst: no progress to show
|
|
Widget inner;
|
|
if (recording || running) {
|
|
inner = Container(width: 22, height: 22, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5)));
|
|
} else {
|
|
inner = Container(width: 44, height: 44, decoration: BoxDecoration(color: widget.video ? Glass.rec : Colors.white, shape: BoxShape.circle));
|
|
}
|
|
return GestureDetector(
|
|
key: const ValueKey<String>('shutter'),
|
|
onTap: widget.onTap,
|
|
child: SizedBox(
|
|
width: 60,
|
|
height: 60,
|
|
child: Stack(alignment: Alignment.center, children: <Widget>[
|
|
Container(decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4))),
|
|
if (running)
|
|
AnimatedBuilder(
|
|
animation: _interval,
|
|
builder: (BuildContext context, _) => CustomPaint(
|
|
size: const Size(60, 60),
|
|
painter: _RingPainter(ring ?? (c.shootingInterval ? _interval.value : 1)),
|
|
),
|
|
),
|
|
inner,
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RingPainter extends CustomPainter {
|
|
const _RingPainter(this.progress);
|
|
|
|
final double progress;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final Rect r = (Offset.zero & size).deflate(2);
|
|
canvas.drawArc(
|
|
r,
|
|
-math.pi / 2,
|
|
2 * math.pi * progress.clamp(0, 1),
|
|
false,
|
|
Paint()
|
|
..color = Glass.accent
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 4
|
|
..strokeCap = StrokeCap.round,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _RingPainter old) => old.progress != progress;
|
|
}
|
|
|
|
class _Pill extends StatelessWidget {
|
|
const _Pill({super.key, required this.label, required this.onTap, this.color = Glass.pillStrong, this.width = 44, this.height = 28});
|
|
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final Color color;
|
|
final double width;
|
|
final double height;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: width,
|
|
height: height,
|
|
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)),
|
|
alignment: Alignment.center,
|
|
child: Text(label, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, fontWeight: FontWeight.w700, color: Glass.ink)),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _Round extends StatelessWidget {
|
|
const _Round({super.key, required this.icon, required this.onTap});
|
|
|
|
final String icon;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 30,
|
|
height: 30,
|
|
decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: Glass.hairline)),
|
|
alignment: Alignment.center,
|
|
child: PVIcon(icon, size: 14, stroke: 1.8, color: Glass.ink),
|
|
),
|
|
);
|
|
}
|