远程遥控界面编写

This commit is contained in:
CC
2026-01-19 18:24:24 +08:00
parent 289e706337
commit 32b702fb6d
48 changed files with 1118 additions and 303 deletions

View File

@@ -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)),
],
);
}
}

View File

@@ -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,
),

View File

@@ -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),
],
);
}

View File

@@ -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')");
}
}
}

View File

@@ -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,
),