Files
PilotVault/Fly App/lib/fly/camera_menu.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

580 lines
20 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 '../dji_service.dart';
import 'product_profile.dart';
import 'state/camera_state.dart';
import 'state/parse.dart';
/// The camera menu's rows (plan P4), as data: each tab is a list of row specs
/// bound to one camera setting, so adding a setting is one entry here plus,
/// if it is new to the bridge, one line in `CameraBridge.kt`'s settings table.
///
/// Rows read what the camera *reported* ([CameraState.settings], with the
/// menu's pending writes on top) and are built from its capability ranges where
/// it publishes them. GO 4 hides rows per product, and so do we, in two ways:
/// a row's [CameraRow.requires] predicate on the [ProductProfile], and — once
/// the camera has reported its settings — any row whose setting it never
/// reported, because that key does not exist on this camera.
enum CameraMenuTab {
exposure('Exposure'),
photo('Photo'),
video('Video'),
other('Other');
const CameraMenuTab(this.label);
final String label;
}
/// One choice of a [ChoiceRow]: the wire value and its label.
class CameraOption {
const CameraOption(this.value, this.label);
final String value;
final String label;
@override
bool operator ==(Object other) => other is CameraOption && other.value == value && other.label == label;
@override
int get hashCode => Object.hash(value, label);
@override
String toString() => '$value ($label)';
}
typedef CameraPredicate = bool Function(ProductProfile p, CameraState c);
sealed class CameraRow {
const CameraRow({required this.id, required this.label, required this.tab, this.requires, this.reportedBy});
final String id;
final String label;
final CameraMenuTab tab;
/// Product / state condition for the row to exist at all.
final CameraPredicate? requires;
/// The `cameraSettings` key whose presence proves the camera has this
/// setting; null for rows that are always there (actions).
final String? reportedBy;
/// Whether the row shows for [p] / [c].
bool visible(ProductProfile p, CameraState c) {
if (p.connected && requires != null && !requires!(p, c)) return false;
// Before the first report nothing is known; show the row as a preview.
final String? key = reportedBy;
if (key == null || !p.connected || c.reportedSettings.isEmpty) return true;
return c.reportedSettings[key] != null;
}
}
/// Pick one of several values.
class ChoiceRow extends CameraRow {
const ChoiceRow({
required super.id,
required super.label,
required super.tab,
required this.options,
required this.current,
required this.apply,
this.pendingFor,
this.pendingKeys,
super.requires,
super.reportedBy,
});
final List<CameraOption> Function(ProductProfile p, CameraState c) options;
final String? Function(CameraState c) current;
final Future<void> Function(DjiService dji, CameraState c, String value) apply;
/// The settings to show as pending while [apply] runs; `{id: value}` by default.
final Map<String, Object?> Function(String value)? pendingFor;
/// The settings [pendingFor] writes; `[id]` by default.
final List<String>? pendingKeys;
Map<String, Object?> pending(String value) => pendingFor?.call(value) ?? <String, Object?>{id: value};
List<String> get writes => pendingKeys ?? <String>[id];
}
/// On / off.
class SwitchRow extends CameraRow {
const SwitchRow({
required super.id,
required super.label,
required super.tab,
required this.apply,
super.requires,
super.reportedBy,
});
final Future<void> Function(DjiService dji, bool on) apply;
bool? current(CameraState c) => asBool(c.setting(id));
}
/// A number with − / + (custom white balance, picture style).
class StepperRow extends CameraRow {
const StepperRow({
required super.id,
required super.label,
required super.tab,
required this.min,
required this.max,
required this.step,
required this.apply,
this.unit = '',
super.requires,
super.reportedBy,
});
final int min;
final int max;
final int step;
final String unit;
final Future<void> Function(DjiService dji, CameraState c, int value) apply;
int? current(CameraState c) => asInt(c.setting(id));
}
/// A button, optionally behind a slide-to-confirm.
class ActionRow extends CameraRow {
const ActionRow({
required super.id,
required super.label,
required super.tab,
required this.button,
required this.run,
this.confirmTitle,
this.confirmText,
this.danger = false,
this.enabled,
super.requires,
});
final String button;
final Future<void> Function(DjiService dji) run;
final String? confirmTitle;
final String? confirmText;
final bool danger;
/// Greyed out (with the reason) while false, e.g. Format during recording.
final String? Function(CameraState c)? enabled;
/// Why the action is unavailable right now, or null when it can run.
String? blockedReason(CameraState c) => enabled?.call(c);
}
abstract final class CameraMenu {
static List<CameraRow> rowsFor(CameraMenuTab tab, ProductProfile p, CameraState c) => <CameraRow>[
for (final CameraRow r in all)
if (r.tab == tab && r.visible(p, c)) r,
];
// ── Option helpers ─────────────────────────────────────────────────────────
static List<CameraOption> _opts(Iterable<String> values) =>
<CameraOption>[for (final String v in values) CameraOption(v, CameraLabels.of(v))];
/// The camera's own range when it published one, else [fallback].
static List<CameraOption> _ranged(List<String>? reported, List<String> fallback) =>
_opts((reported != null && reported.isNotEmpty) ? reported : fallback);
static Future<void> _set(DjiService dji, String id, Object value) => dji.setCameraValue(id, value);
static bool _mode(CameraState c, String mode) => c.shootPhotoMode == null || c.shootPhotoMode == mode;
// ── Rows ───────────────────────────────────────────────────────────────────
static final List<CameraRow> all = <CameraRow>[
// Exposure (below the quick exposure controls).
ChoiceRow(
id: 'meteringMode',
label: 'Metering',
tab: CameraMenuTab.exposure,
reportedBy: 'meteringMode',
requires: (ProductProfile p, _) => p.camera?.meteringSupported ?? false,
options: (_, __) => _opts(const <String>['CENTER', 'AVERAGE', 'SPOT']),
current: (CameraState c) => c.meteringMode,
apply: (DjiService d, _, String v) => _set(d, 'meteringMode', v),
),
ChoiceRow(
id: 'focusMode',
label: 'Focus',
tab: CameraMenuTab.exposure,
reportedBy: 'focusMode',
requires: (ProductProfile p, _) => p.camera?.adjustableFocalPointSupported ?? false,
options: (_, __) => const <CameraOption>[CameraOption('AUTO', 'AF'), CameraOption('AFC', 'AF-C'), CameraOption('MANUAL', 'MF')],
current: (CameraState c) => asString(c.setting('focusMode')),
apply: (DjiService d, _, String v) => _set(d, 'focusMode', v),
),
SwitchRow(
id: 'aeLock',
label: 'AE lock',
tab: CameraMenuTab.exposure,
reportedBy: 'aeLock',
apply: (DjiService d, bool on) => _set(d, 'aeLock', on),
),
// Photo.
ChoiceRow(
id: 'shootPhotoMode',
label: 'Photo mode',
tab: CameraMenuTab.photo,
reportedBy: 'shootPhotoMode',
options: (ProductProfile p, _) =>
_ranged(p.camera?.photoModes, const <String>['SINGLE', 'HDR', 'BURST', 'AEB', 'INTERVAL', 'PANORAMA']),
current: (CameraState c) => c.shootPhotoMode,
apply: (DjiService d, _, String v) => _set(d, 'shootPhotoMode', v),
),
ChoiceRow(
id: 'burstCount',
label: 'Burst',
tab: CameraMenuTab.photo,
reportedBy: 'burstCount',
requires: (_, CameraState c) => _mode(c, 'BURST'),
options: (_, __) => _opts(const <String>['BURST_COUNT_3', 'BURST_COUNT_5', 'BURST_COUNT_7', 'BURST_COUNT_10', 'BURST_COUNT_14']),
current: (CameraState c) => asString(c.setting('burstCount')),
apply: (DjiService d, _, String v) => _set(d, 'burstCount', v),
),
ChoiceRow(
id: 'aebCount',
label: 'AEB',
tab: CameraMenuTab.photo,
reportedBy: 'aebCount',
requires: (_, CameraState c) => _mode(c, 'AEB'),
options: (_, __) => _opts(const <String>['AEB_COUNT_3', 'AEB_COUNT_5', 'AEB_COUNT_7']),
current: (CameraState c) => asString(c.setting('aebCount')),
apply: (DjiService d, _, String v) => _set(d, 'aebCount', v),
),
ChoiceRow(
id: 'intervalSeconds',
label: 'Interval',
tab: CameraMenuTab.photo,
reportedBy: 'intervalSeconds',
requires: (_, CameraState c) => _mode(c, 'INTERVAL'),
options: (_, __) => <CameraOption>[
for (final int s in const <int>[2, 3, 5, 7, 10, 15, 20, 30, 60]) CameraOption('$s', '${s}s'),
],
current: (CameraState c) => c.intervalSeconds?.toString(),
// 255 photos = until stopped, GO 4's default.
apply: (DjiService d, CameraState c, String v) =>
d.setPhotoTimeInterval(count: c.intervalCount ?? 255, seconds: int.parse(v)),
pendingFor: (String v) => <String, Object?>{'intervalSeconds': int.parse(v)},
),
ChoiceRow(
id: 'panoramaMode',
label: 'Panorama',
tab: CameraMenuTab.photo,
reportedBy: 'panoramaMode',
requires: (_, CameraState c) => _mode(c, 'PANORAMA'),
options: (_, __) => _opts(const <String>[
'PANORAMA_MODE_SPHERE',
'PANORAMA_MODE_180',
'PANORAMA_MODE_3X1',
'PANORAMA_MODE_1X3',
'PANORAMA_MODE_3X3',
]),
current: (CameraState c) => asString(c.setting('panoramaMode')),
apply: (DjiService d, _, String v) => _set(d, 'panoramaMode', v),
),
ChoiceRow(
id: 'photoAspectRatio',
label: 'Image size',
tab: CameraMenuTab.photo,
reportedBy: 'photoAspectRatio',
options: (_, __) => _opts(const <String>['RATIO_4_3', 'RATIO_16_9', 'RATIO_3_2']),
current: (CameraState c) => c.photoAspectRatio,
apply: (DjiService d, _, String v) => _set(d, 'photoAspectRatio', v),
),
ChoiceRow(
id: 'photoFileFormat',
label: 'Image format',
tab: CameraMenuTab.photo,
reportedBy: 'photoFileFormat',
options: (ProductProfile p, _) => _opts(<String>[
'JPEG',
'RAW',
'RAW_AND_JPEG',
if (p.hasThermal) ...<String>['TIFF_14_BIT', 'RADIOMETRIC_JPEG'],
]),
current: (CameraState c) => c.photoFileFormat,
apply: (DjiService d, _, String v) => _set(d, 'photoFileFormat', v),
),
// Video.
ChoiceRow(
id: 'videoSize',
label: 'Video size',
tab: CameraMenuTab.video,
reportedBy: 'videoResolution',
requires: (ProductProfile p, _) => p.camera?.videoResolutions.isNotEmpty ?? false,
options: (ProductProfile p, _) => <CameraOption>[
for (final String v in p.camera?.videoResolutions ?? const <String>[]) CameraOption(v, CameraLabels.videoSize(v)),
],
current: (CameraState c) =>
c.videoResolution == null || c.videoFrameRate == null ? null : '${c.videoResolution}@${c.videoFrameRate}',
apply: (DjiService d, _, String v) {
final List<String> parts = v.split('@');
return d.setVideoResolutionAndFrameRate(parts.first, parts.last);
},
pendingFor: (String v) {
final List<String> parts = v.split('@');
return <String, Object?>{'videoResolution': parts.first, 'videoFrameRate': parts.last};
},
pendingKeys: const <String>['videoResolution', 'videoFrameRate'],
),
ChoiceRow(
id: 'videoFileFormat',
label: 'Video format',
tab: CameraMenuTab.video,
reportedBy: 'videoFileFormat',
options: (_, __) => _opts(const <String>['MOV', 'MP4']),
current: (CameraState c) => c.videoFileFormat,
apply: (DjiService d, _, String v) => _set(d, 'videoFileFormat', v),
),
ChoiceRow(
id: 'videoStandard',
label: 'NTSC / PAL',
tab: CameraMenuTab.video,
reportedBy: 'videoStandard',
options: (ProductProfile p, _) => _ranged(p.camera?.videoStandards, const <String>['NTSC', 'PAL']),
current: (CameraState c) => asString(c.setting('videoStandard')),
apply: (DjiService d, _, String v) => _set(d, 'videoStandard', v),
),
ChoiceRow(
id: 'whiteBalancePreset',
label: 'White balance',
tab: CameraMenuTab.video,
reportedBy: 'whiteBalancePreset',
options: (ProductProfile p, _) => _ranged(
p.camera?.whiteBalancePresets,
const <String>['AUTO', 'SUNNY', 'CLOUDY', 'INDOOR_INCANDESCENT', 'INDOOR_FLUORESCENT', 'CUSTOM'],
),
current: (CameraState c) => c.whiteBalancePreset,
// CUSTOM keeps the last temperature (or 5600 K) until the stepper moves it.
apply: (DjiService d, CameraState c, String v) =>
d.setWhiteBalance(v == 'CUSTOM' ? '${c.whiteBalanceKelvin ?? 5600}K' : v),
),
StepperRow(
id: 'whiteBalanceKelvin',
label: 'Colour temperature',
tab: CameraMenuTab.video,
reportedBy: 'whiteBalancePreset',
requires: (_, CameraState c) => c.whiteBalancePreset == 'CUSTOM',
min: 2000,
max: 10000,
step: 100,
unit: 'K',
apply: (DjiService d, _, int v) => d.setWhiteBalance('${v}K'),
),
StepperRow(
id: 'styleSharpness',
label: 'Sharpness',
tab: CameraMenuTab.video,
reportedBy: 'styleSharpness',
min: -3,
max: 3,
step: 1,
apply: (DjiService d, CameraState c, int v) => _style(d, c, sharpness: v),
),
StepperRow(
id: 'styleContrast',
label: 'Contrast',
tab: CameraMenuTab.video,
reportedBy: 'styleContrast',
min: -3,
max: 3,
step: 1,
apply: (DjiService d, CameraState c, int v) => _style(d, c, contrast: v),
),
StepperRow(
id: 'styleSaturation',
label: 'Saturation',
tab: CameraMenuTab.video,
reportedBy: 'styleSaturation',
min: -3,
max: 3,
step: 1,
apply: (DjiService d, CameraState c, int v) => _style(d, c, saturation: v),
),
ChoiceRow(
id: 'colorProfile',
label: 'Colour',
tab: CameraMenuTab.video,
reportedBy: 'colorProfile',
options: (ProductProfile p, _) => _ranged(p.camera?.colors, const <String>['NONE', 'D_LOG', 'D_CINELIKE']),
current: (CameraState c) => c.colorProfile,
apply: (DjiService d, _, String v) => _set(d, 'colorProfile', v),
),
SwitchRow(
id: 'videoCaption',
label: 'Video captions',
tab: CameraMenuTab.video,
reportedBy: 'videoCaption',
apply: (DjiService d, bool on) => _set(d, 'videoCaption', on),
),
// Other.
SwitchRow(
id: 'histogramEnabled',
label: 'Histogram',
tab: CameraMenuTab.other,
reportedBy: 'histogramEnabled',
apply: (DjiService d, bool on) => d.setHistogramEnabled(on),
),
SwitchRow(
id: 'autoLockGimbal',
label: 'Lock gimbal while shooting',
tab: CameraMenuTab.other,
reportedBy: 'autoLockGimbal',
requires: (ProductProfile p, _) => p.hasGimbal,
apply: (DjiService d, bool on) => _set(d, 'autoLockGimbal', on),
),
ChoiceRow(
id: 'antiFlicker',
label: 'Anti-flicker',
tab: CameraMenuTab.other,
reportedBy: 'antiFlicker',
options: (ProductProfile p, _) =>
_ranged(p.camera?.antiFlickerModes, const <String>['AUTO', 'MANUAL_50HZ', 'MANUAL_60HZ']),
current: (CameraState c) => asString(c.setting('antiFlicker')),
apply: (DjiService d, _, String v) => _set(d, 'antiFlicker', v),
),
ChoiceRow(
id: 'fileIndexMode',
label: 'File index',
tab: CameraMenuTab.other,
reportedBy: 'fileIndexMode',
options: (_, __) => _opts(const <String>['SEQUENCE', 'RESET']),
current: (CameraState c) => asString(c.setting('fileIndexMode')),
apply: (DjiService d, _, String v) => _set(d, 'fileIndexMode', v),
),
ActionRow(
id: 'formatSd',
label: 'Format SD card',
tab: CameraMenuTab.other,
button: 'Format',
danger: true,
confirmTitle: 'Format SD card',
confirmText: 'Every photo and video on the card is erased. This cannot be undone.',
enabled: (CameraState c) {
if (c.isRecording || c.shootingContinuous) return 'Stop shooting first';
if (c.storage != null && !c.storage!.inserted) return 'No SD card';
return null;
},
run: (DjiService d) => d.formatStorage(),
),
ActionRow(
id: 'resetCamera',
label: 'Reset camera settings',
tab: CameraMenuTab.other,
button: 'Reset',
confirmTitle: 'Reset camera settings',
confirmText: 'Every camera setting goes back to its factory default. Photos and videos are kept.',
enabled: (CameraState c) => c.isRecording || c.shootingContinuous ? 'Stop shooting first' : null,
run: (DjiService d) => d.restoreCameraDefaults(),
),
];
/// The style is one SDK value; send all three with the one that moved.
static Future<void> _style(DjiService d, CameraState c, {int? sharpness, int? contrast, int? saturation}) =>
d.setPictureStyle(
sharpness: sharpness ?? asInt(c.setting('styleSharpness')) ?? 0,
contrast: contrast ?? asInt(c.setting('styleContrast')) ?? 0,
saturation: saturation ?? asInt(c.setting('styleSaturation')) ?? 0,
);
}
/// Pilot-facing labels for the SDK's enum names.
abstract final class CameraLabels {
static const Map<String, String> _known = <String, String>{
'SINGLE': 'Single',
'HDR': 'HDR',
'EHDR': 'Enhanced HDR',
'BURST': 'Burst',
'RAW_BURST': 'RAW burst',
'AEB': 'AEB',
'INTERVAL': 'Interval',
'TIME_LAPSE': 'Time-lapse',
'PANORAMA': 'Pano',
'HYPER_LIGHT': 'HyperLight',
'HYPER_LAPSE': 'Hyperlapse',
'SHALLOW_FOCUS': 'Shallow focus',
'HIGH_RESOLUTION': 'High resolution',
'SUPER_RESOLUTION': 'Super resolution',
'RATIO_4_3': '4:3',
'RATIO_16_9': '16:9',
'RATIO_3_2': '3:2',
'RATIO_1_1': '1:1',
'JPEG': 'JPEG',
'RAW': 'RAW',
'RAW_AND_JPEG': 'JPEG + RAW',
'TIFF_14_BIT': 'TIFF',
'RADIOMETRIC_JPEG': 'R-JPEG',
'MOV': 'MOV',
'MP4': 'MP4',
'NTSC': 'NTSC',
'PAL': 'PAL',
'AUTO': 'Auto',
'SUNNY': 'Sunny',
'CLOUDY': 'Cloudy',
'WATER_SURFACE': 'Water',
'INDOOR_INCANDESCENT': 'Incandescent',
'INDOOR_FLUORESCENT': 'Fluorescent',
'PRESET_NEUTRAL': 'Neutral',
'CUSTOM': 'Custom',
'NONE': 'None',
'D_LOG': 'D-Log',
'D_CINELIKE': 'D-Cinelike',
'TRUE_COLOR': 'True colour',
'HLG': 'HLG',
'BLACK_AND_WHITE': 'B&W',
'ART': 'Art',
'CENTER': 'Centre',
'AVERAGE': 'Average',
'SPOT': 'Spot',
'MANUAL_50HZ': '50 Hz',
'MANUAL_60HZ': '60 Hz',
'DISABLED': 'Off',
'SEQUENCE': 'Continuous',
'RESET': 'Reset',
'PANORAMA_MODE_SPHERE': 'Sphere',
'PANORAMA_MODE_180': '180°',
'PANORAMA_MODE_3X1': 'Wide',
'PANORAMA_MODE_1X3': 'Vertical',
'PANORAMA_MODE_3X3': '3×3',
};
static String of(String value) {
final String? k = _known[value];
if (k != null) return k;
final RegExpMatch? count = RegExp(r'^(?:BURST|AEB)_COUNT_(\d+)$').firstMatch(value);
if (count != null) return count.group(1)!;
// FILM_A → Film a; anything else readable.
final String t = value.replaceAll('_', ' ').toLowerCase();
return t.isEmpty ? value : t[0].toUpperCase() + t.substring(1);
}
/// `RESOLUTION_3840x2160@FRAME_RATE_29_DOT_970_FPS` → `4K 29.97p`.
static String videoSize(String wire) {
final List<String> parts = wire.split('@');
final String res = parts.first.replaceFirst('RESOLUTION_', '');
final String fps = parts.length > 1 ? fpsLabel(parts.last) : '';
final String name = switch (res) {
'3840x2160' => '4K',
'4096x2160' => 'C4K',
'2720x1530' => '2.7K',
'1920x1080' => '1080p',
'1280x720' => '720p',
_ => res,
};
return fps.isEmpty ? name : '$name $fps';
}
/// `FRAME_RATE_29_DOT_970_FPS` → `29.97p`.
static String fpsLabel(String wire) {
final String body = wire.replaceFirst('FRAME_RATE_', '').replaceFirst('_FPS', '').replaceAll('_DOT_', '.');
final double? n = double.tryParse(body);
if (n == null) return body;
final String s = n == n.roundToDouble() ? n.toStringAsFixed(0) : n.toStringAsFixed(2).replaceFirst(RegExp(r'0+$'), '');
return '${s}p';
}
}