MarsCarController/lib/pages/player_page.dart
2025-07-03 09:17:39 +08:00

750 lines
27 KiB
Dart

import 'dart:io';
import 'dart:convert';
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_joystick/flutter_joystick.dart';
import 'package:marscar_controller/main.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:marscar_controller/pages/square_joystick.dart';
class PlayerPage extends StatefulWidget {
const PlayerPage({super.key});
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> {
late final player = Player();
late final controller = VideoController(player);
bool finishConnection = false;
Timer? _reconnectionTimer;
final int callBackPeriod = 10;
RawDatagramSocket? _udpSocket;
String _pingResult = '正在连接图传...';
Socket? _timestampSocket;
StreamSubscription? _socketSubscription;
final List<int> _latencyReadings = [];
final int _latencyWindow = 30;
Timer? _latencyTestTimer;
String _controlLatencyResult = '正在连接控制器...';
final List<int> _controlLatencyReadings = [];
final int _controlLatencyWindow = 10;
Timer? _udpSendTimer;
final List<FlSpot> _videoLatencyHistory = [];
final List<FlSpot> _controlLatencyHistory = [];
final int _maxHistoryCount = 100;
Timer? _connectionHealthTimer;
DateTime? _lastMessageReceivedTime;
String _bitrateInfo = '速率: N/A';
Timer? _infoTimer;
double maxPawRateScale = 0.35;
Set<double> _pawScaleSelection = {0.35};
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
GlobalSettingState globalSettingState = context.read<GlobalSettingState>();
_initializeUdpSocket();
_latencyTestTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
if (_udpSocket == null || globalSettingState.carIP.isEmpty) return;
final sendTime = DateTime.now().millisecondsSinceEpoch;
final data = utf8.encode('TESTTIME=$sendTime');
try {
_udpSocket!.send(data, InternetAddress(globalSettingState.carIP), 80);
} catch (e) {
if (mounted) {
setState(() {
_controlLatencyResult = '控制延迟测试失败';
});
}
}
});
_infoTimer = Timer.periodic(const Duration(seconds: 1), (_) {
_updateStreamInfo();
});
_udpSendTimer = Timer.periodic(Duration(milliseconds: callBackPeriod), (timer) {
if (_udpSocket == null || globalSettingState.carIP.isEmpty) return;
List<int> data = utf8.encode('LX=$lx&LY=$ly&RX=$rx&RY=$ry&FORWARD=$forward&BACKWARD=$backward&LEFT=$left&RIGHT=$right&UP=$up&DOWN=$down&PAW=$paw');
try {
_udpSocket!.send(data, InternetAddress(globalSettingState.carIP), 80);
if (reset == 1) {
List<int> data = utf8.encode('RESET=1');
_udpSocket!.send(data, InternetAddress(globalSettingState.carIP), 80);
setState(() {
reset = 0;
});
}
if (getball == 1) {
List<int> data = utf8.encode('GETBALL=1');
_udpSocket!.send(data, InternetAddress(globalSettingState.carIP), 80);
setState(() {
getball = 0;
});
}
} catch (e) {
_showErrorSnackBar('控制失败: $e');
timer.cancel();
}
});
if (player.platform is NativePlayer) {
final props = {
'profile': 'low-latency',
'untimed': 'yes',
'demuxer-max-bytes': '102400',
'demuxer-max-back-bytes': '102400',
'demuxer-readahead-secs': '0.1',
'cache': 'no',
'cache-pause': 'no',
'vd-lavc-threads': '1',
'framedrop': 'vo',
'hwdec': 'auto-safe',
};
for (final entry in props.entries) {
(player.platform as NativePlayer).setProperty(entry.key, entry.value);
}
}
player.open(Media('rtsp://${globalSettingState.cameraIP}:8554/live'));
_connectToTimestampServer(globalSettingState.cameraIP);
}
void _handleDisconnection() {
if (!mounted) return;
if (_reconnectionTimer?.isActive ?? false) return;
_socketSubscription?.cancel();
_timestampSocket?.destroy();
_latencyReadings.clear();
if (mounted) {
setState(() {
_pingResult = '图传已断开, 10秒后重连...';
finishConnection = true;
});
}
GlobalSettingState globalSettingState = context.read<GlobalSettingState>();
final ip = globalSettingState.cameraIP;
if (ip.isNotEmpty) {
_reconnectionTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
if (!mounted) {
timer.cancel();
return;
}
player.open(Media('rtsp://${globalSettingState.cameraIP}:8554/live'));
_connectToTimestampServer(ip);
});
}
}
void _startHealthCheckTimer() {
_connectionHealthTimer?.cancel();
_connectionHealthTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_lastMessageReceivedTime != null &&
DateTime.now().difference(_lastMessageReceivedTime!) > const Duration(seconds: 10)) {
_connectionHealthTimer?.cancel();
_showErrorSnackBar('图传信号丢失,正在重连...');
_handleDisconnection();
}
});
}
void _updateStreamInfo() async {
if (player.platform is! NativePlayer) return;
final nativePlayer = player.platform as NativePlayer;
try {
final bitrate = await nativePlayer.getProperty('video-bitrate');
if (mounted) {
setState(() {
final bitrateInKbps = (double.tryParse(bitrate) ?? 0) / 1024;
_bitrateInfo = '速率: ${bitrateInKbps.toStringAsFixed(0)} Kbps';
});
}
} catch (e) {
// Handle potential errors if properties are not available
}
}
void _initializeUdpSocket() async {
try {
_udpSocket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
_udpSocket?.listen((RawSocketEvent event) {
if (event == RawSocketEvent.read) {
Datagram? datagram = _udpSocket!.receive();
if (datagram == null) return;
final message = utf8.decode(datagram.data);
if (message.startsWith('TESTTIME=')) {
final sentTimestampStr = message.substring(9);
final sentTimestamp = int.tryParse(sentTimestampStr);
if (sentTimestamp != null) {
final latency = DateTime.now().millisecondsSinceEpoch - sentTimestamp;
_controlLatencyReadings.add(latency);
if (_controlLatencyReadings.length > _controlLatencyWindow) {
_controlLatencyReadings.removeAt(0);
}
final avgLatency = _controlLatencyReadings.reduce((a, b) => a + b) / _controlLatencyReadings.length;
if (mounted) {
setState(() {
_controlLatencyResult = '控制RTT: ${avgLatency.toStringAsFixed(0)} ms';
_controlLatencyHistory.add(FlSpot(DateTime.now().millisecondsSinceEpoch.toDouble(), avgLatency));
if (_controlLatencyHistory.length > _maxHistoryCount) {
_controlLatencyHistory.removeAt(0);
}
});
}
}
}
}
});
} catch (e) {
_showErrorSnackBar('控制模块初始化失败');
}
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
double lx = 0.0, ly = 0.0, rx = 0.0, ry = 0.0, paw = 0.0, forward = 0.0, backward = 0.0, left = 0.0, right = 0.0, up = 0.0, down = 0.0;
int getball = 0, reset = 0;
bool hasSendZeroPack = false;
void _connectToTimestampServer(String ip) async {
if (ip.isEmpty) {
if (!mounted) return;
setState(() {
_pingResult = 'IP地址未设置';
});
return;
}
try {
if (!mounted) return;
setState(() {
_pingResult = '正在连接图传服务...';
});
_timestampSocket = await Socket.connect(ip, 8555, timeout: const Duration(seconds: 5));
_reconnectionTimer?.cancel();
_lastMessageReceivedTime = DateTime.now();
_startHealthCheckTimer();
setState(() {
finishConnection = true;
});
_socketSubscription = _timestampSocket?.listen(
(data) {
_lastMessageReceivedTime = DateTime.now();
final message = utf8.decode(data).trim();
final serverTimestampStr = message.split('\n').last;
if (serverTimestampStr.isNotEmpty) {
final serverTimestamp = int.tryParse(serverTimestampStr);
if (serverTimestamp != null) {
final clientTimestamp = DateTime.now().millisecondsSinceEpoch;
final latency = clientTimestamp - serverTimestamp;
if (latency > 0) {
_latencyReadings.add(latency);
if (_latencyReadings.length > _latencyWindow) {
_latencyReadings.removeAt(0);
}
}
if (_latencyReadings.isNotEmpty) {
final avgLatency = _latencyReadings.reduce((a, b) => a + b) / _latencyReadings.length;
if (mounted) {
setState(() {
_pingResult = '图传信号延迟: ${avgLatency.toStringAsFixed(0)} ms';
_videoLatencyHistory.add(FlSpot(DateTime.now().millisecondsSinceEpoch.toDouble(), avgLatency));
if (_videoLatencyHistory.length > _maxHistoryCount) {
_videoLatencyHistory.removeAt(0);
}
});
}
}
}
}
},
onError: (error) {
if (!mounted) return;
setState(() {
_pingResult = '图传出错';
_handleDisconnection();
});
_showErrorSnackBar('图传出错');
_handleDisconnection();
},
onDone: () {
if (!mounted) return;
setState(() {
_pingResult = '图传已断开';
});
_showErrorSnackBar('图传已断开');
_handleDisconnection();
},
cancelOnError: true,
);
} catch (e) {
setState(() {
_pingResult = '连接图传失败';
});
_showErrorSnackBar('连接图传失败');
_handleDisconnection();
}
}
@override
void dispose() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
player.dispose();
_socketSubscription?.cancel();
_timestampSocket?.destroy();
_udpSendTimer?.cancel();
_udpSocket?.close();
_latencyTestTimer?.cancel();
_latencyReadings.clear();
_controlLatencyReadings.clear();
_infoTimer?.cancel();
_connectionHealthTimer?.cancel();
_reconnectionTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).primaryColor,
body: Center(
child: Stack(
children: [
SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Video(
controller: controller,
controls: (state) => const SizedBox.shrink(),
),
),
if(finishConnection) Positioned(
top: 8,
left: 64,
child: ElevatedButton(
onPressed: () {
GlobalSettingState globalSettingState = context.read<GlobalSettingState>();
globalSettingState.setCameraIP('');
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(Color.fromARGB(60, 255, 255, 255)),
iconColor: WidgetStateProperty.all(Color.fromARGB(200, 255, 255, 255)),
),
child: Icon(Icons.arrow_back),
),
),
Positioned(
top: 8,
right: 0,
left: 0,
child: Center(
child: Container(
width: 400,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: const Color.fromARGB(100, 0, 0, 0),
),
child: ExpansionTile(
collapsedIconColor: Colors.white,
iconColor: Colors.white,
title: Text(
'$_pingResult $_bitrateInfo\n$_controlLatencyResult',
style: const TextStyle(color: Colors.white, fontSize: 12),
textAlign: TextAlign.center,
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'图传延迟 (ms)',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
const SizedBox(height: 8),
SizedBox(
height: 80,
child: LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: _videoLatencyHistory,
isCurved: true,
color: Colors.white,
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
],
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (value, meta) {
return Text(
'${value.toInt()}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
);
},
),
),
bottomTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: true,
getDrawingHorizontalLine: (value) => const FlLine(
color: Color.fromARGB(50, 255, 255, 255),
strokeWidth: 1,
),
getDrawingVerticalLine: (value) => const FlLine(
color: Color.fromARGB(50, 255, 255, 255),
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: const Color.fromARGB(80, 255, 255, 255)),
),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'控制RTT (ms)',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
const SizedBox(height: 8),
SizedBox(
height: 80,
child: LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: _controlLatencyHistory,
isCurved: true,
color: Colors.white,
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(show: false),
),
],
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (value, meta) {
return Text(
'${value.toInt()}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
);
},
),
),
bottomTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: true,
getDrawingHorizontalLine: (value) => const FlLine(
color: Color.fromARGB(50, 255, 255, 255),
strokeWidth: 1,
),
getDrawingVerticalLine: (value) => const FlLine(
color: Color.fromARGB(50, 255, 255, 255),
strokeWidth: 1,
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: const Color.fromARGB(80, 255, 255, 255)),
),
),
),
),
],
),
),
],
),
),
),
),
Positioned(
left: 64,
bottom: 32,
child: SizedBox(
width: 160,
height: 160,
child: SquareJoystick(
onChanged: (Offset position) {
setState(() {
lx = position.dx * 127 + 127.5;
ly = position.dy * 127 + 127.5;
});
},
),
),
),
Positioned(
right: 128,
bottom: 180,
child: SizedBox(
width: 160,
height: 160,
child: Joystick2D(
onChanged: (Offset position) {
if(position.dx == 0 && position.dy == 0) {
left = right = forward = backward = 0;
} else {
var angle = -atan2(position.dy, position.dx), pi_18 = pi / 18.0;
setState(() {
left = angle < - pi_18 * 12 || angle > pi_18 * 12 ? position.dx * maxPawRateScale : 0;
right = angle > - pi_18 * 6 && angle < pi_18 * 6 ? position.dx * maxPawRateScale : 0;
forward = angle > pi_18 * 3 && angle < pi_18 * 15 ? -position.dy * maxPawRateScale : 0;
backward = angle > - pi_18 * 15 && angle < - pi_18 * 3 ? -position.dy * maxPawRateScale : 0;
});
}
},
),
),
),
Positioned(
bottom: 180,
right: 54,
child: SizedBox(
width: 60,
height: 160,
child: VerticalJoystick(
onChanged: (double y) {
setState(() {
up = y < 0 ? -y * maxPawRateScale * 0.6 : 0;
down = y > 0 ? -y * maxPawRateScale * 0.6 : 0;
});
},
),
),
),
Positioned(
bottom: 90,
right: 64,
child: SizedBox(
width: 160,
height: 60,
child: HorizontalJoystick(
onChanged: (double x) {
setState(() {
ry = x * 127 + 127.5;
});
},
),
),
),
Positioned(
bottom: 30,
right: 64,
child: SizedBox(
width: 160,
height: 60,
child: HorizontalJoystick(
onChanged: (double x) {
setState(() {
rx = x * 127 / 2 + 127.5;
});
},
),
),
),
Positioned(
bottom: 30,
right: 256,
child: Joystick(
includeInitialAnimation: false,
period: Duration(milliseconds: callBackPeriod),
mode: JoystickMode.vertical,
stick: CircleAvatar(
radius: 20,
backgroundColor: Color.fromARGB(200, 255, 255, 255),
),
base: Container(
width: 32,
height: 120,
decoration: BoxDecoration(
color: Color.fromARGB(60, 255, 255, 255),
borderRadius: const BorderRadius.all(
Radius.circular(20)
),
border: BoxBorder.all(
color: Theme.of(context).colorScheme.outline,
width: 1
)
)
),
listener: (details) {
setState(() {
paw = details.y / 2 + 0.5;
});
},
),
),
if(finishConnection) Positioned(
top: 8,
right: 64,
child: IconButton.filled(
iconSize: 24.0,
onPressed: () {
setState(() {
reset = 1;
});
},
icon: Icon(Icons.refresh),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(Color.fromARGB(60, 0, 0, 0)),
iconColor: WidgetStateProperty.all(Color.fromARGB(200, 255, 255, 255))
),
)
),
if(finishConnection) Positioned(
top: 8,
right: 120,
child: IconButton.filled(
iconSize: 24.0,
onPressed: () {
setState(() {
getball = 1;
});
},
icon: Icon(Icons.shopping_basket),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(Color.fromARGB(60, 0, 0, 0)),
iconColor: WidgetStateProperty.all(Color.fromARGB(200, 255, 255, 255))
),
)
),
Positioned(
bottom: 20,
left: 0,
right: 0,
child: Center(
child: SegmentedButton<double>(
segments: const <ButtonSegment<double>>[
ButtonSegment<double>(value: 0.05, label: Text('精确')),
ButtonSegment<double>(value: 0.10, label: Text('缓慢')),
ButtonSegment<double>(value: 0.35, label: Text('默认')),
ButtonSegment<double>(value: 0.5, label: Text('狂暴')),
],
selected: _pawScaleSelection,
onSelectionChanged: (Set<double> newSelection) {
setState(() {
if (newSelection.isNotEmpty) {
_pawScaleSelection = {newSelection.first};
maxPawRateScale = newSelection.first;
}
});
},
style: SegmentedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
selectedForegroundColor: Theme.of(context).primaryColor,
selectedBackgroundColor: Colors.white,
side: BorderSide(color: Colors.grey.shade800),
),
),
),
),
],
),
),
);
}
}