播放器修改,功能完善

This commit is contained in:
CC
2026-01-29 19:16:41 +08:00
parent 949743972c
commit e16625c288
16 changed files with 717 additions and 424 deletions

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g stroke="black" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M3,7V5a2,2 0,0 1,2 -2h2" />
<path d="M17,3h2a2,2 0,0 1,2 2v2" />
<path d="M21,17v2a2,2 0,0 1,-2 2h-2" />
<path d="M7,21H5a2,2 0,0 1,-2 -2v-2" />
<circle cx="12" cy="12" r="1" />
<path d="M18.944,12.33a1,1 0,0 0,0 -0.66a7.5,7.5 0,0 0,-13.888 0a1,1 0,0 0,0 0.66a7.5,7.5 0,0 0,13.888 0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g stroke="black" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M3,7V5a2,2 0,0 1,2 -2h2" />
<path d="M17,3h2a2,2 0,0 1,2 2v2" />
<path d="M21,17v2a2,2 0,0 1,-2 2h-2" />
<path d="M7,21H5a2,2 0,0 1,-2 -2v-2" />
<circle cx="12" cy="12" r="1" />
<path d="M18.944,12.33a1,1 0,0 0,0 -0.66a7.5,7.5 0,0 0,-13.888 0a1,1 0,0 0,0 0.66a7.5,7.5 0,0 0,13.888 0" />
<line x1="22" y1="3" x2="2" y2="22" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View File

@@ -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;
}

View File

@@ -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<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
final sl = GetIt.instance;
Future<void> init() async {
@@ -91,7 +93,7 @@ Future<void> 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<void> init() async {
/// 8. 页面级 Bloc/Cubit (Factory)
sl.registerFactory(() => LoginBloc(sl()));
sl.registerFactory(() => LoginCubit(sl(), sl()));
}

View File

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

View File

@@ -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<void> 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<String, dynamic>? 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<void> _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<void> _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<String, dynamic>? 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<void> _writeToLocalFile(String entry) async {
try {
await _localLogFile?.writeAsString(entry, mode: FileMode.append);
} catch (e) {
// 这里的错误不再抛出,避免日志系统本身崩溃导致主业务中断
}
}
Future<String?> 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<void> _collectDeviceInfo() async { /* 同原逻辑 */ }
}

View File

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

View File

@@ -16,6 +16,7 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
StreamSubscription? _statusSub;
StreamSubscription<PacketEntity>? _packetSub;
Timer? _watchdogTimer;
static const int _watchdogTimerDuration = 10;
// 核心:这是唯一的流入口
Stream<PacketEntity>? _broadcastPacketStream;
@@ -23,12 +24,12 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
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<ConnectivityState> {
emit(state.copyWith(activeMode: mode, status: status));
if (status == ConnectionStatus.connected) {
// 3. 只有连接成功时,才初始化唯一的广播流
// 3. 只有连接成功时,才初始化广播流
_ensureStreamInitialized();
_startMonitoring();
} else {
@@ -54,17 +55,10 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
// 核心:强制初始化,确保 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<ConnectivityState> {
_resetWatchdog();
if (packet.cmdType == 0xFF) {
sendCommand(0xFF, null);
sl<ILoggerService>().log("receive heartbeat,has response...");
sl<ILoggerService>().log("收到心跳包,已回复...");
}
}, onError: (e) => print("流监听错误: $e"));
}, onError: (e) => sl<ILoggerService>().log("流监听错误: $e"));
}
// Getter 也要改,保证它只返回已经创建好的流
@@ -94,8 +88,8 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
// --- 其他逻辑保持不变 ---
void _resetWatchdog() {
_watchdogTimer?.cancel();
_watchdogTimer = Timer(const Duration(seconds: 10), () {
print("警告:10秒内数据,触发断开逻辑");
_watchdogTimer = Timer(const Duration(seconds: _watchdogTimerDuration), () {
sl<ILoggerService>().log("$_watchdogTimerDuration秒内未收到心跳包...");
emit(state.copyWith(status: ConnectionStatus.disconnected));
});
}

View File

@@ -22,6 +22,5 @@ class ConnectivityState extends Equatable{
}
@override
// TODO: implement props
List<Object?> get props => [activeMode, status];
}

View File

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

View File

@@ -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<RemoteControlState> {
final RemoteControlRepository _repository;
final ConnectivityCubit connectivityCubit;
// 定时器与流订阅管理
Timer? _timer;
StreamSubscription<RunningStatusEntity>? _statusSubscription;
StreamSubscription<PacketEntity>? _responseSubscription;
RemoteControlCubit(this._repository)
RemoteControlCubit(this._repository,this.connectivityCubit)
: super(
RemoteControlState(
controlEntity: MachineControlEntity(),
@@ -153,6 +157,32 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
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<ILoggerService>().log("Cubit: 视频地址已置空,准备断开连接");
}
// ==========================================
// 4. 销毁与清理 (Cleanup)
// ==========================================
@@ -164,4 +194,5 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
_responseSubscription?.cancel();
return super.close();
}
}

View File

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

View File

@@ -49,125 +49,153 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
final deviceState = context.watch<DevicesCubit>().state;
final currentDevice = deviceState.selectedDevice;
if (currentDevice == null) {
return _buildOfflineScaffold();
if (currentDevice != null &&
context.read<RemoteControlCubit>().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<RemoteControlCubit>().initVideoUrls(mainUrl, aiUrl);
});
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// 在 Stack 的最底层替换:
Positioned.fill(
child: BlocBuilder<RemoteControlCubit, RemoteControlState>(
// 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里
buildWhen: (p, c) =>
p.showLeftPip != c.showLeftPip ||
p.showRightPip != c.showRightPip,
return PopScope(
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
// 无论怎么退出的,都强制清理视频和控制循环
context.read<RemoteControlCubit>().stopControlLoop();
context.read<RemoteControlCubit>().disposeVideo();
}
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// 在 Stack 的最底层替换:
Positioned.fill(
child: BlocBuilder<RemoteControlCubit, RemoteControlState>(
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<RemoteControlCubit, RemoteControlState>(
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<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: [
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<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) =>
p.showPermissionRequestDialog != c.showPermissionRequestDialog,
builder: (context, state) {
if (state.showPermissionRequestDialog) {
return _buildPermissionDialog(context, state);
}
return const SizedBox.shrink();
},
),
],
// 权限弹窗
BlocBuilder<RemoteControlCubit, RemoteControlState>(
buildWhen: (p, c) =>
p.showPermissionRequestDialog !=
c.showPermissionRequestDialog,
builder: (context, state) {
if (state.showPermissionRequestDialog) {
return _buildPermissionDialog(context, state);
}
return const SizedBox.shrink();
},
),
],
),
),
);
}

