diff --git a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart index c1df35d2..9975a957 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -35,7 +35,8 @@ class RemoteControlCubit extends Cubit { StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期 final NetMessageDispatcher dispatcher; final DeviceStatusBloc deviceStatusBloc; // 🔥 注入 DeviceStatusBloc - StreamSubscription? _deviceStatusSub; // 🔥 订阅 DeviceStatusBloc 的状态流 + StreamSubscription? _deviceStatusSub; // 🔥 订阅 DeviceStatusBloc 的状态流(MQTT,已停用) + StreamSubscription? _tcpStatusSub; // 🔥 订阅 TCP 0x02 机器状态推送(门槛 + 电压/电量/控制模式) static const platform = MethodChannel('com.maibu.satabot/ping'); int _currentPing = 50; final ILoggerService _logger = GetIt.I(); @@ -70,7 +71,11 @@ class RemoteControlCubit extends Cubit { ), ) { _initPacketListener(); - _initDeviceStatusListener(); // 🔥 改为订阅 DeviceStatusBloc + // 🔥 门槛(hasReceivedStatusPush)与电压/电量/控制模式改由 TCP 0x02 驱动, + // 与 MQTT 定位链路解耦;MQTT(DeviceStatusBloc) 仅继续负责定位。 + // 回滚:注释下行、改回 _initDeviceStatusListener() 即可。 + _initTcpStatusListener(); + // _initDeviceStatusListener(); // 🔥 旧:订阅 DeviceStatusBloc(MQTT),已停用 } // 🔥 新增:订阅 DeviceStatusBloc 的状态流,而不是直接监听TCP @@ -82,6 +87,9 @@ class RemoteControlCubit extends Cubit { int? _cachePing; DateTime? _lastStatusPushTime; // 最后一次收到设备状态推送的时间 + // 🔥 已停用:门槛与电压/电量/控制模式改由 TCP 0x02 驱动(见 _initTcpStatusListener)。 + // 保留本方法用于回滚——恢复构造函数中的调用即可重新走 MQTT(DeviceStatusUpdated)。 + // ignore: unused_element void _initDeviceStatusListener() { _deviceStatusSub?.cancel(); @@ -129,6 +137,77 @@ class RemoteControlCubit extends Cubit { }); } + /// 🔥 门槛 + 运行状态专用监听:直接监听 TCP 0x02 机器状态推送。 + /// + /// 与 MQTT 定位链路彻底解耦: + /// - 门槛 hasReceivedStatusPush / _lastStatusPushTime 由 TCP 0x02 驱动; + /// - 电压/电量/控制模式 直接由 0x02 报文的 fromFields 解析(fields[0]/[21]/[20]); + /// - MQTT(DeviceStatusBloc) 仅继续负责定位,不再影响本页门槛与三个值。 + void _initTcpStatusListener() { + _tcpStatusSub?.cancel(); + + _tcpStatusSub = tcpClient.packetStream + .where((p) => p.command == 0x02) + .listen((packet) async { + try { + final message = utf8.decode(packet.payload, allowMalformed: true); + if (message.trim().isEmpty) return; + + final fields = message.trim().split(','); + if (fields.length < 18) { + debugPrint('⚠️ [TCP-0x02门槛] 字段不足:${fields.length},期望≥18,跳过'); + return; + } + + final status = RunningStatusEntity.fromFields(fields); + final controlMode = status.controlMode == '3' ? '远程模式' : '本地模式'; + + // 🔥 门槛:收到 TCP 0x02 即视为推送活跃(节流前刷新,轻量无阻塞) + _lastStatusPushTime = DateTime.now(); + + _cacheVoltage = status.voltage.toString(); + _cacheBattery = status.battery; + _cacheCtrlMode = controlMode; + + // 500ms节流,不到时间不刷新UI + final now = DateTime.now(); + if (_lastUiUpdateTime != null && + now.difference(_lastUiUpdateTime!) < + const Duration(milliseconds: 500)) { + // 节流期间仍需保证门槛标志已置位(否则首帧恰逢节流会导致门槛漏置) + if (!state.hasReceivedStatusPush && !isClosed) { + emit(state.copyWith(hasReceivedStatusPush: true)); + } + return; + } + _lastUiUpdateTime = now; + + // 🔥 ping 只在真正刷新UI时测(≤2Hz):0x02 原始推送可能高频, + // 若每帧 await getNetworkDelay 会造成大量并发 ping,移到节流后规避。 + final c = await getNetworkDelay(); + _cachePing = c; + + if (!isClosed) { + emit( + state.copyWith( + runningStatusModel: state.runningStatusModel.copyWith( + voltage: _cacheVoltage, + battery: _cacheBattery, + controlMode: _cacheCtrlMode, + ), + battery: int.tryParse(_cacheBattery ?? '') ?? 0, + ping: _cachePing, + hasReceivedStatusPush: true, // 🔥 门槛:TCP 0x02 到达即置位 + updateType: 'device_status', + ), + ); + } + } catch (e) { + debugPrint('❌ [TCP-0x02门槛] 解析异常:$e'); + } + }); + } + // 🔥 超简单方法:传入 IP,得到 ping 值 // 🔥 模拟设备状态更新 - 用于测试 @@ -701,6 +780,7 @@ class RemoteControlCubit extends Cubit { Future close() { _timer?.cancel(); _deviceStatusSub?.cancel(); // 🔥 取消订阅 DeviceStatusBloc + _tcpStatusSub?.cancel(); // 🔥 取消订阅 TCP 0x02 门槛监听 return super.close(); } diff --git a/lib/features/remote_control/presentation/pages/remote_control_page.dart b/lib/features/remote_control/presentation/pages/remote_control_page.dart index 20df0847..457a9601 100644 --- a/lib/features/remote_control/presentation/pages/remote_control_page.dart +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -198,16 +198,20 @@ class _RemoteControlPageState extends State { .targetDevice; final deviceId = targetDevice?.deviceName; - // 🔥 从 RemoteControlCubit 获取 token(如果有的话) - // 注意:如果 token 不在 RemoteControlCubit 中,需要从其他地方获取 - // 这里假设 token 是有效的,直接构建 URL - if (deviceId != null && deviceId.isNotEmpty) { + // 🔥 从 AppUserCubit 获取登录 token:SRS on_play 会校验,缺失会被拒绝拉流 + final token = context.read().state.user?.token; + if (deviceId != null && + deviceId.isNotEmpty && + token != null && + token.isNotEmpty) { _videoStreamUrl = - "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId"; + "webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=$token"; debugPrint('🎬 [WebRTC] URL构建成功: $_videoStreamUrl'); } else { _videoStreamUrl = ''; - debugPrint('❌ [WebRTC] URL构建失败 - deviceId: $deviceId'); + debugPrint( + '❌ [WebRTC] URL构建失败 - deviceId: $deviceId, hasToken: ${token != null}', + ); } final int originY = context .watch() diff --git a/lib/features/remote_control/presentation/widgets/center_control_area.dart b/lib/features/remote_control/presentation/widgets/center_control_area.dart index 86fff675..57d4c226 100644 --- a/lib/features/remote_control/presentation/widgets/center_control_area.dart +++ b/lib/features/remote_control/presentation/widgets/center_control_area.dart @@ -2,6 +2,7 @@ import 'package:cc_ui_kit/cc_ui_kit.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/components/capsule_toast.dart'; import 'package:maibu_satabot_v2/core/localization/app_localizations.dart'; import '../bloc/remote_control_cubit.dart'; @@ -115,6 +116,13 @@ class CenterControlArea extends StatelessWidget { void _handleSliderAction(String deviceKey, String action, BuildContext context) { debugPrint('🎯 [Slider 业务] deviceKey: $deviceKey, action: $action'); + // 🔥 门槛检查:与摇杆一致,未收到 TCP 0x02 推送时禁止操作并提示。 + // 胶囊 Toast 全局去重、自动消失,适配 StatelessWidget 无实例状态。 + if (!context.read().isStatusPushActive()) { + CapsuleToast.show('暂未收到该机器的推送,不能控制', showCheck: false); + return; + } + // 🔥 使用deviceKey进行判断,不依赖显示文本 switch (deviceKey) { case 'chassis': diff --git a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart index 52c43a77..9e68c911 100644 --- a/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart +++ b/lib/features/remote_control/presentation/widgets/webrtc/webrtc_local_player.dart @@ -71,6 +71,11 @@ class _WebRTCLocalPlayerState extends State { void _updateLoadState(WebRTCLoadState state) { if (_loadState == state) return; _loadState = state; + // 🔥 自身状态变化必须触发重建:未传 onLoadingStateChanged 的调用方 + // (如远程遥控页)否则无法从"视频加载中"切到 playing/error 界面 + if (mounted) { + setState(() {}); + } widget.onLoadingStateChanged?.call(state); } @@ -404,19 +409,34 @@ class _WebRTCLocalPlayerState extends State { // 🔥 在真正渲染出视频帧(playing 态)之前,始终显示加载指示器 // 避免出现"track 已收到但画面是黑的"黑屏情况 if (_loadState != WebRTCLoadState.playing) { + // 🔥 区分 loading 与 error:error 态显示明确失败提示,不再一直转圈 + final bool isError = _loadState == WebRTCLoadState.error; return Container( color: Colors.black, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(color: Colors.white), - const SizedBox(height: 12), - Text( - '视频加载中...', - style: TextStyle(color: Colors.white70, fontSize: 14), - ), - ], + children: isError + ? [ + const Icon( + Icons.videocam_off, + size: 48, + color: Colors.white38, + ), + const SizedBox(height: 12), + const Text( + '视频加载失败,请检查设备是否在线或重试', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + ] + : [ + const CircularProgressIndicator(color: Colors.white), + const SizedBox(height: 12), + const Text( + '视频加载中...', + style: TextStyle(color: Colors.white70, fontSize: 14), + ), + ], ), ), );