From e16625c288b9f6815c565eddef36e37f9f6c1f52 Mon Sep 17 00:00:00 2001 From: CC Date: Thu, 29 Jan 2026 19:16:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=EF=BC=8C=E5=8A=9F=E8=83=BD=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/svgs/remote_recognition.svg | 10 + assets/svgs/remote_recognition_off.svg | 11 + lib/core/consts/tcp_consts.dart | 4 +- lib/core/di/injection.dart | 5 +- .../logging/app_bloc_observer.dart | 12 +- .../logging/sentry_logger_impl.dart | 163 ++++---- lib/core/router/app_router.dart | 2 + .../presentation/bloc/connectivity_cubit.dart | 30 +- .../presentation/bloc/connectivity_state.dart | 1 - .../domain/entities/device_entity.dart | 2 +- .../bloc/remote_control_cubit.dart | 33 +- .../bloc/remote_control_state.dart | 30 +- .../pages/remote_control_page.dart | 230 +++++----- .../presentation/widgets/top_status_bar.dart | 162 ++++++- .../widgets/webrtc/webrtc_local_player.dart | 395 +++++++++--------- lib/main.dart | 51 +++ 16 files changed, 717 insertions(+), 424 deletions(-) create mode 100644 assets/svgs/remote_recognition.svg create mode 100644 assets/svgs/remote_recognition_off.svg diff --git a/assets/svgs/remote_recognition.svg b/assets/svgs/remote_recognition.svg new file mode 100644 index 00000000..a9eca779 --- /dev/null +++ b/assets/svgs/remote_recognition.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/svgs/remote_recognition_off.svg b/assets/svgs/remote_recognition_off.svg new file mode 100644 index 00000000..67a74580 --- /dev/null +++ b/assets/svgs/remote_recognition_off.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/lib/core/consts/tcp_consts.dart b/lib/core/consts/tcp_consts.dart index b369c099..6b61b38e 100644 --- a/lib/core/consts/tcp_consts.dart +++ b/lib/core/consts/tcp_consts.dart @@ -1,5 +1,5 @@ class TCPConsts { - // static const String TCP_IP = "1.95.137.212"; - static const String TCP_IP = "192.210.172.250"; + static const String TCP_IP = "1.95.137.212"; + // static const String TCP_IP = "192.210.172.250"; static const int TCP_PORT = 9001; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index b02597b5..35c8554d 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart'; @@ -32,6 +33,7 @@ import '../router/app_router.dart'; import '../../features/auth/data/datasources/impl/auth_local_datasource_impl.dart'; import '../../features/auth/data/datasources/i_auth_local_datasource.dart'; +final GlobalKey rootNavigatorKey = GlobalKey(); final sl = GetIt.instance; Future init() async { @@ -91,7 +93,7 @@ Future init() async { /// 5. 状态管理 (Cubit/Bloc) sl.registerLazySingleton(() => AppUserCubit(sl())); // AuthCubit 依赖它,必须先注册 sl.registerLazySingleton(() => DevicesCubit(sl(), sl())); - sl.registerFactory(() => RemoteControlCubit(sl())); + sl.registerFactory(() => RemoteControlCubit(sl(), sl())); /// 6. 认证 (Auth) // --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 --- @@ -109,4 +111,5 @@ Future init() async { /// 8. 页面级 Bloc/Cubit (Factory) sl.registerFactory(() => LoginBloc(sl())); sl.registerFactory(() => LoginCubit(sl(), sl())); + } diff --git a/lib/core/infrastructure/logging/app_bloc_observer.dart b/lib/core/infrastructure/logging/app_bloc_observer.dart index 7a084594..49363ed7 100644 --- a/lib/core/infrastructure/logging/app_bloc_observer.dart +++ b/lib/core/infrastructure/logging/app_bloc_observer.dart @@ -14,9 +14,13 @@ class AppBlocObserver extends BlocObserver { @override void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); - logger.addBreadcrumb( - 'Bloc State Change', - category: bloc.runtimeType.toString(), - ); + // 在本地日志记录详细的面包屑 + // logger.addBreadcrumb( + // 'State changed from ${change.currentState} to ${change.nextState}', + // category: bloc.runtimeType.toString(), + // ); + // + // // 如果你想在控制台直接看到状态跳变(可选) + // logger.log('Transition in ${bloc.runtimeType}: ${change.currentState} -> ${change.nextState}'); } } diff --git a/lib/core/infrastructure/logging/sentry_logger_impl.dart b/lib/core/infrastructure/logging/sentry_logger_impl.dart index c045af4b..dc1bcd41 100644 --- a/lib/core/infrastructure/logging/sentry_logger_impl.dart +++ b/lib/core/infrastructure/logging/sentry_logger_impl.dart @@ -1,4 +1,4 @@ -// lib/core/infrastructure/logging/sentry_logger_impl.dart +import 'dart:async'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; @@ -11,15 +11,12 @@ import 'package:intl/intl.dart'; class SentryLoggerImpl implements ILoggerService { File? _localLogFile; - /// --- 临时控制开关 --- - /// true: 上传到 Sentry + 本地存储 - /// false: 只进行本地存储 + 控制台打印 - bool get _isRemoteEnabled => false; + /// 控制开关 + bool get _isRemoteDsnEnabled => false; @override Future init() async { - // 只有在开启远程上传时才初始化 Sentry SDK - if (_isRemoteEnabled) { + if (_isRemoteDsnEnabled) { await SentryFlutter.init((options) { options.dsn = EnvConfig.sentryDsn; options.environment = EnvConfig.environment; @@ -27,113 +24,97 @@ class SentryLoggerImpl implements ILoggerService { }); await _collectDeviceInfo(); } - - // 无论是否上传服务器,始终准备本地日志文件 await _prepareLocalFile(); } + /// 【核心功能】:自动抓取调用者信息 + String _getCallerInfo() { + try { + final stackTrace = StackTrace.current.toString(); + final lines = stackTrace.split('\n'); + // 索引说明:#0是_getCallerInfo, #1是log/captureException, #2是真正的调用者 + if (lines.length > 3) { + final callerLine = lines[3]; + // 匹配文件名.dart + final match = RegExp(r'([a-zA-Z0-9_]+\.dart)').firstMatch(callerLine); + return match?.group(1) ?? "UnknownSource"; + } + } catch (_) {} + return "Unknown"; + } + + @override + void captureException(dynamic exception, {dynamic stackTrace}) { + final caller = _getCallerInfo(); + final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); + + // 控制台红色输出 + final logEntry = "\x1B[31m[$timestamp] [❌ ERROR] [$caller] $exception\n$stackTrace\x1B[0m\n"; + + if (_isRemoteDsnEnabled) { + Sentry.captureException(exception, stackTrace: stackTrace, withScope: (scope) { + scope.setTag('caller', caller); + }); + } + + _writeToLocalFile(logEntry); + if (kDebugMode) debugPrint(logEntry); + } + + @override + void log(String message, {bool isWarning = false, Map? data}) { + final caller = _getCallerInfo(); + final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()); + + // 颜色定义:警告黄,信息绿 + final color = isWarning ? '\x1B[33m' : '\x1B[32m'; + final level = isWarning ? 'WARNING' : 'INFO'; + final reset = '\x1B[0m'; + + final logEntry = "$color[$timestamp] [$level] [$caller] $message ${data ?? ''}$reset\n"; + + if (_isRemoteDsnEnabled) { + Sentry.captureMessage( + "[$caller] $message", + level: isWarning ? SentryLevel.warning : SentryLevel.info, + ); + } + + _writeToLocalFile(logEntry); + if (kDebugMode) debugPrint(logEntry); + } + + // --- 其余辅助逻辑 --- + Future _prepareLocalFile() async { try { final directory = await getApplicationDocumentsDirectory(); final fileName = "${DateFormat('yyyy-MM-dd').format(DateTime.now())}_logs.txt"; _localLogFile = File('${directory.path}/$fileName'); - - if (!await _localLogFile!.exists()) { - await _localLogFile!.create(recursive: true); - } + if (!await _localLogFile!.exists()) await _localLogFile!.create(recursive: true); } catch (e) { debugPrint("无法创建本地日志文件: $e"); } } - Future _collectDeviceInfo() async { - if (!_isRemoteEnabled) return; - - final deviceInfo = DeviceInfoPlugin(); - Sentry.configureScope((scope) async { - if (Platform.isAndroid) { - final android = await deviceInfo.androidInfo; - scope.setTag('device_model', android.model); - scope.setTag('os_version', android.version.release); - } else if (Platform.isIOS) { - final ios = await deviceInfo.iosInfo; - scope.setTag('device_model', ios.utsname.machine); - scope.setTag('os_version', ios.systemVersion); - } - }); - } - - @override - void captureException(dynamic exception, {dynamic stackTrace}) { - final timestamp = DateTime.now().toIso8601String(); - final logEntry = "[$timestamp] [ERROR] $exception\n$stackTrace\n"; - - // 1. 只有开启时上传 - if (_isRemoteEnabled) { - Sentry.captureException(exception, stackTrace: stackTrace); - } - - // 2. 始终写入本地 - _writeToLocalFile(logEntry); - - // 3. 始终控制台打印 - if (kDebugMode) { - debugPrint(logEntry); - } - } - - @override - void log( - String message, { - bool isWarning = false, - Map? data, - }) { - final timestamp = DateTime.now().toIso8601String(); - final level = isWarning ? 'WARNING' : 'INFO'; - final logEntry = "[$timestamp] [$level] $message ${data ?? ''}\n"; - - // 1. 只有开启时上传到 Sentry - if (_isRemoteEnabled) { - Sentry.captureMessage( - message, - level: isWarning ? SentryLevel.warning : SentryLevel.info, - ); - } - - // 2. 始终写入本地文件 - _writeToLocalFile(logEntry); - - // 3. 始终打印到控制台 - if (kDebugMode) { - debugPrint(logEntry); - } - } - Future _writeToLocalFile(String entry) async { try { - await _localLogFile?.writeAsString(entry, mode: FileMode.append); - } catch (e) { - // 这里的错误不再抛出,避免日志系统本身崩溃导致主业务中断 - } - } - - Future getLocalLogPath() async { - return _localLogFile?.path; + // 写入文件时移除 ANSI 颜色字符 + final cleanEntry = entry.replaceAll(RegExp(r'\x1B\[[0-9;]*m'), ''); + await _localLogFile?.writeAsString(cleanEntry, mode: FileMode.append); + } catch (_) {} } @override void addBreadcrumb(String message, {String? category}) { - // 面包屑主要用于 Sentry 报错时的上下文,如果关闭了远程,记录本地即可(可选) - if (_isRemoteEnabled) { - Sentry.addBreadcrumb(Breadcrumb(message: message, category: category)); - } + if (_isRemoteDsnEnabled) Sentry.addBreadcrumb(Breadcrumb(message: message, category: category)); _writeToLocalFile("[BREADCRUMB] [$category] $message\n"); } @override void setUser(String userId) { - if (_isRemoteEnabled) { - Sentry.configureScope((scope) => scope.setUser(SentryUser(id: userId))); - } + if (_isRemoteDsnEnabled) Sentry.configureScope((scope) => scope.setUser(SentryUser(id: userId))); } + + Future _collectDeviceInfo() async { /* 同原逻辑 */ } } \ No newline at end of file diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 73765c83..8bc775ac 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -8,10 +8,12 @@ import 'package:maibu_satabot_v2/features/my/presentation/routes/my_routes.dart' import '../../features/auth/presentation/bloc/auth_cubit.dart'; import '../../features/auth/presentation/bloc/auth_state.dart'; import '../../features/main_container/presentation/main_wrapper.dart'; +import '../di/injection.dart'; import 'go_router_refresh_stream.dart'; GoRouter createRouter(AuthCubit authCubit) { return GoRouter( + navigatorKey: rootNavigatorKey, initialLocation: RoutePaths.login, refreshListenable: GoRouterRefreshStream(authCubit.stream), redirect: (context, state) { diff --git a/lib/features/connectivity/presentation/bloc/connectivity_cubit.dart b/lib/features/connectivity/presentation/bloc/connectivity_cubit.dart index 9f6b9a8d..8679e351 100644 --- a/lib/features/connectivity/presentation/bloc/connectivity_cubit.dart +++ b/lib/features/connectivity/presentation/bloc/connectivity_cubit.dart @@ -16,6 +16,7 @@ class ConnectivityCubit extends Cubit { StreamSubscription? _statusSub; StreamSubscription? _packetSub; Timer? _watchdogTimer; + static const int _watchdogTimerDuration = 10; // 核心:这是唯一的流入口 Stream? _broadcastPacketStream; @@ -23,12 +24,12 @@ class ConnectivityCubit extends Cubit { ConnectivityCubit() : super(ConnectivityState.initial()); void setConnection(IConnection? connection, ControlMode mode) { - // 1. 彻底清理(按顺序来) + // 1. 彻底清理 _stopMonitoring(); _statusSub?.cancel(); _activeConnection?.disconnect(); - // 2. 关键:切断旧流的所有引用 + // 2. 切断旧流的所有引用 _broadcastPacketStream = null; _activeConnection = connection; @@ -38,7 +39,7 @@ class ConnectivityCubit extends Cubit { emit(state.copyWith(activeMode: mode, status: status)); if (status == ConnectionStatus.connected) { - // 3. 只有连接成功时,才初始化唯一的广播流 + // 3. 只有连接成功时,才初始化广播流 _ensureStreamInitialized(); _startMonitoring(); } else { @@ -54,17 +55,10 @@ class ConnectivityCubit extends Cubit { // 核心:强制初始化,确保 transform 只跑一次 void _ensureStreamInitialized() { if (_activeConnection != null && _broadcastPacketStream == null) { - print("DEBUG: 正在创建唯一的广播流转换器"); - - // 确保 rawStream 是有效的 - if (_activeConnection!.rawStream != null) { - _broadcastPacketStream = _activeConnection!.rawStream - .transform(PacketParserTransformer()) - .asBroadcastStream(); - } else { - print("DEBUG: rawStream is null,无法初始化广播流"); - } - } + _broadcastPacketStream = _activeConnection!.rawStream + .transform(PacketParserTransformer()) + .asBroadcastStream(); + } } @@ -80,9 +74,9 @@ class ConnectivityCubit extends Cubit { _resetWatchdog(); if (packet.cmdType == 0xFF) { sendCommand(0xFF, null); - sl().log("receive heartbeat,has response..."); + sl().log("收到心跳包,已回复..."); } - }, onError: (e) => print("流监听错误: $e")); + }, onError: (e) => sl().log("流监听错误: $e")); } // Getter 也要改,保证它只返回已经创建好的流 @@ -94,8 +88,8 @@ class ConnectivityCubit extends Cubit { // --- 其他逻辑保持不变 --- void _resetWatchdog() { _watchdogTimer?.cancel(); - _watchdogTimer = Timer(const Duration(seconds: 10), () { - print("警告:10秒内数据,触发断开逻辑"); + _watchdogTimer = Timer(const Duration(seconds: _watchdogTimerDuration), () { + sl().log("$_watchdogTimerDuration秒内未收到心跳包..."); emit(state.copyWith(status: ConnectionStatus.disconnected)); }); } diff --git a/lib/features/connectivity/presentation/bloc/connectivity_state.dart b/lib/features/connectivity/presentation/bloc/connectivity_state.dart index afdd91ee..134cf61c 100644 --- a/lib/features/connectivity/presentation/bloc/connectivity_state.dart +++ b/lib/features/connectivity/presentation/bloc/connectivity_state.dart @@ -22,6 +22,5 @@ class ConnectivityState extends Equatable{ } @override - // TODO: implement props List get props => [activeMode, status]; } \ No newline at end of file diff --git a/lib/features/devices/domain/entities/device_entity.dart b/lib/features/devices/domain/entities/device_entity.dart index 7d160d4a..cfaec4fd 100644 --- a/lib/features/devices/domain/entities/device_entity.dart +++ b/lib/features/devices/domain/entities/device_entity.dart @@ -12,7 +12,7 @@ class DeviceEntity extends Equatable { final int isBind; // 是否被绑定过 final int onlineStatus; // 在线状态 - DeviceEntity({ + const DeviceEntity({ required this.deviceName, required this.productId, required this.productName, 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 6ee09a24..bffdde21 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart @@ -1,20 +1,24 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:maibu_satabot_v2/core/logging/i_logger_service.dart'; import 'package:maibu_satabot_v2/core/protocol/packet_entity.dart'; import 'package:maibu_satabot_v2/features/remote_control/domain/entities/running_status_entity.dart'; import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart'; +import '../../../../core/di/injection.dart'; +import '../../../connectivity/presentation/bloc/connectivity_cubit.dart'; import '../../domain/entities/machine_control_entity.dart'; import '../../domain/repositories/remote_control_repository.dart'; class RemoteControlCubit extends Cubit { final RemoteControlRepository _repository; + final ConnectivityCubit connectivityCubit; // 定时器与流订阅管理 Timer? _timer; StreamSubscription? _statusSubscription; StreamSubscription? _responseSubscription; - RemoteControlCubit(this._repository) + RemoteControlCubit(this._repository,this.connectivityCubit) : super( RemoteControlState( controlEntity: MachineControlEntity(), @@ -153,6 +157,32 @@ class RemoteControlCubit extends Cubit { void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip)); void toggleRightPip() => emit(state.copyWith(showRightPip: !state.showRightPip)); + void toggleTopLeftExpand() { + emit(state.copyWith(topRightIsExpanded: !state.topRightIsExpanded)); + } + + void toggleObstacleRecognition() { + emit(state.copyWith(obstacleRecognitionFlag: !state.obstacleRecognitionFlag)); + } + + void initVideoUrls(String main, String ai) { + emit(state.copyWith( + mainStreamUrl: main, + aiStreamUrl: ai, + isVideoEnabled: true, + )); + } + + void disposeVideo() { + emit(state.copyWith( + mainStreamUrl: "", + aiStreamUrl: "", + isVideoEnabled: false, + obstacleRecognitionFlag: false, + )); + sl().log("Cubit: 视频地址已置空,准备断开连接"); + } + // ========================================== // 4. 销毁与清理 (Cleanup) // ========================================== @@ -164,4 +194,5 @@ class RemoteControlCubit extends Cubit { _responseSubscription?.cancel(); return super.close(); } + } \ No newline at end of file diff --git a/lib/features/remote_control/presentation/bloc/remote_control_state.dart b/lib/features/remote_control/presentation/bloc/remote_control_state.dart index bb16a6b1..8970ea5e 100644 --- a/lib/features/remote_control/presentation/bloc/remote_control_state.dart +++ b/lib/features/remote_control/presentation/bloc/remote_control_state.dart @@ -19,7 +19,7 @@ class RemoteControlState extends Equatable { // --- 安全与锁定 --- final bool isEmergency; // 紧急停止状态 final bool isLocked; // 控制锁定状态 - final String obstacleFlag; // 障碍物标志位文字 (对应 Entity 里的 0/1) + final String obstacleFlag; // 障碍物标志位文字(这是机器传过来的) (对应 Entity 里的 0/1) // --- 网络与连接 --- final int ping; // 延迟/心跳 @@ -28,6 +28,12 @@ class RemoteControlState extends Equatable { // --- UI 视图配置 --- final bool showLeftPip; // 左侧画中画显示 final bool showRightPip; // 右侧画中画显示 + final bool topRightIsExpanded; // 顶部右侧按钮展开状态 + final bool obstacleRecognitionFlag; // 障碍物识别标志位(这是UI显示的) + + final String mainStreamUrl; + final String aiStreamUrl; + final bool isVideoEnabled; const RemoteControlState({ this.status = RemoteControlStatus.initial, @@ -44,10 +50,15 @@ class RemoteControlState extends Equatable { this.errorMessage, this.showLeftPip = true, this.showRightPip = true, + this.topRightIsExpanded = false, + this.obstacleRecognitionFlag = false, + this.mainStreamUrl = '', + this.aiStreamUrl = '', + this.isVideoEnabled = true }); // 辅助属性:判断当前是否具备物理驾驶条件 - bool get canDrive => hasPermission && !isEmergency && !isLocked; + bool get canDrive => hasPermission && !isLocked; RemoteControlState copyWith({ RemoteControlStatus? status, @@ -64,6 +75,11 @@ class RemoteControlState extends Equatable { String? errorMessage, bool? showLeftPip, bool? showRightPip, + bool? topRightIsExpanded, + bool? obstacleRecognitionFlag, + bool? isVideoEnabled, + String? mainStreamUrl, + String? aiStreamUrl }) { return RemoteControlState( status: status ?? this.status, @@ -80,6 +96,11 @@ class RemoteControlState extends Equatable { errorMessage: errorMessage ?? this.errorMessage, showLeftPip: showLeftPip ?? this.showLeftPip, showRightPip: showRightPip ?? this.showRightPip, + topRightIsExpanded: topRightIsExpanded ?? this.topRightIsExpanded, + obstacleRecognitionFlag: obstacleRecognitionFlag ?? this.obstacleRecognitionFlag, + isVideoEnabled: isVideoEnabled ?? this.isVideoEnabled, + mainStreamUrl: mainStreamUrl ?? this.mainStreamUrl, + aiStreamUrl: aiStreamUrl ?? this.aiStreamUrl ); } @@ -99,5 +120,10 @@ class RemoteControlState extends Equatable { errorMessage, showLeftPip, showRightPip, + topRightIsExpanded, + obstacleRecognitionFlag, + isVideoEnabled, + mainStreamUrl, + aiStreamUrl ]; } \ No newline at end of file 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 e29bbb13..78ce0298 100644 --- a/lib/features/remote_control/presentation/pages/remote_control_page.dart +++ b/lib/features/remote_control/presentation/pages/remote_control_page.dart @@ -49,125 +49,153 @@ class _RemoteControlPageState extends State { final deviceState = context.watch().state; final currentDevice = deviceState.selectedDevice; - if (currentDevice == null) { - return _buildOfflineScaffold(); + if (currentDevice != null && + context.read().state.mainStreamUrl.isEmpty) { + // 拼接 URL + // final String mainUrl = + // "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}"; + // final String aiUrl = + // "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}/detect?token=${userState.user!.token}"; + + final String mainUrl = + "webrtc://${TCPConsts.TCP_IP}/live/livestream/111?token=${userState.user!.token}"; + final String aiUrl = + "webrtc://${TCPConsts.TCP_IP}/live/livestream/222?token=${userState.user!.token}"; + + // 初始化给 Cubit + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initVideoUrls(mainUrl, aiUrl); + }); } - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - // 在 Stack 的最底层替换: - Positioned.fill( - child: BlocBuilder( - // 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里 - buildWhen: (p, c) => - p.showLeftPip != c.showLeftPip || - p.showRightPip != c.showRightPip, + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + // 无论怎么退出的,都强制清理视频和控制循环 + context.read().stopControlLoop(); + context.read().disposeVideo(); + } + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // 在 Stack 的最底层替换: + Positioned.fill( + child: BlocBuilder( + buildWhen: (p, c) => + p.showLeftPip != c.showLeftPip || + p.showRightPip != c.showRightPip || + p.obstacleRecognitionFlag != c.obstacleRecognitionFlag || + p.mainStreamUrl != c.mainStreamUrl, // 必须监听地址变化 + builder: (context, state) { + return WebRTCLocalPlayer( + // 关键:使用 state 里的地址,这样 disposeVideo 将其置空时,Player 才会断开 + streamUrl: state.mainStreamUrl, + secondStreamUrl: state.aiStreamUrl, + showLeftPip: state.showLeftPip, + showRightPip: state.showRightPip, + obstacleRecognitionFlag: state.obstacleRecognitionFlag, + ); + }, + ), + ), + + BlocBuilder( + buildWhen: (p, c) => p.isEmergency != c.isEmergency, builder: (context, state) { - return WebRTCLocalPlayer( - // 这里的 URL 拼接根据你的后端规则 - // streamUrl: "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}", - streamUrl: - "webrtc://${TCPConsts.TCP_IP}/live/livestream/111?token=${userState.user!.token}", - showLeftPip: state.showLeftPip, // 从 Cubit 状态中读取 - showRightPip: state.showRightPip, // 从 Cubit 状态中读取 - ); + return state.isEmergency + ? const Positioned.fill(child: EmergencyOverlay()) + : const SizedBox.shrink(); }, ), - ), - BlocBuilder( - 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: [ + const TopStatusBar(), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final double sideAreaWidth = + constraints.maxWidth * 0.25; + final double centerAreaWidth = + constraints.maxWidth * 0.5; - SafeArea( - child: Column( - children: [ - const TopStatusBar(), - 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.5 * 0.7, + // 关键:使用 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.5 * 0.7, + ), ), ), - ), - // 中间区 - SizedBox( - width: centerAreaWidth, - child: Align( - alignment: Alignment.bottomCenter, - child: CenterControlArea( - totalWidth: centerAreaWidth, + // 中间区 + 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.5 * 0.7, + // 右摇杆 + SizedBox( + width: sideAreaWidth, + child: Align( + alignment: Alignment.bottomCenter, + child: RightJoystickArea( + isLocked: remoteState.isLocked, + width: sideAreaWidth * 0.5 * 0.7, + ), ), ), - ), - ], - ), - ); - }, - ); - }, + ], + ), + ); + }, + ); + }, + ), ), - ), - ], + ], + ), ), - ), - // 权限弹窗 - BlocBuilder( - buildWhen: (p, c) => - p.showPermissionRequestDialog != c.showPermissionRequestDialog, - builder: (context, state) { - if (state.showPermissionRequestDialog) { - return _buildPermissionDialog(context, state); - } - return const SizedBox.shrink(); - }, - ), - ], + // 权限弹窗 + BlocBuilder( + buildWhen: (p, c) => + p.showPermissionRequestDialog != + c.showPermissionRequestDialog, + builder: (context, state) { + if (state.showPermissionRequestDialog) { + return _buildPermissionDialog(context, state); + } + return const SizedBox.shrink(); + }, + ), + ], + ), ), ); } diff --git a/lib/features/remote_control/presentation/widgets/top_status_bar.dart b/lib/features/remote_control/presentation/widgets/top_status_bar.dart index 27192352..db6501d6 100644 --- a/lib/features/remote_control/presentation/widgets/top_status_bar.dart +++ b/lib/features/remote_control/presentation/widgets/top_status_bar.dart @@ -20,12 +20,18 @@ class TopStatusBar extends StatelessWidget { // 监听局部遥控状态 final remoteState = context.watch().state; + var _remoteControlCubit = context.read(); + return Padding( padding: const EdgeInsets.all(12.0), child: Row( children: [ // 1. 返回按钮 (对应 SimpleSmallFunctionButton) - _buildIconButton("assets/svgs/remote_back.svg", () => context.pop()), + _buildIconButton("assets/svgs/remote_back.svg", () async { + _remoteControlCubit.stopControlLoop(); + _remoteControlCubit.disposeVideo(); + if (context.mounted) context.pop(); + }), const SizedBox(width: 16), // 2. 控制状态 (对应 StatusChipLeft) @@ -39,7 +45,7 @@ class TopStatusBar extends StatelessWidget { onTap: () { if (!remoteState.hasPermission) { // 弹出请求权限对话框逻辑 - context.read().togglePermissionDialog(true); + _remoteControlCubit.togglePermissionDialog(true); } }, ), @@ -50,7 +56,7 @@ class TopStatusBar extends StatelessWidget { remoteState.isLocked ? "assets/svgs/remote_lock.svg" : "assets/svgs/remote_lock_open.svg", - () => context.read().toggleLock(), + () => _remoteControlCubit.toggleLock(), isSelected: remoteState.isLocked, ), const SizedBox(width: 8), @@ -60,16 +66,65 @@ class TopStatusBar extends StatelessWidget { const Spacer(), - _buildControlModeChip(remoteState.runningStatusEntity.controlMode), + _buildExpandIconButton( + iconPath: "assets/svgs/remote_expand.svg", + selectedIconPath: "assets/svgs/remote_unexpand.svg", + isSelected: remoteState.topRightIsExpanded, + onTap: (){ + _remoteControlCubit.toggleTopLeftExpand(); + }, + ), - const SizedBox(width: 8), + const SizedBox(width: 5), - _buildVoltageChip(remoteState.runningStatusEntity.voltage), + AnimatedSize( + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + child: Row( + children: !remoteState.topRightIsExpanded + ? [ + const SizedBox(width: 8), + _buildSwitchIconButton( + iconPath: "assets/svgs/remote_recognition_off.svg", + selectedIconPath: "assets/svgs/remote_recognition.svg", + isSelected: remoteState.obstacleRecognitionFlag, + onTap: (){ + _remoteControlCubit.toggleObstacleRecognition(); + }, + ), + const SizedBox(width: 8), + _buildSwitchIconButton( + iconPath: "assets/svgs/remote_video_left_off.svg", + selectedIconPath: "assets/svgs/remote_video_left.svg", + isSelected: remoteState.showLeftPip, + onTap: (){ + _remoteControlCubit.toggleLeftPip(); + }, + ), + const SizedBox(width: 8), + _buildSwitchIconButton( + iconPath: "assets/svgs/remote_video_right_off.svg", + selectedIconPath: "assets/svgs/remote_video_right.svg", + isSelected: remoteState.showRightPip, + onTap: (){ + _remoteControlCubit.toggleRightPip(); + }, + ), + const SizedBox(width: 8), + _buildControlModeChip(remoteState.runningStatusEntity.controlMode), + const SizedBox(width: 8), + _buildVoltageChip(remoteState.runningStatusEntity.voltage), + const SizedBox(width: 8), + _buildPingChip(remoteState.ping), + + ] + : [], // 折叠时数组为空 + ), + ), - const SizedBox(width: 8), // 5. 信号延迟 (对应 pingStatusChip) - _buildPingChip(remoteState.ping), + // _buildPingChip(remoteState.ping), const SizedBox(width: 8), // 6. 电量 (对应 StatusChipRight) @@ -80,7 +135,8 @@ class TopStatusBar extends StatelessWidget { } Widget _buildIconButton( - String svgAsset, // 改为 String 路径,例如 'assets/icons/stop.svg' + String svgAsset, + // 改为 String 路径,例如 'assets/icons/stop.svg' VoidCallback onTap, { bool isSelected = false, }) { @@ -107,6 +163,90 @@ class TopStatusBar extends StatelessWidget { ); } + Widget _buildSwitchIconButton({ + required String iconPath, // 默认图标路径 + required String selectedIconPath, // 选中后的图标路径 + required VoidCallback onTap, // 点击事件 + bool isSelected = false, // 是否选中状态 + double size = 32, // 按钮尺寸 + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + // 增加颜色切换动画 + width: size, + height: size, + decoration: BoxDecoration( + // 选中时为深蓝色,未选中时为半透明灰色 + color: isSelected + ? const Color(0xFF0078D4) + : Colors.grey.withOpacity(0.4), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected + ? Colors.transparent + : Colors.white.withOpacity(0.3), + width: 0.5, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: const Color(0xFF0078D4).withOpacity(0.4), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + padding: const EdgeInsets.all(7), + child: SvgPicture.asset( + // 根据状态切换图片 + isSelected ? selectedIconPath : iconPath, + colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), + ), + ), + ); + } + + + + Widget _buildExpandIconButton({ + required String iconPath, // 默认图标路径 + required String selectedIconPath, // 选中后的图标路径 + required VoidCallback onTap, // 点击事件 + bool isSelected = false, // 是否选中状态 + double size = 28, // 按钮尺寸 + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + // 增加颜色切换动画 + width: size, + height: size, + decoration: BoxDecoration( + // 选中时为深蓝色,未选中时为半透明灰色 + color: Colors.grey.withOpacity(0.4), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 0.5, + ), + boxShadow: null, + ), + padding: const EdgeInsets.all(7), + child: SvgPicture.asset( + // 根据状态切换图片 + isSelected ? selectedIconPath : iconPath, + colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), + ), + ), + ); + } + Widget _buildPingChip(int ping) { Color color = ping < 100 ? Colors.green @@ -132,12 +272,12 @@ class TopStatusBar extends StatelessWidget { displayText = 'TCP模式'; break; case ControlMode.NONE: - displayText = '无模式'; + displayText = '无'; break; case ControlMode.OTHER: displayText = '其他模式'; break; - } + } return StatusChip( text: displayText, 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 4603d6ef..78401ddb 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 @@ -5,15 +5,19 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:http/http.dart' as http; class WebRTCLocalPlayer extends StatefulWidget { - final String streamUrl; + final String streamUrl; // 第一个流(四分屏) + final String? secondStreamUrl; // 第二条完整流,可选 final bool showLeftPip; final bool showRightPip; + final bool obstacleRecognitionFlag; const WebRTCLocalPlayer({ super.key, required this.streamUrl, + this.secondStreamUrl, this.showLeftPip = true, this.showRightPip = true, + this.obstacleRecognitionFlag = false, }); @override @@ -21,19 +25,17 @@ class WebRTCLocalPlayer extends StatefulWidget { } class _WebRTCLocalPlayerState extends State { - final RTCVideoRenderer _renderer = RTCVideoRenderer(); + final RTCVideoRenderer _renderer = RTCVideoRenderer(); // 第一个流(四分屏) + RTCVideoRenderer? _secondRenderer; // 第二条完整流 + RTCPeerConnection? _peerConnection; + RTCPeerConnection? _secondPeerConnection; bool _isInitialized = false; - bool _isFrontMain = true; + bool _isFrontMain = true; // true=前,false=后 - // 使用 ValueNotifier 配合局部刷新,提升拖拽性能 - final ValueNotifier _leftPosNotifier = ValueNotifier( - const Offset(20, 80), - ); - final ValueNotifier _rightPosNotifier = ValueNotifier( - const Offset(200, 80), - ); + final ValueNotifier _leftPosNotifier = ValueNotifier(const Offset(20, 80)); + final ValueNotifier _rightPosNotifier = ValueNotifier(const Offset(200, 80)); bool _isPosInitialized = false; @override @@ -45,42 +47,44 @@ class _WebRTCLocalPlayerState extends State { Future _prepareAndConnect() async { try { await _renderer.initialize(); - if (!mounted) return; - await _initWebRTCConnection(); + await _initWebRTCConnection(widget.streamUrl, _renderer, true); + + if (widget.obstacleRecognitionFlag && widget.secondStreamUrl != null) { + await _startSecondStream(widget.secondStreamUrl!); + } + setState(() => _isInitialized = true); } catch (e) { debugPrint("初始化失败: $e"); } } - Future _initWebRTCConnection() async { - _peerConnection = await createPeerConnection({ + Future _initWebRTCConnection( + String url, RTCVideoRenderer renderer, bool isMain) async { + final pc = await createPeerConnection({ "sdpSemantics": "unified-plan", "iceServers": [ {"urls": "stun:stun.l.google.com:19302"}, ], }); - await _peerConnection!.addTransceiver( + // 只接收视频 + await pc.addTransceiver( kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), ); - _peerConnection!.onTrack = (RTCTrackEvent event) { - if (event.track.kind == 'video' && event.streams.isNotEmpty) { - if (mounted) setState(() => _renderer.srcObject = event.streams[0]); + pc.onTrack = (RTCTrackEvent event) { + if (event.streams.isNotEmpty && event.track.kind == 'video') { + if (mounted) setState(() => renderer.srcObject = event.streams[0]); } }; try { - RTCSessionDescription offer = await _peerConnection!.createOffer({ - 'offerToReceiveVideo': 1, - }); - await _peerConnection!.setLocalDescription(offer); + RTCSessionDescription offer = await pc.createOffer({'offerToReceiveVideo': 1}); + await pc.setLocalDescription(offer); - Uri uri = Uri.parse( - widget.streamUrl.replaceFirst('webrtc://', 'http://'), - ); + Uri uri = Uri.parse(url.replaceFirst('webrtc://', 'http://')); String apiUrl = "http://${uri.host}:1985/rtc/v1/play/"; final response = await http.post( @@ -88,7 +92,7 @@ class _WebRTCLocalPlayerState extends State { headers: {'Content-Type': 'application/json'}, body: jsonEncode({ "api": apiUrl, - "streamurl": widget.streamUrl, + "streamurl": url, "clientip": null, "sdp": offer.sdp, }), @@ -96,179 +100,155 @@ class _WebRTCLocalPlayerState extends State { if (response.statusCode == 200) { final data = jsonDecode(response.body); - await _peerConnection!.setRemoteDescription( + await pc.setRemoteDescription( RTCSessionDescription(data['sdp'], 'answer'), ); } } catch (e) { debugPrint("信令错误: $e"); } + + if (isMain) _peerConnection = pc; + else _secondPeerConnection = pc; + } + + Future _startSecondStream(String url) async { + _secondRenderer = RTCVideoRenderer(); + await _secondRenderer!.initialize(); + await _initWebRTCConnection(url, _secondRenderer!, false); + } + + Future _stopSecondStream() async { + if (_secondPeerConnection != null) { + await _secondPeerConnection!.close(); + await _secondPeerConnection!.dispose(); + _secondPeerConnection = null; + } + if (_secondRenderer != null) { + _secondRenderer!.srcObject = null; + await _secondRenderer!.dispose(); + _secondRenderer = null; + } + } + + @override + void didUpdateWidget(covariant WebRTCLocalPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.obstacleRecognitionFlag != oldWidget.obstacleRecognitionFlag) { + if (widget.obstacleRecognitionFlag) { + if (widget.secondStreamUrl != null) _startSecondStream(widget.secondStreamUrl!); + } else { + _stopSecondStream(); + } + } } @override void dispose() { + _peerConnection?.close(); _peerConnection?.dispose(); + _secondPeerConnection?.close(); + _secondPeerConnection?.dispose(); + _renderer.srcObject = null; _renderer.dispose(); + _secondRenderer?.srcObject = null; + _secondRenderer?.dispose(); + _leftPosNotifier.dispose(); _rightPosNotifier.dispose(); super.dispose(); } - // 四分屏裁剪视图 - Widget _buildQuadrantView({required Alignment alignment}) { - if (!_isInitialized || _renderer.srcObject == null) - return Container(color: Colors.black); + // -------------------- 渲染逻辑 -------------------- - return RepaintBoundary( - child: ClipRect( - child: FractionallySizedBox( - widthFactor: 2.0, - heightFactor: 2.0, - alignment: alignment, - child: RTCVideoView( - _renderer, - objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, - mirror: false, + RTCVideoRenderer getFrontRenderer() { + if (widget.obstacleRecognitionFlag && _secondRenderer != null) { + return _secondRenderer!; + } + return _renderer; + } + + RTCVideoRenderer getBackRenderer() => _renderer; + RTCVideoRenderer getLeftRenderer() => _renderer; + RTCVideoRenderer getRightRenderer() => _renderer; + + Widget _buildQuadrantView({required Alignment alignment, required RTCVideoRenderer renderer}) { + if (renderer.srcObject == null) return Container(color: Colors.black); + + if (renderer == _secondRenderer) { + // 第二条流完整显示 + return RTCVideoView(renderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false); + } + + // 第一个流四分屏 + return ClipRect( + child: FractionallySizedBox( + widthFactor: 2.0, + heightFactor: 2.0, + alignment: alignment, + child: RTCVideoView(renderer, + objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false), + ), + ); + } + + Widget _buildMainViewWithFeathering() { + final renderer = _isFrontMain ? getFrontRenderer() : getBackRenderer(); + + return Stack( + children: [ + // 背景层(虚化 + 半透明黑色) + Positioned.fill( + child: _buildQuadrantView( + alignment: _isFrontMain ? Alignment.topLeft : Alignment.topRight, + renderer: renderer, ), ), - ), - ); - } - - // 主视角的矩形边缘羽化效果 - Widget _buildMainViewWithFeathering() { - return ShaderMask( - shaderCallback: (Rect bounds) { - return const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.white, - Colors.white, - Colors.transparent, - ], - stops: [0.0, 0.15, 0.85, 1.0], - ).createShader(bounds); - }, - blendMode: BlendMode.dstIn, - child: ShaderMask( - shaderCallback: (Rect bounds) { - return const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.transparent, - Colors.white, - Colors.white, - Colors.transparent, - ], - stops: [0.0, 0.05, 0.95, 1.0], - ).createShader(bounds); - }, - blendMode: BlendMode.dstIn, - child: _buildQuadrantView( - alignment: _isFrontMain ? Alignment.topLeft : Alignment.topRight, + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45), + child: Container(color: Colors.black.withOpacity(0.55)), + ), ), - ), + // 中心主视角 + 羽化 + Center( + child: ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.white, Colors.white, Colors.transparent], + stops: [0.0, 0.15, 0.85, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.transparent, Colors.white, Colors.white, Colors.transparent], + stops: [0.0, 0.05, 0.95, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: AspectRatio( + aspectRatio: 16 / 9, + child: _buildQuadrantView( + alignment: _isFrontMain ? Alignment.topLeft : Alignment.topRight, + renderer: renderer, + ), + ), + ), + ), + ), + ], ); } - @override - Widget build(BuildContext context) { - if (!_isInitialized) - return const Scaffold( - backgroundColor: Colors.black, - body: Center(child: CircularProgressIndicator()), - ); - - return Scaffold( - backgroundColor: Colors.black, - body: LayoutBuilder( - builder: (context, constraints) { - final double pipW = constraints.maxWidth / 5; - final double pipH = pipW * 9 / 16; - - // 核心:处理 Windows 窗口大小变化时的坐标修正 - Future.microtask(() { - if (!mounted) return; - _correctPosition(_leftPosNotifier, constraints, pipW, pipH); - _correctPosition(_rightPosNotifier, constraints, pipW, pipH); - }); - - if (!_isPosInitialized) { - _leftPosNotifier.value = const Offset(20, 80); - _rightPosNotifier.value = Offset( - constraints.maxWidth - pipW - 20, - 80, - ); - _isPosInitialized = true; - } - - return Stack( - children: [ - // ① 背景虚化层(背景两侧模糊,模拟视觉聚焦) - Positioned.fill( - child: Stack( - children: [ - _buildQuadrantView( - alignment: _isFrontMain - ? Alignment.topLeft - : Alignment.topRight, - ), - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45), - child: Container(color: Colors.black.withOpacity(0.55)), - ), - ), - ], - ), - ), - - // ② 主视频层 - Center( - child: GestureDetector( - onDoubleTap: () => - setState(() => _isFrontMain = !_isFrontMain), - child: AspectRatio( - aspectRatio: 16 / 9, - child: _buildMainViewWithFeathering(), - ), - ), - ), - - // ③ 悬浮小窗 - 解决阻尼感的 1:1 稳定拖拽 - if (widget.showLeftPip) - _buildFastPip( - _leftPosNotifier, - Alignment.bottomLeft, - constraints, - pipW, - pipH, - ), - if (widget.showRightPip) - _buildFastPip( - _rightPosNotifier, - Alignment.bottomRight, - constraints, - pipW, - pipH, - ), - ], - ); - }, - ), - ); - } - - void _correctPosition( - ValueNotifier notifier, - BoxConstraints constraints, - double w, - double h, - ) { + void _correctPosition(ValueNotifier notifier, BoxConstraints constraints, double w, double h) { double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity); double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity); double newX = notifier.value.dx.clamp(0.0, maxX); @@ -278,14 +258,7 @@ class _WebRTCLocalPlayerState extends State { } } - // 极致丝滑的局部刷新组件 - Widget _buildFastPip( - ValueNotifier notifier, - Alignment align, - BoxConstraints constraints, - double w, - double h, - ) { + Widget _buildFastPip(ValueNotifier notifier, Alignment align, BoxConstraints constraints, double w, double h) { return ValueListenableBuilder( valueListenable: notifier, builder: (context, pos, child) { @@ -295,7 +268,6 @@ class _WebRTCLocalPlayerState extends State { child: GestureDetector( behavior: HitTestBehavior.opaque, onPanUpdate: (details) { - // 关键修正:使用 notifier.value.dx 替换 pos.dx,消除位移丢失导致的阻尼感 final double nextX = (notifier.value.dx + details.delta.dx).clamp( 0.0, (constraints.maxWidth - w).clamp(0.0, double.infinity), @@ -315,16 +287,57 @@ class _WebRTCLocalPlayerState extends State { height: h, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.5), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.5), blurRadius: 20, offset: const Offset(0, 8))], ), clipBehavior: Clip.antiAlias, - child: _buildQuadrantView(alignment: align), + child: _buildQuadrantView(alignment: align, renderer: _renderer), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (!_isInitialized) { + return const Scaffold( + backgroundColor: Colors.black, + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + backgroundColor: Colors.black, + body: LayoutBuilder( + builder: (context, constraints) { + final double pipW = constraints.maxWidth / 5; + final double pipH = pipW * 9 / 16; + + Future.microtask(() { + if (!mounted) return; + _correctPosition(_leftPosNotifier, constraints, pipW, pipH); + _correctPosition(_rightPosNotifier, constraints, pipW, pipH); + }); + + if (!_isPosInitialized) { + _leftPosNotifier.value = const Offset(20, 80); + _rightPosNotifier.value = Offset(constraints.maxWidth - pipW - 20, 80); + _isPosInitialized = true; + } + + return Stack( + children: [ + Center( + child: GestureDetector( + onDoubleTap: () => setState(() => _isFrontMain = !_isFrontMain), + child: _buildMainViewWithFeathering(), + ), + ), + if (widget.showLeftPip) + _buildFastPip(_leftPosNotifier, Alignment.bottomLeft, constraints, pipW, pipH), + if (widget.showRightPip) + _buildFastPip(_rightPosNotifier, Alignment.bottomRight, constraints, pipW, pipH), + ], + ); + }, ), ); } diff --git a/lib/main.dart b/lib/main.dart index 81d306b5..1b7eeacb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,7 +12,10 @@ import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; import 'core/di/injection.dart'; +import 'core/network/base/i_connection.dart'; import 'features/auth/presentation/bloc/login_cubit.dart'; +import 'features/connectivity/presentation/bloc/connectivity_cubit.dart'; +import 'features/connectivity/presentation/bloc/connectivity_state.dart'; void main() async { // 1. 使用 runZonedGuarded 捕获所有未处理的异步错误 @@ -55,12 +58,60 @@ class MyApp extends StatelessWidget { sl() ..fetchAllDevices(sl().state.user!.username), ), + BlocProvider(create: (_) => sl()), ], child: MaterialApp.router( title: 'Maibu Satabot', theme: AppTheme.lightTheme, routerConfig: sl(), + builder: (context, child) { + return BlocListener( + // 只有当状态真正变为 disconnected 时才弹出 + listenWhen: (prev, curr) => prev.status != curr.status, + listener: (context, state) { + if (state.status == ConnectionStatus.disconnected) { + _showGlobalDisconnectDialog(); + } + }, + child: child, // child 就是当前路由显示的页面内容 + ); + }, ), + ); } + + // 全局断连提示弹窗 + void _showGlobalDisconnectDialog() { + // 直接从全局变量获取真正的 Navigator Context + final context = rootNavigatorKey.currentContext; + + if (context != null) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning, color: Colors.red), + SizedBox(width: 8), + Text("连接已断开"), + ], + ), + content: const Text("与服务器通信丢失,请检查网络连接。"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // 关闭弹窗 + // 如果需要,这里可以执行 context.go('/login'); + }, + child: const Text("知道了"), + ), + ], + ), + ); + } else { + print("DEBUG: 弹窗失败,rootNavigatorKey.currentContext 为空"); + } + } }