播放器修改,功能完善
This commit is contained in:
10
assets/svgs/remote_recognition.svg
Normal file
10
assets/svgs/remote_recognition.svg
Normal 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 |
11
assets/svgs/remote_recognition_off.svg
Normal file
11
assets/svgs/remote_recognition_off.svg
Normal 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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
}
|
||||
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { /* 同原逻辑 */ }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,5 @@ class ConnectivityState extends Equatable{
|
||||
}
|
||||
|
||||
@override
|
||||
// TODO: implement props
|
||||
List<Object?> get props => [activeMode, status];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user