Playback: the flight screen's playback button opens the album over the live view, so a video tapped there plays on the aircraft into the feed with a transport bar. Long press selects files to download or delete (delete asks first); one file opens in a viewer with the camera's preview. Leaving the album puts the camera back in its capture mode. Flight records: every flight is recorded from take-off to landing (1 Hz track, max height / distance / speed, distance flown, battery used) and kept on the phone. When it lands while signed in, it is uploaded to the PilotVault logbook through the existing /api/drones/auto and /api/flights endpoints as an automatic-path entry with the track as GeoJSON. Simulator flights are never uploaded; flights recorded while signed out retry on sign-in. Flight logs lists them with their upload state and a track map, and the profile stats come from real flights. Find My Drone: the aircraft's last fix is kept across lost links and restarts, and shown against the phone's own GPS with distance, direction and a hand-off to the maps app. Live streaming: a new LiveStreamBridge streams the main camera to an RTMP address set in Settings > General, with a LIVE pill on the flight screen that turns amber when the uplink falls behind and stops on tap. Pre-flight checklist (manual checks plus the aircraft's live status) and an in-app Academy with six short guides that work offline. None of this has run on hardware yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.4 KiB
Dart
102 lines
3.4 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import 'parse.dart';
|
|
|
|
/// RTMP live streaming (plan P9), from the bridge's `liveStream` events. The
|
|
/// stream is app-level in the SDK, so it is not cleared on disconnect: the
|
|
/// next event says whether it survived.
|
|
class LiveStreamState extends ChangeNotifier {
|
|
bool streaming = false;
|
|
|
|
/// The last `OnLiveChangeListener` status: `STREAMING`, `STOPPED`, `ERROR`, or null.
|
|
String? status;
|
|
String? url;
|
|
int videoKbps = 0;
|
|
int audioKbps = 0;
|
|
double fps = 0;
|
|
|
|
/// How long it has been streaming, as the SDK counts it.
|
|
int seconds = 0;
|
|
|
|
/// Frames waiting to be sent; a growing cache means the uplink cannot keep up.
|
|
int cacheSize = 0;
|
|
String? error;
|
|
|
|
/// The uplink is behind (more than ~2 s of video cached).
|
|
bool get congested => streaming && cacheSize > 60;
|
|
|
|
void apply(Map<String, dynamic> e) {
|
|
streaming = asBool(e['streaming']) ?? false;
|
|
status = asString(e['status']);
|
|
url = asString(e['url']);
|
|
videoKbps = asInt(e['videoKbps']) ?? 0;
|
|
audioKbps = asInt(e['audioKbps']) ?? 0;
|
|
fps = finite(e['fps']) ?? 0;
|
|
seconds = asInt(e['seconds']) ?? 0;
|
|
cacheSize = asInt(e['cacheSize']) ?? 0;
|
|
error = asString(e['error']);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// `1:05`, `1:02:09`.
|
|
String get elapsedLabel {
|
|
final int h = seconds ~/ 3600, m = (seconds % 3600) ~/ 60, s = seconds % 60;
|
|
final String ss = s.toString().padLeft(2, '0');
|
|
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$ss' : '$m:$ss';
|
|
}
|
|
|
|
/// `2.4 Mbps`, `850 kbps`.
|
|
String get bitrateLabel {
|
|
final int total = videoKbps + audioKbps;
|
|
return total >= 1000 ? '${(total / 1000).toStringAsFixed(1)} Mbps' : '$total kbps';
|
|
}
|
|
}
|
|
|
|
/// The aircraft's own video player (plan P9 in-FPV playback), from
|
|
/// `mediaPlayback` events.
|
|
@immutable
|
|
class MediaPlayback {
|
|
const MediaPlayback({this.index = -1, this.status = 'STOPPED', this.position = 0});
|
|
|
|
factory MediaPlayback.fromEvent(Map<String, dynamic> e) => MediaPlayback(
|
|
index: asInt(e['index']) ?? -1,
|
|
status: asString(e['status']) ?? 'UNKNOWN',
|
|
position: finite(e['position']) ?? 0,
|
|
);
|
|
|
|
/// Index of the playing file in the last `mediaList`, or -1.
|
|
final int index;
|
|
|
|
/// `PLAYING`, `PAUSED`, `STOPPED` or `UNKNOWN`.
|
|
final String status;
|
|
|
|
/// Seconds into the file.
|
|
final double position;
|
|
|
|
bool get playing => status == 'PLAYING';
|
|
bool get paused => status == 'PAUSED';
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is MediaPlayback && other.index == index && other.status == status && other.position == position;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(index, status, position);
|
|
}
|
|
|
|
/// The stream address with its key hidden (the last path segment is the
|
|
/// platform's secret): `rtmp://a.rtmp.youtube.com/live2/•••x9`.
|
|
String maskStreamUrl(String url) {
|
|
final int slash = url.lastIndexOf('/');
|
|
if (slash < 0 || slash < url.indexOf('://') + 3 || slash == url.length - 1) return url;
|
|
final String key = url.substring(slash + 1);
|
|
final String tail = key.length > 4 ? key.substring(key.length - 2) : '';
|
|
return '${url.substring(0, slash + 1)}•••$tail';
|
|
}
|
|
|
|
/// Whether [url] is an address the SDK will stream to.
|
|
bool validStreamUrl(String url) {
|
|
final Uri? u = Uri.tryParse(url.trim());
|
|
return u != null && (u.scheme == 'rtmp' || u.scheme == 'rtmps') && u.host.isNotEmpty && u.pathSegments.where((String s) => s.isNotEmpty).isNotEmpty;
|
|
}
|