View File

@@ -20,12 +20,18 @@ class TopStatusBar extends StatelessWidget {
// 监听局部遥控状态
final remoteState = context.watch<RemoteControlCubit>().state;
var _remoteControlCubit = context.read<RemoteControlCubit>();
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<RemoteControlCubit>().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<RemoteControlCubit>().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,

View File

@@ -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<WebRTCLocalPlayer> {
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<Offset> _leftPosNotifier = ValueNotifier(
const Offset(20, 80),
);
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(
const Offset(200, 80),
);
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(20, 80));
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(200, 80));
bool _isPosInitialized = false;
@override
@@ -45,42 +47,44 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
Future<void> _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<void> _initWebRTCConnection() async {
_peerConnection = await createPeerConnection({
Future<void> _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<WebRTCLocalPlayer> {
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<WebRTCLocalPlayer> {
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<void> _startSecondStream(String url) async {
_secondRenderer = RTCVideoRenderer();
await _secondRenderer!.initialize();
await _initWebRTCConnection(url, _secondRenderer!, false);
}
Future<void> _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<Offset> notifier,
BoxConstraints constraints,
double w,
double h,
) {
void _correctPosition(ValueNotifier<Offset> 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<WebRTCLocalPlayer> {
}
}
// 极致丝滑的局部刷新组件
Widget _buildFastPip(
ValueNotifier<Offset> notifier,
Alignment align,
BoxConstraints constraints,
double w,
double h,
) {
Widget _buildFastPip(ValueNotifier<Offset> notifier, Alignment align, BoxConstraints constraints, double w, double h) {
return ValueListenableBuilder<Offset>(
valueListenable: notifier,
builder: (context, pos, child) {
@@ -295,7 +268,6 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
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<WebRTCLocalPlayer> {
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),
],
);
},
),
);
}

View File

@@ -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<DevicesCubit>()
..fetchAllDevices(sl<AppUserCubit>().state.user!.username),
),
BlocProvider(create: (_) => sl<ConnectivityCubit>()),
],
child: MaterialApp.router(
title: 'Maibu Satabot',
theme: AppTheme.lightTheme,
routerConfig: sl<GoRouter>(),
builder: (context, child) {
return BlocListener<ConnectivityCubit, ConnectivityState>(
// 只有当状态真正变为 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 为空");
}
}
}