From 1736ce9159e8d77695e118cd7812ed7c393672f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=A6=E5=87=8C=E6=B1=90?= Date: Wed, 2 Jul 2025 15:22:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/home_page.dart | 4 +- lib/pages/player_page.dart | 436 +++++++++++++++++++++++++++++++++---- pubspec.lock | 18 +- pubspec.yaml | 1 + 4 files changed, 415 insertions(+), 44 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index fdad9c1..d6d370a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -34,8 +34,8 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - carIpController.text = '192.168.137.210'; - cameraIpController.text = '192.168.137.87'; + carIpController.text = '192.168.137.122'; + cameraIpController.text = '192.168.137.138'; GlobalSettingState globalSettingState = context.watch(); return Scaffold( body: globalSettingState.cameraIP != '' && globalSettingState.carIP != '' ? diff --git a/lib/pages/player_page.dart b/lib/pages/player_page.dart index 5fc3d86..ca92d64 100644 --- a/lib/pages/player_page.dart +++ b/lib/pages/player_page.dart @@ -9,6 +9,7 @@ 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'; class PlayerPage extends StatefulWidget { const PlayerPage({super.key}); @@ -23,6 +24,8 @@ class _PlayerPageState extends State { bool finishConnection = false; + Timer? _reconnectionTimer; + final int callBackPeriod = 20; RawDatagramSocket? _udpSocket; @@ -31,11 +34,31 @@ class _PlayerPageState extends State { Socket? _timestampSocket; StreamSubscription? _socketSubscription; + final List _latencyReadings = []; + final int _latencyWindow = 30; + Timer? _latencyTestTimer; - String _controlLatencyResult = '正在连接控制...'; + String _controlLatencyResult = '正在连接控制器...'; + + final List _controlLatencyReadings = []; + final int _controlLatencyWindow = 10; Timer? _udpSendTimer; + final List _videoLatencyHistory = []; + final List _controlLatencyHistory = []; + final int _maxHistoryCount = 100; + + Timer? _connectionHealthTimer; + DateTime? _lastMessageReceivedTime; + + String _fpsInfo = 'FPS: N/A'; + String _bitrateInfo = '速率: N/A'; + Timer? _infoTimer; + + double maxPawRateScale = 1.0; + Set _pawScaleSelection = {1.0}; + @override void initState() { super.initState(); @@ -43,17 +66,17 @@ class _PlayerPageState extends State { DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight ]); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); GlobalSettingState globalSettingState = context.read(); - + _initializeUdpSocket(); - _latencyTestTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + _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) { @@ -65,11 +88,15 @@ class _PlayerPageState extends State { } }); + _infoTimer = Timer.periodic(const Duration(seconds: 1), (_) { + _updateStreamInfo(); + }); + _udpSendTimer = Timer.periodic(Duration(milliseconds: callBackPeriod), (timer) { if (_udpSocket == null || globalSettingState.carIP.isEmpty) return; List 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); @@ -80,6 +107,13 @@ class _PlayerPageState extends State { reset = 0; }); } + if (getball == 1) { + List data = utf8.encode('GETBALL=1'); + _udpSocket!.send(data, InternetAddress(globalSettingState.carIP), 80); + setState(() { + getball = 0; + }); + } } catch (e) { _showErrorSnackBar('控制失败: $e'); timer.cancel(); @@ -89,17 +123,96 @@ class _PlayerPageState extends State { 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(); + 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'); + final fps = await nativePlayer.getProperty('video-fps'); + + if (mounted) { + setState(() { + final bitrateInKbps = (double.tryParse(bitrate) ?? 0) / 1024; + _bitrateInfo = '速率: ${bitrateInKbps.toStringAsFixed(0)} Kbps'; + final double? fpsValue = double.tryParse(fps); + if (fpsValue != null) { + _fpsInfo = 'FPS: ${fpsValue.toStringAsFixed(1)}'; + } + }); + } + } catch (e) { + // Handle potential errors if properties are not available + } + } + void _initializeUdpSocket() async { try { _udpSocket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); @@ -114,9 +227,22 @@ class _PlayerPageState extends State { 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 = '控制延迟: $latency ms'; + _controlLatencyResult = '控制RTT: ${avgLatency.toStringAsFixed(0)} ms'; + + _controlLatencyHistory.add(FlSpot(DateTime.now().millisecondsSinceEpoch.toDouble(), avgLatency)); + if (_controlLatencyHistory.length > _maxHistoryCount) { + _controlLatencyHistory.removeAt(0); + } }); } } @@ -137,8 +263,8 @@ class _PlayerPageState extends State { ); } - double lx = 0.0, ly = 0.0, rx = 0.0, ry = 0.0, paw = 0.0; - int forward = 0, backward = 0, left = 0, right = 0, up = 0, down = 0, reset = 0; + 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 { @@ -158,11 +284,19 @@ class _PlayerPageState extends State { _timestampSocket = await Socket.connect(ip, 8555, timeout: const Duration(seconds: 5)); + _reconnectionTimer?.cancel(); + + _lastMessageReceivedTime = DateTime.now(); + _startHealthCheckTimer(); + + setState(() { + finishConnection = true; + }); + _socketSubscription = _timestampSocket?.listen( (data) { - setState(() { - finishConnection = true; - }); + _lastMessageReceivedTime = DateTime.now(); + final message = utf8.decode(data).trim(); final serverTimestampStr = message.split('\n').last; @@ -171,10 +305,26 @@ class _PlayerPageState extends State { if (serverTimestamp != null) { final clientTimestamp = DateTime.now().millisecondsSinceEpoch; final latency = clientTimestamp - serverTimestamp; - if (mounted) { - setState(() { - _pingResult = '图传延迟: ${latency.abs()} ms'; - }); + + 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); + } + }); + } } } } @@ -183,28 +333,27 @@ class _PlayerPageState extends State { if (!mounted) return; setState(() { _pingResult = '图传出错'; - finishConnection = true; + _handleDisconnection(); }); _showErrorSnackBar('图传出错'); - _timestampSocket?.destroy(); + _handleDisconnection(); }, onDone: () { if (!mounted) return; setState(() { _pingResult = '图传已断开'; - finishConnection = true; }); _showErrorSnackBar('图传已断开'); - _timestampSocket?.destroy(); + _handleDisconnection(); }, cancelOnError: true, ); } catch (e) { setState(() { _pingResult = '连接图传失败'; - finishConnection = true; }); _showErrorSnackBar('连接图传失败'); + _handleDisconnection(); } } @@ -219,6 +368,11 @@ class _PlayerPageState extends State { _udpSendTimer?.cancel(); _udpSocket?.close(); _latencyTestTimer?.cancel(); + _latencyReadings.clear(); + _controlLatencyReadings.clear(); + _infoTimer?.cancel(); + _connectionHealthTimer?.cancel(); + _reconnectionTimer?.cancel(); super.dispose(); } @@ -254,15 +408,161 @@ class _PlayerPageState extends State { ), Positioned( top: 8, - right: 64, - child: Container( - padding: EdgeInsets.all(8.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8.0), - ), - child: Text( - '$_pingResult\n$_controlLatencyResult', - style: TextStyle(color: Colors.white, fontSize: 16), + right: 0, + left: 0, + child: Center( + child: Container( + width: 300, + 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 $_fpsInfo\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: 120, + 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: 120, + 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)), + ), + ), + ), + ), + ], + ), + ), + ], + ), ), ), ), @@ -270,6 +570,7 @@ class _PlayerPageState extends State { left: 64, bottom: 32, child: Joystick( + includeInitialAnimation: false, period: Duration(milliseconds: callBackPeriod), stick: CircleAvatar( radius: 20, @@ -294,6 +595,7 @@ class _PlayerPageState extends State { right: 128, bottom: 180, child: Joystick( + includeInitialAnimation: false, period: Duration(milliseconds: callBackPeriod), stick: CircleAvatar( radius: 20, @@ -317,10 +619,10 @@ class _PlayerPageState extends State { } else { var angle = -atan2(details.y, details.x), pi_18 = pi / 18.0; setState(() { - left = angle < - pi_18 * 12 || angle > pi_18 * 12 ? 1 : 0; - right = angle > - pi_18 * 6 && angle < pi_18 * 6 ? 1 : 0; - forward = angle > pi_18 * 3 && angle < pi_18 * 15 ? 1 : 0; - backward = angle > - pi_18 * 15 && angle < - pi_18 * 3 ? 1 : 0; + left = angle < - pi_18 * 12 || angle > pi_18 * 12 ? details.x * maxPawRateScale : 0; + right = angle > - pi_18 * 6 && angle < pi_18 * 6 ? details.x * maxPawRateScale : 0; + forward = angle > pi_18 * 3 && angle < pi_18 * 15 ? -details.y * maxPawRateScale : 0; + backward = angle > - pi_18 * 15 && angle < - pi_18 * 3 ? -details.y * maxPawRateScale : 0; }); } }, @@ -330,6 +632,7 @@ class _PlayerPageState extends State { bottom: 180, right: 64, child: Joystick( + includeInitialAnimation: false, period: Duration(milliseconds: callBackPeriod), mode: JoystickMode.vertical, stick: CircleAvatar( @@ -352,8 +655,8 @@ class _PlayerPageState extends State { ), listener: (details) { setState(() { - up = details.y < 0 ? 1: 0; - down = details.y > 0 ? 1: 0; + up = details.y < 0 ? -details.y * maxPawRateScale * 0.6 : 0; + down = details.y > 0 ? -details.y * maxPawRateScale * 0.6 : 0; }); }, ), @@ -362,6 +665,7 @@ class _PlayerPageState extends State { bottom: 100, right: 64, child: Joystick( + includeInitialAnimation: false, period: Duration(milliseconds: callBackPeriod), mode: JoystickMode.horizontal, stick: CircleAvatar( @@ -393,6 +697,7 @@ class _PlayerPageState extends State { bottom: 40, right: 64, child: Joystick( + includeInitialAnimation: false, period: Duration(milliseconds: callBackPeriod), mode: JoystickMode.horizontal, stick: CircleAvatar( @@ -415,7 +720,7 @@ class _PlayerPageState extends State { ), listener: (details) { setState(() { - rx = details.x * 127 + 127.5; + rx = details.x * 127 / 2 + 127.5; }); }, ), @@ -424,6 +729,7 @@ class _PlayerPageState extends State { bottom: 30, right: 256, child: Joystick( + includeInitialAnimation: false, period: Duration(milliseconds: callBackPeriod), mode: JoystickMode.vertical, stick: CircleAvatar( @@ -453,21 +759,69 @@ class _PlayerPageState extends State { ), if(finishConnection) Positioned( top: 8, - left: 140, + right: 64, child: IconButton.filled( iconSize: 24.0, onPressed: () { setState(() { reset = 1; }); - }, + }, icon: Icon(Icons.refresh), style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(Color.fromARGB(60, 255, 255, 255)), + 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( + segments: const >[ + ButtonSegment(value: 0.05, label: Text('精确')), + ButtonSegment(value: 0.2, label: Text('缓慢')), + ButtonSegment(value: 0.5, label: Text('默认')), + ButtonSegment(value: 1.0, label: Text('狂暴')), + ], + selected: _pawScaleSelection, + onSelectionChanged: (Set 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), + ), + ), + ), + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index fe1ead9..fc37608 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -113,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter: dependency: "direct main" description: flutter @@ -551,4 +567,4 @@ packages: version: "6.5.0" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.22.0" + flutter: ">=3.27.4" diff --git a/pubspec.yaml b/pubspec.yaml index 6b50119..e2ade93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: dart_ping: ^9.0.1 udp: 5.0.3 http: ^1.4.0 + fl_chart: ^1.0.0 dev_dependencies: flutter_test: