Files
PilotVault/Fly App/lib/fly/state/live_stream_state.dart
T
tajniak81andClaude Opus 5.5 91ca150a35 Give the Fly App GO 4-style playback, flight records, Find My Drone and live streaming
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>
2026-10-20 22:07:10 +02:00

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;
}