远程遥控界面编写
This commit is contained in:
@@ -1,93 +1,90 @@
|
||||
import 'package:cc_ui_kit/cc_ui_kit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../bloc/remote_control_cubit.dart';
|
||||
import 'emergency_stop_button.dart';
|
||||
import 'middle_expandslider.dart';
|
||||
|
||||
class CenterControlArea extends StatelessWidget {
|
||||
const CenterControlArea({super.key});
|
||||
final double totalWidth;
|
||||
|
||||
const CenterControlArea({super.key, required this.totalWidth});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remoteState = context.watch<RemoteControlCubit>().state;
|
||||
|
||||
// 如果处于急停激活状态,根据 Compose 逻辑,中间只显示急停状态
|
||||
if (remoteState.isEmergency) {
|
||||
return const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
EmergencyStopButton(), // 刚才写的带 3 秒解除逻辑的按钮
|
||||
SizedBox(height: 16),
|
||||
],
|
||||
children: [EmergencyStopButton(), SizedBox(height: 16)],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 左推杆:底盘升降 (对应 Compose 的第一个 MiddleExpandSlider)
|
||||
_buildVerticalSlider(
|
||||
label: "底盘",
|
||||
onTop: () =>
|
||||
context.read<RemoteControlCubit>().updateChassisLift(1),
|
||||
onMiddle: () =>
|
||||
context.read<RemoteControlCubit>().updateChassisLift(0),
|
||||
onBottom: () =>
|
||||
context.read<RemoteControlCubit>().updateChassisLift(2),
|
||||
iconTop: Icons.expand_less,
|
||||
iconMiddle: Icons.layers,
|
||||
iconBottom: Icons.expand_more,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double expandedWidth = totalWidth * 0.25;
|
||||
final double emergencyStopButtonWidth = constraints.maxWidth * 0.5;
|
||||
|
||||
return Container(
|
||||
height: 180,
|
||||
// 这里不再使用 Row 包装三个 Expanded,而是直接固定宽度
|
||||
child: Row(
|
||||
//mainAxisSize: MainAxisSize.min, // 极其重要:防止在 spaceBetween 下挤压
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 左摇杆 (3份)
|
||||
_buildSliderBox(expandedWidth, "底盘", true),
|
||||
|
||||
// 急停 (2份)
|
||||
SizedBox(
|
||||
width: emergencyStopButtonWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
EmergencyStopButton(width: emergencyStopButtonWidth),
|
||||
|
||||
SizedBox(height: totalWidth * 0.05),
|
||||
|
||||
const Text(
|
||||
'双击空白处切换视角',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 右摇杆 (3份)
|
||||
_buildSliderBox(expandedWidth, "备用", false),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const SizedBox(width: 24),
|
||||
|
||||
// 中间:急停按钮
|
||||
const EmergencyStopButton(),
|
||||
|
||||
const SizedBox(width: 24),
|
||||
|
||||
// 右推杆:备用/其他 (对应 Compose 的第二个 MiddleExpandSlider)
|
||||
_buildVerticalSlider(
|
||||
label: "云台",
|
||||
onTop: () {}, // 预留接口
|
||||
onMiddle: () {},
|
||||
onBottom: () {},
|
||||
iconTop: Icons.keyboard_arrow_up,
|
||||
iconMiddle: Icons.videocam,
|
||||
iconBottom: Icons.keyboard_arrow_down,
|
||||
Widget _buildSliderBox(double boxWidth, String label, bool isLeft) {
|
||||
return SizedBox(
|
||||
width: boxWidth,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: boxWidth * 0.6,
|
||||
height: boxWidth * 0.6 * 3,
|
||||
child: CCExpandSlider(
|
||||
label: label,
|
||||
svgStart: "assets/svgs/remote_up.svg",
|
||||
svgCenter: "assets/svgs/remote_layers.svg",
|
||||
svgEnd: "assets/svgs/remote_down.svg",
|
||||
onStart: () => print("$label start"),
|
||||
onCenter: () => print("$label center"),
|
||||
onEnd: () => print("$label end"),
|
||||
iconSize: boxWidth * 0.6 * 0.35,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 快捷构建推杆的方法
|
||||
Widget _buildVerticalSlider({
|
||||
required String label,
|
||||
required VoidCallback onTop,
|
||||
required VoidCallback onMiddle,
|
||||
required VoidCallback onBottom,
|
||||
required IconData iconTop,
|
||||
required IconData iconMiddle,
|
||||
required IconData iconBottom,
|
||||
}) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MiddleExpandSlider(
|
||||
onTop: onTop,
|
||||
onMiddle: onMiddle,
|
||||
onBottom: onBottom,
|
||||
iconTop: iconTop,
|
||||
iconMiddle: iconMiddle,
|
||||
iconBottom: iconBottom,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
||||
import '../bloc/remote_control_cubit.dart';
|
||||
|
||||
class EmergencyStopButton extends StatefulWidget {
|
||||
const EmergencyStopButton({super.key});
|
||||
final double width;
|
||||
const EmergencyStopButton({
|
||||
Key? key,
|
||||
this.width = 120,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EmergencyStopButton> createState() => _EmergencyStopButtonState();
|
||||
@@ -19,7 +24,6 @@ class _EmergencyStopButtonState extends State<EmergencyStopButton>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 对应 Compose 中的 durationMs = 3000L
|
||||
_progressController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 3),
|
||||
@@ -40,6 +44,9 @@ class _EmergencyStopButtonState extends State<EmergencyStopButton>
|
||||
.state
|
||||
.isEmergency;
|
||||
|
||||
final width = widget.width * 0.65;
|
||||
final double indicatorSize = width + 10;
|
||||
|
||||
return GestureDetector(
|
||||
// 1. 处理点击:仅在未急停时触发
|
||||
onTap: () {
|
||||
@@ -75,20 +82,19 @@ class _EmergencyStopButtonState extends State<EmergencyStopButton>
|
||||
// 对应 Compose 的 Canvas 绘制进度环
|
||||
if (_isPressing)
|
||||
SizedBox(
|
||||
width: 130,
|
||||
height: 130,
|
||||
width: indicatorSize,
|
||||
height: indicatorSize,
|
||||
child: CircularProgressIndicator(
|
||||
value: _progressController.value,
|
||||
strokeWidth: 10,
|
||||
color: Colors.red.withOpacity(0.8),
|
||||
backgroundColor: Colors.red.withOpacity(0.2),
|
||||
backgroundColor: Colors.red.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
|
||||
// 按钮本体 (对应 Android 的 120.dp Box)
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: width,
|
||||
height: width,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// 根据状态切换颜色,对应 Color(0x80C53030)
|
||||
@@ -98,7 +104,7 @@ class _EmergencyStopButtonState extends State<EmergencyStopButton>
|
||||
boxShadow: isEmergencyActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.red.withOpacity(0.5),
|
||||
color: Colors.red.withValues(alpha: 0.5),
|
||||
blurRadius: 20,
|
||||
),
|
||||
]
|
||||
@@ -107,16 +113,12 @@ class _EmergencyStopButtonState extends State<EmergencyStopButton>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.report_problem_outlined, // 对应 remote_alert 图标
|
||||
color: Colors.white,
|
||||
size: 45,
|
||||
),
|
||||
SvgPicture.asset('assets/svgs/remote_alert.svg',width: width * 0.4,color: Colors.white.withValues(alpha: 0.5),),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
isEmergencyActive ? "急停中" : "急停",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
|
||||
@@ -7,8 +7,13 @@ import '../bloc/remote_control_cubit.dart';
|
||||
|
||||
class LeftJoystickArea extends StatelessWidget {
|
||||
final bool isLocked;
|
||||
final double width;
|
||||
|
||||
const LeftJoystickArea({super.key, required this.isLocked});
|
||||
const LeftJoystickArea({
|
||||
Key? key,
|
||||
required this.isLocked,
|
||||
required this.width,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,7 +27,7 @@ class LeftJoystickArea extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: isLocked ? 0.3 : 1.0, // 锁定后变透明
|
||||
child: CCJoystick(
|
||||
radius: 100, // 对应 size(200.dp)
|
||||
radius: width, // 对应 size(200.dp)
|
||||
axisHint: AxisHint.forwardBackward,
|
||||
onValueChanged: (value) {
|
||||
// 对应 viewModel.updateOriginY(y)
|
||||
@@ -35,15 +40,16 @@ class LeftJoystickArea extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
"前后控制",
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
|
||||
@@ -9,6 +9,7 @@ class MyVideoPlayer extends StatefulWidget {
|
||||
final String deviceIp;
|
||||
final DeviceEntity device;
|
||||
final UserEntity user;
|
||||
|
||||
const MyVideoPlayer({
|
||||
Key? key,
|
||||
required this.deviceIp,
|
||||
@@ -21,11 +22,10 @@ class MyVideoPlayer extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MyVideoPlayerState extends State<MyVideoPlayer> {
|
||||
// 1. 定义本地服务器
|
||||
InAppLocalhostServer? _localServer;
|
||||
InAppWebViewController? _webViewController;
|
||||
bool _isServerRunning = false;
|
||||
var actualPort;
|
||||
int? actualPort;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,79 +33,73 @@ class _MyVideoPlayerState extends State<MyVideoPlayer> {
|
||||
_startServer();
|
||||
}
|
||||
|
||||
// 2. 启动服务器 (适用于 Windows, Android, iOS)
|
||||
Future<void> _startServer() async {
|
||||
// 1. 手动找一个系统分配的空闲端口
|
||||
int availablePort = 0;
|
||||
try {
|
||||
// 绑定到端口 0,系统会随机分配一个
|
||||
var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
availablePort = socket.port;
|
||||
await socket.close(); // 立即释放,给 LocalhostServer 用
|
||||
await socket.close();
|
||||
} catch (e) {
|
||||
availablePort = 8080; // 万一失败,给个保底
|
||||
availablePort = 8080;
|
||||
}
|
||||
|
||||
// 2. 使用找到的明确端口启动
|
||||
_localServer = InAppLocalhostServer(port: availablePort);
|
||||
await _localServer!.start();
|
||||
|
||||
// 确认端口(有些版本需要通过这种方式确认)
|
||||
actualPort = availablePort;
|
||||
|
||||
debugPrint("服务器启动在端口: $actualPort");
|
||||
|
||||
setState(() {
|
||||
_isServerRunning = true;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isServerRunning = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localServer?.close(); // 页面销毁时关闭服务器
|
||||
_localServer?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 防止父级因其他状态 setState 导致视频闪烁
|
||||
if (!_isServerRunning) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(child: CircularProgressIndicator(color: Colors.white));
|
||||
}
|
||||
|
||||
return InAppWebView(
|
||||
// 3. 通过 localhost 地址访问,而不是 file://
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri("http://localhost:$actualPort/assets/www/playwebrtc.html"),
|
||||
// [性能关键] RepaintBoundary 必须包裹渲染密集型的 WebView
|
||||
return RepaintBoundary(
|
||||
child: InAppWebView(
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri("http://localhost:$actualPort/assets/www/webrtc/playwebrtc.html"),
|
||||
),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
preferredContentMode: UserPreferredContentMode.MOBILE,
|
||||
// 优化性能:禁用滚动,开启硬件加速
|
||||
supportZoom: false,
|
||||
disableHorizontalScroll: true,
|
||||
disableVerticalScroll: true,
|
||||
hardwareAcceleration: true,
|
||||
),
|
||||
onWebViewCreated: (controller) => _webViewController = controller,
|
||||
onPermissionRequest: (controller, request) async {
|
||||
return PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: PermissionResponseAction.GRANT,
|
||||
);
|
||||
},
|
||||
onLoadStop: (controller, url) {
|
||||
_initWebRTC(widget.deviceIp, widget.device.deviceName, widget.user.token);
|
||||
},
|
||||
),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
// Windows/Android 开启硬件加速优化图传
|
||||
preferredContentMode: UserPreferredContentMode.DESKTOP,
|
||||
),
|
||||
onWebViewCreated: (controller) => _webViewController = controller,
|
||||
onPermissionRequest: (controller, request) async {
|
||||
return PermissionResponse(
|
||||
resources: request.resources,
|
||||
action: PermissionResponseAction.GRANT,
|
||||
);
|
||||
},
|
||||
onLoadStop: (controller, url) {
|
||||
_initWebRTC(
|
||||
widget.deviceIp,
|
||||
widget.device.deviceName,
|
||||
widget.user.token,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _initWebRTC(String deviceIp, String deviceId, String token) {
|
||||
final streamUrl =
|
||||
"webrtc://$deviceIp/live/livestream/$deviceId?token=$token";
|
||||
_webViewController?.evaluateJavascript(
|
||||
source: "setStreamUrl('$streamUrl')",
|
||||
);
|
||||
final streamUrl = "webrtc://$deviceIp/live/livestream/$deviceId?token=$token";
|
||||
_webViewController?.evaluateJavascript(source: "setStreamUrl('$streamUrl')");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,13 @@ import '../bloc/remote_control_cubit.dart';
|
||||
|
||||
class RightJoystickArea extends StatelessWidget {
|
||||
final bool isLocked;
|
||||
final double width;
|
||||
|
||||
const RightJoystickArea({super.key, required this.isLocked});
|
||||
const RightJoystickArea({
|
||||
Key? key,
|
||||
required this.isLocked,
|
||||
required this.width,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,7 +27,7 @@ class RightJoystickArea extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: isLocked ? 0.3 : 1.0, // 锁定后变透明/灰色
|
||||
child: CCJoystick(
|
||||
radius: 100, // 对应 size(200.dp)
|
||||
radius: width, // 对应 size(200.dp)
|
||||
axisHint: AxisHint.leftRight, // 关键:指定为左右控制
|
||||
onValueChanged: (value) {
|
||||
// 对应 viewModel.updateOriginX(x)
|
||||
@@ -35,12 +40,12 @@ class RightJoystickArea extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 18),
|
||||
// 2. 底部文字:对应 Text("左右控制", color = Color.Gray)
|
||||
const Text(
|
||||
Text(
|
||||
"左右控制",
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user