Files
flutterApp/lib/features/remote_control/presentation/pages/remote_control_page.dart
2026-11-06 08:44:05 +08:00

590 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart';
import 'package:maibu_satabot_v2/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/components/capsule_toast.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/router/route_paths.dart';
import '../../../auth/presentation/bloc/auth_cubit.dart'; // 🔥 导入 AuthCubit
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
import '../bloc/remote_control_state.dart';
import '../widgets/center_control_area.dart';
import '../widgets/emergency_overlay.dart';
import '../widgets/left_joystick_area.dart';
import '../widgets/right_joystick_area.dart';
import '../widgets/driving_guide_overlay.dart';
import '../widgets/top_status_bar.dart';
import '../widgets/webrtc/webrtc_local_player.dart';
class RemoteControlPage extends StatefulWidget {
const RemoteControlPage({super.key});
@override
State<RemoteControlPage> createState() => _RemoteControlPageState();
}
class _RemoteControlPageState extends State<RemoteControlPage> {
String _videoStreamUrl = "";
late RemoteControlCubit _cubit;
DevicesCubit? _devicesCubit;
StreamSubscription? _permissionSubscription; // 🔥 权限监听订阅
bool _isShowingPermissionDialog = false; // 🔥 防止弹窗重复显示
bool? _lastPermissionDialogState; // 🔥 记录上次弹窗状态,检测状态变化
bool _controlSessionStarted = false;
late final String _modeDeviceId = _cubit.state.targetDevice?.deviceName ?? '';
bool _isLoadingPermission = true; // 🔥 标记是否正在加载权限状态
/// 缓存本地化实例:dispose 阶段 context 已失效 / 弹窗中需提前拼文案,
/// 不能依赖 `AppLocalizations.of(context)`。
late AppLocalizations _l10n;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_l10n = AppLocalizations.of(context);
_cubit = context.read<RemoteControlCubit>();
_devicesCubit = context.read<DevicesCubit>();
// 🔥 监听权限弹窗状态变化 (只订阅一次)
if (_permissionSubscription == null) {
_permissionSubscription = _cubit.stream.listen((state) {
// 🔥 关键修复:只有当状态更新类型是弹窗更新时才处理弹窗逻辑
// 设备状态更新(500ms)不会触发弹窗显示
if (state.updateType != 'permission_dialog') {
// 如果是设备状态更新,只重置状态记录,不显示弹窗
if (!state.showPermissionRequestDialog) {
_lastPermissionDialogState = false;
}
return; // 跳过设备状态更新
}
// 🔥 只有当状态从 false 变为 true 时才显示弹窗
// 使用三个条件确保不会重复显示
final wasFalseBefore =
_lastPermissionDialogState == null ||
_lastPermissionDialogState == false;
final shouldShow =
state.showPermissionRequestDialog &&
!_isShowingPermissionDialog &&
wasFalseBefore;
if (mounted && shouldShow) {
_lastPermissionDialogState = true; // 记录当前状态
_isShowingPermissionDialog = true;
debugPrint('🔔 [权限弹窗] 检测到 showPermissionRequestDialog=true,准备显示弹窗');
// 使用微任务确保标志位已设置
Future.microtask(() {
_showPermissionDialog(state).then((_) {
debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位');
_isShowingPermissionDialog = false; // 弹窗关闭后重置标志
_lastPermissionDialogState = false; // 重置状态记录
});
});
} else if (!state.showPermissionRequestDialog) {
// 状态变为 false 时,重置记录
_lastPermissionDialogState = false;
}
});
} else {
// 🔥 如果已经订阅,重置状态记录,确保下次能正确检测状态变化
_lastPermissionDialogState = _cubit.state.showPermissionRequestDialog;
}
}
@override
void initState() {
super.initState();
// 🔥 进入安全模式:防止异地登录推送导致控制中断
GetIt.I<AuthCubit>().enterSafeMode();
// 1. 锁定横屏
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
// 2. 隐藏状态栏和虚拟按键
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
@override
void dispose() {
// 🔥 离开安全模式:启动 90 秒倒计时
GetIt.I<AuthCubit>().exitSafeMode();
// 释放远程控制权限(type=2:退出远程遥控页面释放)
final deviceId = _cubit.state.targetDevice?.deviceName;
if (_controlSessionStarted && deviceId != null && deviceId.isNotEmpty) {
_cubit.releasePermission(deviceId: deviceId, type: 2).then((success) {
CapsuleToast.show(
success
? _l10n.translate('remote_control.toast_released')
: _l10n.translate('remote_control.toast_release_failed'),
showCheck: success,
);
});
}
_permissionSubscription?.cancel(); // 🔥 取消订阅
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// 🔥 退页先把车真正停下:归零双轴 + 连发 30 帧停止指令。
// 只靠 stopControlLoop() 断掉 200ms 循环是不够的:设备只认“最后收到的
// 运动帧”,若手指还压在摇杆上就退出(没走松手归零),已下发的高速帧会
// 让车继续跑到它自己的失联保护为止。与新版 HUD 折叠/退出的 deactivate()
// 同一口径。
// 必须放在 stopControlLoop() 之前:stopAllMovement 末尾会按 _controlWanted
// 决定是否重启循环,这里同步把意图关掉,就不会在退页后留下无主定时器。
if (_controlSessionStarted) {
unawaited(_cubit.stopAllMovement());
_cubit.stopControlLoop();
}
_cubit.stopReverseCameraMock(); // 🔥 停止倒车影像 Mock,避免定时器空转
// 🔥 重置权限状态,避免单例 hasPermission 残留导致下次进入不再查询接口
_cubit.resetPermissionState();
super.dispose();
}
@override
Widget build(BuildContext context) => ControlModeEntryGate(
deviceId: _modeDeviceId,
target: ControlModeTarget.remote,
onReady: () {
if (_cubit.state.targetDevice?.deviceName != _modeDeviceId) return;
_controlSessionStarted = true;
_cubit.startControlLoop();
},
child: _buildPage(context),
);
Widget _buildPage(BuildContext context) {
// 🔥 只监听 RemoteControlCubit,避免其他状态变化导致频繁 rebuild
final remoteCubit = context.read<RemoteControlCubit>();
final targetDevice = remoteCubit.state.targetDevice;
// 🔥 调试日志:检查设备状态
debugPrint(
'🔍 [RemoteControlPage] build - targetDevice: ${targetDevice?.deviceName}',
);
if (targetDevice == null) {
debugPrint('❌ [RemoteControlPage] targetDevice 为空,显示离线页面');
return _buildOfflineScaffold();
}
// 🔥 进入页面只查询一次真实权限状态(不抢占),按接口 remoteControl 如实展示
if (_isLoadingPermission) {
// 直接置标志位,不在 build 期间调用 setState
_isLoadingPermission = false;
final deviceName = targetDevice.deviceName;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && deviceName.isNotEmpty) {
debugPrint(
'🔑 [RemoteControlPage] 进入页面,仅查询权限状态 - deviceName: $deviceName',
);
remoteCubit.requestControlPermissionS(
deviceName,
'app',
source: '进入页面查询',
grabControl: false,
);
}
});
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// 在 Stack 的最底层替换:
Positioned.fill(
child: BlocBuilder<RemoteControlCubit, RemoteControlState>(
// 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里
// 🔥 倒车引导线放在单独嵌套 BlocBuilder 里实时刷新,不带着整块视频重建
buildWhen: (p, c) =>
p.showLeftPip != c.showLeftPip ||
p.showRightPip != c.showRightPip,
builder: (context, state) {
// 🔥 从 targetDevice 获取 deviceId
final targetDevice = context
.watch<RemoteControlCubit>()
.state
.targetDevice;
final deviceId = targetDevice?.deviceName;
// 🔥 从 AppUserCubit 获取登录 token:SRS on_play 会校验,缺失会被拒绝拉流
final token = context.read<AppUserCubit>().state.user?.token;
if (deviceId != null &&
deviceId.isNotEmpty &&
token != null &&
token.isNotEmpty) {
_videoStreamUrl =
"webrtc://${TCPConsts.TCP_IP}/live/livestream/$deviceId?token=$token";
debugPrint('🎬 [WebRTC] URL构建成功: $_videoStreamUrl');
} else {
_videoStreamUrl = '';
debugPrint(
'❌ [WebRTC] URL构建失败 - deviceId: $deviceId, hasToken: ${token != null}',
);
}
final int originY = context
.watch<RemoteControlCubit>()
.state
.controlEntity
.originY;
// debugPrint("${originY},originY");
return Stack(
children: [
WebRTCLocalPlayer(
streamUrl: _videoStreamUrl,
showLeftPip: state.showLeftPip,
showRightPip: state.showRightPip,
isFrontMain: originY >= 0,
onDoubleTap: () {
debugPrint('👆 [RemoteControlPage] 双击屏幕,切换前后视角');
// 🔥 通过 TCP 发送切换视角指令
// 这里需要调用 RemoteControlCubit 的方法来切换视角
context.read<RemoteControlCubit>().toggleCameraView();
},
),
// 🔥 倒车影像引导线动画层:由摇杆(滑块)数据实时驱动。
// 嵌套 BlocBuilder 监听完整 reverseCamera,转向/挡位一变就重绘,确保跟手。
// IgnorePointer 不阻挡双击切视角等手势
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) => p.reverseCamera != c.reverseCamera,
builder: (context, rs) => rs.reverseCamera.isReversing
? Positioned.fill(
child: IgnorePointer(
child: DrivingGuideOverlay(
steering: rs.reverseCamera.steeringAngle,
),
),
)
: const SizedBox.shrink(),
),
],
);
},
),
),
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) => p.isEmergency != c.isEmergency,
builder: (context, state) {
return state.isEmergency
? const Positioned.fill(child: EmergencyOverlay())
: const SizedBox.shrink();
},
),
SafeArea(
child: Column(
children: [
// 🔥 用 BlocConsumer 包裹,确保 hasPermission 变化时重建
BlocConsumer<RemoteControlCubit, RemoteControlState>(
listenWhen: (p, c) => p.hasPermission != c.hasPermission,
listener: (context, state) {
debugPrint(
'🔍 [RemoteControlPage] BlocConsumer listener - hasPermission=${state.hasPermission}',
);
},
buildWhen: (p, c) =>
p.hasPermission != c.hasPermission ||
p.isLocked != c.isLocked ||
p.topRightIsExpanded != c.topRightIsExpanded ||
p.obstacleRecognitionFlag != c.obstacleRecognitionFlag ||
p.showLeftPip != c.showLeftPip ||
p.showRightPip != c.showRightPip ||
p.ping != c.ping ||
p.runningStatusModel.voltage !=
c.runningStatusModel.voltage ||
p.runningStatusModel.controlMode !=
c.runningStatusModel.controlMode ||
p.battery != c.battery,
builder: (context, state) {
// 🔥 调试日志:确认 BlocBuilder 接收到的状态值
/* debugPrint(
'🔍 [RemoteControlPage] BlocConsumer builder - hasPermission=${state.hasPermission}',
);*/
return TopStatusBar(remoteState: state);
},
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final double sideAreaWidth = constraints.maxWidth * 0.25;
final double centerAreaWidth = constraints.maxWidth * 0.5;
// 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page
return BlocBuilder<
RemoteControlCubit,
RemoteControlState
>(
// 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理
buildWhen: (p, c) => p.isLocked != c.isLocked,
builder: (context, remoteState) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 左摇杆
SizedBox(
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: LeftJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.45,
),
),
),
// 中间区
SizedBox(
width: centerAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: CenterControlArea(
totalWidth: centerAreaWidth,
),
),
),
// 右摇杆
SizedBox(
width: sideAreaWidth,
child: Align(
alignment: Alignment.bottomCenter,
child: RightJoystickArea(
isLocked: remoteState.isLocked,
width: sideAreaWidth * 0.45,
),
),
),
],
),
);
},
);
},
),
),
],
),
),
],
),
);
}
// 🔥 显示权限请求弹窗 (使用 showDialog 替代 Overlay)
Future<void> _showPermissionDialog(RemoteControlState state) async {
debugPrint('🔔 [权限弹窗] _showPermissionDialog 被调用');
final requestingDeviceId =
state.requestingDeviceId ??
_l10n.translate('remote_control.perm_unknown_device');
final requestingPlatform =
state.requestingPlatform ??
_l10n.translate('remote_control.perm_unknown_platform');
final dialogContent = _l10n
.translate('remote_control.permission_request_body')
.replaceAll('{platform}', requestingPlatform)
.replaceAll('{deviceId}', requestingDeviceId);
debugPrint(
'🔔 [权限弹窗] 准备显示弹窗 - requestingPlatform=$requestingPlatform, requestingDeviceId=$requestingDeviceId',
);
showDialog(
context: context,
barrierDismissible: false, // 禁止点击外部关闭
builder: (dialogContext) => AlertDialog(
title: Text(
AppLocalizations.of(
dialogContext,
).translate('remote_control.permission_request_title'),
),
content: Text(dialogContent),
actions: [
TextButton(
onPressed: () async {
// debugPrint('👆 [权限弹窗] 用户点击了拒绝按钮');
final deviceId =
context
.read<DevicesCubit>()
.state
.selectedDevice
?.deviceName ??
"";
final remoteCubit = context.read<RemoteControlCubit>();
// 🔥 添加详细状态日志 - 检查为什么TCP指令发不出去
// debugPrint('👆 [权限弹窗] ====================状态检查====================');
// debugPrint('👆 [权限弹窗] showPermissionRequestDialog: ${remoteCubit.state.showPermissionRequestDialog}');
// debugPrint('👆 [权限弹窗] targetDevice: ${remoteCubit.state.targetDevice}');
// debugPrint('👆 [权限弹窗] requestingDeviceId: ${remoteCubit.state.requestingDeviceId}');
// debugPrint('👆 [权限弹窗] hasPermission: ${remoteCubit.state.hasPermission}');
// debugPrint('👆 [权限弹窗] ==============================================');
/* debugPrint(
'📋 [权限弹窗] targetDevice=${remoteCubit.state.targetDevice}',
);*/
// 🔥 先发送 TCP 响应,等完成后再关闭弹窗
// debugPrint('🔑 [权限弹窗] 用户拒绝,调用 confirmPermissionResponse');
final targetDevice = remoteCubit.state.targetDevice;
if (targetDevice != null) {
debugPrint(
'✅ [权限弹窗] targetDevice 不为空,开始调用 confirmPermissionResponse',
);
await remoteCubit.confirmPermissionResponse(
targetDevice.deviceName,
'app',
false,
);
// debugPrint('✅ [权限弹窗] TCP响应已发送,准备关闭弹窗');
} else {
debugPrint(
'❌ [权限弹窗] targetDevice 为空,无法调用 confirmPermissionResponse',
);
}
// 🔥 关键修复:先重置弹窗标志位,再关闭弹窗
// 避免 Navigator.pop() 后上下文失效导致后续代码不执行
// 🔥 一律用 await 之前就捕获的 remoteCubit,不再 context.read:
// 异步回来后页面可能已销毁,在失效 Element 上 context.read 会抛。
remoteCubit.resetPermissionCoolDown();
remoteCubit.togglePermissionDialog(false);
// 🔥 关闭弹窗(在状态重置之后):弹窗可能已被上一点击关闭,
// 不守卫就会在 defunct context 上 pop 抛异常。
if (dialogContext.mounted && Navigator.canPop(dialogContext)) {
Navigator.pop(dialogContext);
}
debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位');
},
child: Text(
AppLocalizations.of(
dialogContext,
).translate('remote_control.refuse'),
),
),
TextButton(
onPressed: () async {
debugPrint('👆 [权限弹窗] 用户点击了同意按钮');
final deviceId =
context
.read<DevicesCubit>()
.state
.selectedDevice
?.deviceName ??
"";
final remoteCubit = context.read<RemoteControlCubit>();
// 🔥 添加详细状态日志 - 检查为什么TCP指令发不出去
/* debugPrint(
'👆 [权限弹窗] ====================状态检查====================',
);
debugPrint(
'👆 [权限弹窗] showPermissionRequestDialog: ${remoteCubit.state.showPermissionRequestDialog}',
);
debugPrint(
'👆 [权限弹窗] targetDevice: ${remoteCubit.state.targetDevice}',
);
debugPrint(
'👆 [权限弹窗] requestingDeviceId: ${remoteCubit.state.requestingDeviceId}',
);
debugPrint(
'👆 [权限弹窗] hasPermission: ${remoteCubit.state.hasPermission}',
);
debugPrint(
'👆 [权限弹窗] ==============================================',
);
debugPrint(
'📋 [权限弹窗] targetDevice=${remoteCubit.state.targetDevice}',
);*/
// 🔥 先发送 TCP 响应,等完成后再关闭弹窗
debugPrint('🔑 [权限弹窗] 用户同意,调用 confirmPermissionResponse');
final targetDevice = remoteCubit.state.targetDevice;
if (targetDevice != null) {
debugPrint('✅ [权限弹窗] targetDevice 不为空');
await remoteCubit.confirmPermissionResponse(
targetDevice.deviceName,
'app',
true,
);
debugPrint('✅ [权限弹窗] TCP响应已发送,准备关闭弹窗');
} else {
debugPrint(
'❌ [权限弹窗] targetDevice 为空,无法调用 confirmPermissionResponse',
);
}
// 🔥 关键修复:先重置弹窗标志位,再关闭弹窗
// 避免 Navigator.pop() 后上下文失效导致后续代码不执行
// 🔥 同拒绝分支:用 await 前就捕获的 remoteCubit,不跨 await 用 context.read。
remoteCubit.resetPermissionCoolDown();
remoteCubit.togglePermissionDialog(false);
// 🔥 关闭弹窗(在状态重置之后):同样防重复点击后的 defunct pop。
if (dialogContext.mounted && Navigator.canPop(dialogContext)) {
Navigator.pop(dialogContext);
}
debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位');
},
child: Text(
AppLocalizations.of(
dialogContext,
).translate('remote_control.agree'),
),
),
],
),
);
}
Widget _buildOfflineScaffold() {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.signal_wifi_off, color: Colors.white, size: 60),
const SizedBox(height: 20),
Text(
AppLocalizations.of(
context,
).translate('remote_control.device_disconnected'),
style: const TextStyle(color: Colors.white, fontSize: 18),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => context.pop(),
child: Text(
AppLocalizations.of(context).translate('remote_control.back'),
),
),
],
),
),
);
}
/// 🔥 显示当前控制设备的弹窗 - 已移除:进入远程遥控页不再弹出设备确认弹窗
}