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 createState() => _PlayerPageState(); } class _PlayerPageState extends State { 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 _latencyReadings = []; final int _latencyWindow = 30; Timer? _latencyTestTimer; 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 _bitrateInfo = '速率: N/A'; Timer? _infoTimer; double maxPawRateScale = 0.35; Set _pawScaleSelection = {0.35}; @override void initState() { super.initState(); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); GlobalSettingState globalSettingState = context.read(); _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 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 data = utf8.encode('RESET=1'); _udpSocket!.send(data, InternetAddress(globalSettingState.carIP), 80); setState(() { 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(); } }); 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'); 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.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: 54, 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: 110, 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.10, label: Text('缓慢')), ButtonSegment(value: 0.35, label: Text('默认')), ButtonSegment(value: 0.5, 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), ), ), ), ), ], ), ), ); } }