476 lines
15 KiB
Dart
476 lines
15 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';
|
|
|
|
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;
|
|
|
|
final int callBackPeriod = 20;
|
|
|
|
RawDatagramSocket? _udpSocket;
|
|
|
|
String _pingResult = '正在连接图传...';
|
|
Socket? _timestampSocket;
|
|
StreamSubscription? _socketSubscription;
|
|
|
|
Timer? _latencyTestTimer;
|
|
String _controlLatencyResult = '正在连接控制...';
|
|
|
|
Timer? _udpSendTimer;
|
|
|
|
@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(seconds: 1), (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 = '控制延迟测试失败';
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
_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;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
_showErrorSnackBar('控制失败: $e');
|
|
timer.cancel();
|
|
}
|
|
});
|
|
|
|
if (player.platform is NativePlayer) {
|
|
final props = {
|
|
'profile': 'low-latency',
|
|
};
|
|
|
|
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 _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;
|
|
if (mounted) {
|
|
setState(() {
|
|
_controlLatencyResult = '控制延迟: $latency ms';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
} 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;
|
|
int forward = 0, backward = 0, left = 0, right = 0, up = 0, down = 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));
|
|
|
|
_socketSubscription = _timestampSocket?.listen(
|
|
(data) {
|
|
setState(() {
|
|
finishConnection = true;
|
|
});
|
|
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 (mounted) {
|
|
setState(() {
|
|
_pingResult = '图传延迟: ${latency.abs()} ms';
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onError: (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_pingResult = '图传出错';
|
|
finishConnection = true;
|
|
});
|
|
_showErrorSnackBar('图传出错');
|
|
_timestampSocket?.destroy();
|
|
},
|
|
onDone: () {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_pingResult = '图传已断开';
|
|
finishConnection = true;
|
|
});
|
|
_showErrorSnackBar('图传已断开');
|
|
_timestampSocket?.destroy();
|
|
},
|
|
cancelOnError: true,
|
|
);
|
|
} catch (e) {
|
|
setState(() {
|
|
_pingResult = '连接图传失败';
|
|
finishConnection = true;
|
|
});
|
|
_showErrorSnackBar('连接图传失败');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
SystemChrome.setPreferredOrientations([
|
|
DeviceOrientation.portraitUp,
|
|
]);
|
|
player.dispose();
|
|
_socketSubscription?.cancel();
|
|
_timestampSocket?.destroy();
|
|
_udpSendTimer?.cancel();
|
|
_udpSocket?.close();
|
|
_latencyTestTimer?.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: 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),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 64,
|
|
bottom: 32,
|
|
child: Joystick(
|
|
period: Duration(milliseconds: callBackPeriod),
|
|
stick: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: Color.fromARGB(200, 255, 255, 255),
|
|
),
|
|
base: JoystickSquareBase(
|
|
size: 160,
|
|
decoration: JoystickBaseDecoration(
|
|
color: Color.fromARGB(60, 200, 200, 200),
|
|
)
|
|
),
|
|
stickOffsetCalculator: const RectangleStickOffsetCalculator(),
|
|
listener: (details) {
|
|
setState(() {
|
|
lx = details.x * 127 + 127.5;
|
|
ly = details.y * 127 + 127.5;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 128,
|
|
bottom: 180,
|
|
child: Joystick(
|
|
period: Duration(milliseconds: callBackPeriod),
|
|
stick: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: Color.fromARGB(200, 255, 255, 255),
|
|
),
|
|
base: Container(
|
|
width: 160,
|
|
height: 160,
|
|
decoration: BoxDecoration(
|
|
color: Color.fromARGB(60, 200, 200, 200),
|
|
shape: BoxShape.circle,
|
|
border: BoxBorder.all(
|
|
color: Theme.of(context).colorScheme.outline,
|
|
width: 1
|
|
)
|
|
),
|
|
),
|
|
listener: (details) {
|
|
if(details.x == 0 && details.y == 0) {
|
|
left = right = forward = backward = 0;
|
|
} 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;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 180,
|
|
right: 64,
|
|
child: Joystick(
|
|
period: Duration(milliseconds: callBackPeriod),
|
|
mode: JoystickMode.vertical,
|
|
stick: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: Color.fromARGB(200, 255, 255, 255),
|
|
),
|
|
base: Container(
|
|
width: 32,
|
|
height: 160,
|
|
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(() {
|
|
up = details.y < 0 ? 1: 0;
|
|
down = details.y > 0 ? 1: 0;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 100,
|
|
right: 64,
|
|
child: Joystick(
|
|
period: Duration(milliseconds: callBackPeriod),
|
|
mode: JoystickMode.horizontal,
|
|
stick: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: Color.fromARGB(200, 255, 255, 255),
|
|
),
|
|
base: Container(
|
|
width: 160,
|
|
height: 32,
|
|
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(() {
|
|
ry = details.x * 127 + 127.5;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 40,
|
|
right: 64,
|
|
child: Joystick(
|
|
period: Duration(milliseconds: callBackPeriod),
|
|
mode: JoystickMode.horizontal,
|
|
stick: CircleAvatar(
|
|
radius: 20,
|
|
backgroundColor: Color.fromARGB(200, 255, 255, 255),
|
|
),
|
|
base: Container(
|
|
width: 160,
|
|
height: 32,
|
|
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(() {
|
|
rx = details.x * 127 + 127.5;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 30,
|
|
right: 256,
|
|
child: Joystick(
|
|
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,
|
|
left: 140,
|
|
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)),
|
|
iconColor: WidgetStateProperty.all(Color.fromARGB(200, 255, 255, 255))
|
|
),
|
|
)
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |