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>
580 lines
20 KiB
Dart
580 lines
20 KiB
Dart
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';
|
||
}
|
||
}
|