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 Function(ProductProfile p, CameraState c) options; final String? Function(CameraState c) current; final Future Function(DjiService dji, CameraState c, String value) apply; /// The settings to show as pending while [apply] runs; `{id: value}` by default. final Map Function(String value)? pendingFor; /// The settings [pendingFor] writes; `[id]` by default. final List? pendingKeys; Map pending(String value) => pendingFor?.call(value) ?? {id: value}; List get writes => pendingKeys ?? [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 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 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 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 rowsFor(CameraMenuTab tab, ProductProfile p, CameraState c) => [ for (final CameraRow r in all) if (r.tab == tab && r.visible(p, c)) r, ]; // ── Option helpers ───────────────────────────────────────────────────────── static List _opts(Iterable values) => [for (final String v in values) CameraOption(v, CameraLabels.of(v))]; /// The camera's own range when it published one, else [fallback]. static List _ranged(List? reported, List fallback) => _opts((reported != null && reported.isNotEmpty) ? reported : fallback); static Future _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 all = [ // 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 ['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('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 ['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 ['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 ['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: (_, __) => [ for (final int s in const [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) => {'intervalSeconds': int.parse(v)}, ), ChoiceRow( id: 'panoramaMode', label: 'Panorama', tab: CameraMenuTab.photo, reportedBy: 'panoramaMode', requires: (_, CameraState c) => _mode(c, 'PANORAMA'), options: (_, __) => _opts(const [ '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 ['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([ 'JPEG', 'RAW', 'RAW_AND_JPEG', if (p.hasThermal) ...['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, _) => [ for (final String v in p.camera?.videoResolutions ?? const []) 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 parts = v.split('@'); return d.setVideoResolutionAndFrameRate(parts.first, parts.last); }, pendingFor: (String v) { final List parts = v.split('@'); return {'videoResolution': parts.first, 'videoFrameRate': parts.last}; }, pendingKeys: const ['videoResolution', 'videoFrameRate'], ), ChoiceRow( id: 'videoFileFormat', label: 'Video format', tab: CameraMenuTab.video, reportedBy: 'videoFileFormat', options: (_, __) => _opts(const ['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 ['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 ['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 ['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 ['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 ['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 _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 _known = { '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 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'; } }