import 'package:flutter/foundation.dart'; import 'parse.dart'; import 'settings_overlay.dart'; /// The camera's storage (SD card, or internal storage on cameras that have it). @immutable class StorageInfo { const StorageInfo({ this.location, this.inserted = false, this.initializing = false, this.formatting = false, this.formatted = false, this.full = false, this.error = false, this.readOnly = false, this.invalidFormat = false, this.verified = false, this.totalMB, this.remainingMB, this.availableCaptureCount, this.availableRecordingSeconds, }); factory StorageInfo.fromMap(Map m) => StorageInfo( location: asString(m['location']), inserted: asBool(m['inserted']) ?? false, initializing: asBool(m['initializing']) ?? false, formatting: asBool(m['formatting']) ?? false, formatted: asBool(m['formatted']) ?? false, full: asBool(m['full']) ?? false, error: asBool(m['error']) ?? false, readOnly: asBool(m['readOnly']) ?? false, invalidFormat: asBool(m['invalidFormat']) ?? false, verified: asBool(m['verified']) ?? false, totalMB: asInt(m['totalMB']), remainingMB: asInt(m['remainingMB']), availableCaptureCount: asInt(m['availableCaptureCount']), availableRecordingSeconds: asInt(m['availableRecordingSeconds']), ); final String? location; // SDCARD / INTERNAL_STORAGE final bool inserted; final bool initializing; final bool formatting; final bool formatted; final bool full; final bool error; final bool readOnly; final bool invalidFormat; final bool verified; final int? totalMB; final int? remainingMB; final int? availableCaptureCount; final int? availableRecordingSeconds; /// One word for the HUD's storage chip, worst condition first. String get status { if (!inserted) return 'No SD'; if (error) return 'SD error'; if (invalidFormat) return 'Format SD'; if (formatting) return 'Formatting'; if (initializing) return 'Initializing'; if (full) return 'SD full'; if (readOnly) return 'Read-only'; return 'OK'; } bool get usable => inserted && !error && !invalidFormat && !formatting && !initializing && !full; } /// The SSD on cameras that record to one (X5S / X7 on Inspire 2). @immutable class SsdInfo { const SsdInfo({this.connected = false, this.state, this.capacity, this.remainingMB, this.availableRecordingSeconds}); factory SsdInfo.fromMap(Map m) => SsdInfo( connected: asBool(m['connected']) ?? false, state: asString(m['state']), capacity: asString(m['capacity']), remainingMB: asInt(m['remainingMB']), availableRecordingSeconds: asInt(m['availableRecordingSeconds']), ); final bool connected; /// `SSDOperationState`: `NOT_FOUND` / `IDLE` / `SAVING` / `FORMATTING` / /// `INITIALIZING` / `ERROR` / `FULL` / `POOR_CONNECTION` … final String? state; final String? capacity; // CAPACITY_512_GB … final int? remainingMB; final int? availableRecordingSeconds; /// One word for the HUD's SSD chip, like [StorageInfo.status]. String get status { if (!connected || state == 'NOT_FOUND') return 'No SSD'; return switch (state) { 'ERROR' => 'SSD error', 'FULL' => 'SSD full', 'FORMATTING' => 'Formatting', 'INITIALIZING' => 'Initializing', 'POOR_CONNECTION' => 'SSD loose', _ => 'OK', }; } bool get usable => status == 'OK'; } /// Optical / hybrid zoom state. Focal lengths are in the camera's own units /// (tenths of a millimetre on the Mavic 2 Zoom); [factor] is relative to [min]. @immutable class ZoomInfo { const ZoomInfo({required this.focalLength, required this.min, required this.max, this.opticalMax, this.step}); static ZoomInfo? fromSettings(Map m) { final int? f = asInt(m['zoomFocalLength']), lo = asInt(m['zoomMin']), hi = asInt(m['zoomMax']); if (f == null || lo == null || hi == null || lo <= 0 || hi <= lo) return null; return ZoomInfo(focalLength: f, min: lo, max: hi, opticalMax: asInt(m['zoomOpticalMax']), step: asInt(m['zoomStep'])); } final int focalLength; final int min; final int max; final int? opticalMax; final int? step; double get factor => focalLength / min; double get maxFactor => max / min; /// Past the optical range on a hybrid camera (the image is cropped). bool get digital => opticalMax != null && focalLength > opticalMax!; /// The next focal length one T / W press lands on: an eighth of the range, /// snapped to the camera's step. int stepped(bool zoomIn) { final int s = (step ?? 1) <= 0 ? 1 : step!; final int raw = ((max - min) / 8).round(); final int delta = raw < s ? s : (raw ~/ s) * s; return (focalLength + (zoomIn ? delta : -delta)).clamp(min, max); } } /// Camera state from the bridge: recording, exposure read-back, storage and /// the watched camera settings. Recording and exposure arrive as their own /// `camera` / `exposure` events; storage and settings as the `storage` and /// `cameraSettings` groups of the telemetry tick (≤2 Hz). class CameraState extends ChangeNotifier with SettingsOverlay { bool isRecording = false; int recordSeconds = 0; // Shooting flags from the system state. bool shootingSingle = false; bool shootingInterval = false; bool shootingBurst = false; bool shootingPanorama = false; int panoramaProgress = 0; // 0..100 bool storingPhoto = false; bool overheating = false; bool hasError = false; /// Any multi-shot capture the shutter button should stop rather than start. bool get shootingContinuous => shootingInterval || shootingPanorama || shootingBurst; /// `SHOOT_PHOTO` / `RECORD_VIDEO` / `PLAYBACK` / `MEDIA_DOWNLOAD`. String? mode; // Exposure read-back, in the same labels the setters accept. String? iso; // "100", "AUTO", … String? shutter; // "1/240" String? aperture; // "f/2.8" String? ev; // "-0.3" StorageInfo? storage; /// Only on cameras with an SSD slot; null elsewhere. SsdInfo? ssd; /// Spot-meter temperature in °C, only from thermal cameras. double? thermalSpot; // The watched settings, as the HUD shows them (pending writes included). bool? get aeLock => asBool(settings['aeLock']); String? get focusMode => asString(settings['focusMode']); // MANUAL / AUTO / AFC String? get photoAspectRatio => asString(settings['photoAspectRatio']); // RATIO_4_3 / RATIO_16_9 / RATIO_3_2 String? get photoFileFormat => asString(settings['photoFileFormat']); // JPEG / RAW / RAW_AND_JPEG String? get videoResolution => asString(settings['videoResolution']); // RESOLUTION_3840x2160 … String? get videoFrameRate => asString(settings['videoFrameRate']); // FRAME_RATE_29_DOT_970_FPS … String? get videoFileFormat => asString(settings['videoFileFormat']); // MOV / MP4 String? get colorProfile => asString(settings['colorProfile']); // NONE / D_LOG / … // [settings] ([SettingsOverlay]) is the whole `cameraSettings` group — // every id of `CameraBridge.kt`'s settings table plus the composite values — // overlaid with the camera menu's pending writes. The menu's rows read it // through [setting]. /// `PROGRAM` / `SHUTTER_PRIORITY` / `APERTURE_PRIORITY` / `MANUAL`. String? get exposureMode => asString(settings['exposureMode']); /// The ISO setting (`AUTO` included); [iso] is what the camera is using now. String? get isoSetting => asString(settings['isoSetting']); String? get whiteBalancePreset => asString(settings['whiteBalancePreset']); int? get whiteBalanceKelvin => asInt(settings['whiteBalanceKelvin']); String? get shootPhotoMode => asString(settings['shootPhotoMode']); String? get meteringMode => asString(settings['meteringMode']); int? get intervalSeconds => asInt(settings['intervalSeconds']); int? get intervalCount => asInt(settings['intervalCount']); bool get histogramEnabled => asBool(settings['histogramEnabled']) ?? false; /// Zoom, in the camera's own focal-length units; null on cameras without it. ZoomInfo? get zoom => ZoomInfo.fromSettings(settings); /// Luminance histogram bins while [histogramEnabled]; a separate notifier /// because frames arrive at 4 Hz and only the histogram widget redraws. final ValueNotifier?> histogram = ValueNotifier?>(null); DateTime? updatedAt; void applySystem(Map e) { isRecording = asBool(e['isRecording']) ?? false; recordSeconds = asInt(e['recordingTimeSeconds']) ?? recordSeconds; mode = asString(e['mode']) ?? mode; shootingSingle = asBool(e['shootingSingle']) ?? false; shootingInterval = asBool(e['shootingInterval']) ?? false; shootingBurst = asBool(e['shootingBurst']) ?? false; shootingPanorama = asBool(e['shootingPanorama']) ?? false; panoramaProgress = asInt(e['panoramaProgress']) ?? 0; storingPhoto = asBool(e['storingPhoto']) ?? false; overheating = asBool(e['overheating']) ?? false; hasError = asBool(e['hasError']) ?? false; notifyListeners(); } void applyHistogram(Map e) { final Object? bins = e['bins']; histogram.value = bins is List ? [for (final Object? b in bins) asInt(b) ?? 0] : null; } /// Sets [mode] ahead of the camera's own `camera` event, so the HUD follows /// the photo / video switch at once. void setModeOptimistic(String mode) { if (this.mode == mode) return; this.mode = mode; notifyListeners(); } bool get isVideoMode => mode == 'RECORD_VIDEO'; void applyExposure(Map e) { iso = asString(e['iso']); shutter = asString(e['shutter']); aperture = asString(e['aperture']); ev = asString(e['ev']); notifyListeners(); } /// From the telemetry tick; an absent group means the camera never reported it. void applyTelemetry({ Map? storage, Map? ssd, Map? thermal, Map? settings, }) { this.storage = storage == null ? null : StorageInfo.fromMap(storage); this.ssd = ssd == null ? null : SsdInfo.fromMap(ssd); thermalSpot = finite(thermal?['spotTemperature']); replaceReported(settings ?? const {}); updatedAt = DateTime.now(); notifyListeners(); } void clear() { isRecording = false; recordSeconds = 0; shootingSingle = shootingInterval = shootingBurst = shootingPanorama = false; panoramaProgress = 0; storingPhoto = overheating = hasError = false; clearSettings(); histogram.value = null; mode = iso = shutter = aperture = ev = null; storage = null; ssd = null; thermalSpot = null; updatedAt = null; notifyListeners(); } }