播放器修改,功能完善
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 {
|
class TCPConsts {
|
||||||
// static const String TCP_IP = "1.95.137.212";
|
static const String TCP_IP = "1.95.137.212";
|
||||||
static const String TCP_IP = "192.210.172.250";
|
// static const String TCP_IP = "192.210.172.250";
|
||||||
static const int TCP_PORT = 9001;
|
static const int TCP_PORT = 9001;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:maibu_satabot_v2/core/consts/tcp_consts.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/impl/auth_local_datasource_impl.dart';
|
||||||
import '../../features/auth/data/datasources/i_auth_local_datasource.dart';
|
import '../../features/auth/data/datasources/i_auth_local_datasource.dart';
|
||||||
|
|
||||||
|
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
final sl = GetIt.instance;
|
final sl = GetIt.instance;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
@@ -91,7 +93,7 @@ Future<void> init() async {
|
|||||||
/// 5. 状态管理 (Cubit/Bloc)
|
/// 5. 状态管理 (Cubit/Bloc)
|
||||||
sl.registerLazySingleton(() => AppUserCubit(sl())); // AuthCubit 依赖它,必须先注册
|
sl.registerLazySingleton(() => AppUserCubit(sl())); // AuthCubit 依赖它,必须先注册
|
||||||
sl.registerLazySingleton(() => DevicesCubit(sl(), sl()));
|
sl.registerLazySingleton(() => DevicesCubit(sl(), sl()));
|
||||||
sl.registerFactory(() => RemoteControlCubit(sl()));
|
sl.registerFactory(() => RemoteControlCubit(sl(), sl()));
|
||||||
|
|
||||||
/// 6. 认证 (Auth)
|
/// 6. 认证 (Auth)
|
||||||
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
|
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
|
||||||
@@ -109,4 +111,5 @@ Future<void> init() async {
|
|||||||
/// 8. 页面级 Bloc/Cubit (Factory)
|
/// 8. 页面级 Bloc/Cubit (Factory)
|
||||||
sl.registerFactory(() => LoginBloc(sl()));
|
sl.registerFactory(() => LoginBloc(sl()));
|
||||||
sl.registerFactory(() => LoginCubit(sl(), sl()));
|
sl.registerFactory(() => LoginCubit(sl(), sl()));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ class AppBlocObserver extends BlocObserver {
|
|||||||
@override
|
@override
|
||||||
void onChange(BlocBase bloc, Change change) {
|
void onChange(BlocBase bloc, Change change) {
|
||||||
super.onChange(bloc, change);
|
super.onChange(bloc, change);
|
||||||
logger.addBreadcrumb(
|
// 在本地日志记录详细的面包屑
|
||||||
'Bloc State Change',
|
// logger.addBreadcrumb(
|
||||||
category: bloc.runtimeType.toString(),
|
// '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 'dart:io';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -11,15 +11,12 @@ import 'package:intl/intl.dart';
|
|||||||
class SentryLoggerImpl implements ILoggerService {
|
class SentryLoggerImpl implements ILoggerService {
|
||||||
File? _localLogFile;
|
File? _localLogFile;
|
||||||
|
|
||||||
/// --- 临时控制开关 ---
|
/// 控制开关
|
||||||
/// true: 上传到 Sentry + 本地存储
|
bool get _isRemoteDsnEnabled => false;
|
||||||
/// false: 只进行本地存储 + 控制台打印
|
|
||||||
bool get _isRemoteEnabled => false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
// 只有在开启远程上传时才初始化 Sentry SDK
|
if (_isRemoteDsnEnabled) {
|
||||||
if (_isRemoteEnabled) {
|
|
||||||
await SentryFlutter.init((options) {
|
await SentryFlutter.init((options) {
|
||||||
options.dsn = EnvConfig.sentryDsn;
|
options.dsn = EnvConfig.sentryDsn;
|
||||||
options.environment = EnvConfig.environment;
|
options.environment = EnvConfig.environment;
|
||||||
@@ -27,113 +24,97 @@ class SentryLoggerImpl implements ILoggerService {
|
|||||||
});
|
});
|
||||||
await _collectDeviceInfo();
|
await _collectDeviceInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无论是否上传服务器,始终准备本地日志文件
|
|
||||||
await _prepareLocalFile();
|
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 {
|
Future<void> _prepareLocalFile() async {
|
||||||
try {
|
try {
|
||||||
final directory = await getApplicationDocumentsDirectory();
|
final directory = await getApplicationDocumentsDirectory();
|
||||||
final fileName = "${DateFormat('yyyy-MM-dd').format(DateTime.now())}_logs.txt";
|
final fileName = "${DateFormat('yyyy-MM-dd').format(DateTime.now())}_logs.txt";
|
||||||
_localLogFile = File('${directory.path}/$fileName');
|
_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) {
|
} catch (e) {
|
||||||
debugPrint("无法创建本地日志文件: $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 {
|
Future<void> _writeToLocalFile(String entry) async {
|
||||||
try {
|
try {
|
||||||
await _localLogFile?.writeAsString(entry, mode: FileMode.append);
|
// 写入文件时移除 ANSI 颜色字符
|
||||||
} catch (e) {
|
final cleanEntry = entry.replaceAll(RegExp(r'\x1B\[[0-9;]*m'), '');
|
||||||
// 这里的错误不再抛出,避免日志系统本身崩溃导致主业务中断
|
await _localLogFile?.writeAsString(cleanEntry, mode: FileMode.append);
|
||||||
}
|
} catch (_) {}
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> getLocalLogPath() async {
|
|
||||||
return _localLogFile?.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void addBreadcrumb(String message, {String? category}) {
|
void addBreadcrumb(String message, {String? category}) {
|
||||||
// 面包屑主要用于 Sentry 报错时的上下文,如果关闭了远程,记录本地即可(可选)
|
if (_isRemoteDsnEnabled) Sentry.addBreadcrumb(Breadcrumb(message: message, category: category));
|
||||||
if (_isRemoteEnabled) {
|
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: message, category: category));
|
|
||||||
}
|
|
||||||
_writeToLocalFile("[BREADCRUMB] [$category] $message\n");
|
_writeToLocalFile("[BREADCRUMB] [$category] $message\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void setUser(String userId) {
|
void setUser(String userId) {
|
||||||
if (_isRemoteEnabled) {
|
if (_isRemoteDsnEnabled) Sentry.configureScope((scope) => scope.setUser(SentryUser(id: userId)));
|
||||||
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_cubit.dart';
|
||||||
import '../../features/auth/presentation/bloc/auth_state.dart';
|
import '../../features/auth/presentation/bloc/auth_state.dart';
|
||||||
import '../../features/main_container/presentation/main_wrapper.dart';
|
import '../../features/main_container/presentation/main_wrapper.dart';
|
||||||
|
import '../di/injection.dart';
|
||||||
import 'go_router_refresh_stream.dart';
|
import 'go_router_refresh_stream.dart';
|
||||||
|
|
||||||
GoRouter createRouter(AuthCubit authCubit) {
|
GoRouter createRouter(AuthCubit authCubit) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: rootNavigatorKey,
|
||||||
initialLocation: RoutePaths.login,
|
initialLocation: RoutePaths.login,
|
||||||
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
|
|||||||
StreamSubscription? _statusSub;
|
StreamSubscription? _statusSub;
|
||||||
StreamSubscription<PacketEntity>? _packetSub;
|
StreamSubscription<PacketEntity>? _packetSub;
|
||||||
Timer? _watchdogTimer;
|
Timer? _watchdogTimer;
|
||||||
|
static const int _watchdogTimerDuration = 10;
|
||||||
|
|
||||||
// 核心:这是唯一的流入口
|
// 核心:这是唯一的流入口
|
||||||
Stream<PacketEntity>? _broadcastPacketStream;
|
Stream<PacketEntity>? _broadcastPacketStream;
|
||||||
@@ -23,12 +24,12 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
|
|||||||
ConnectivityCubit() : super(ConnectivityState.initial());
|
ConnectivityCubit() : super(ConnectivityState.initial());
|
||||||
|
|
||||||
void setConnection(IConnection? connection, ControlMode mode) {
|
void setConnection(IConnection? connection, ControlMode mode) {
|
||||||
// 1. 彻底清理(按顺序来)
|
// 1. 彻底清理
|
||||||
_stopMonitoring();
|
_stopMonitoring();
|
||||||
_statusSub?.cancel();
|
_statusSub?.cancel();
|
||||||
_activeConnection?.disconnect();
|
_activeConnection?.disconnect();
|
||||||
|
|
||||||
// 2. 关键:切断旧流的所有引用
|
// 2. 切断旧流的所有引用
|
||||||
_broadcastPacketStream = null;
|
_broadcastPacketStream = null;
|
||||||
|
|
||||||
_activeConnection = connection;
|
_activeConnection = connection;
|
||||||
@@ -38,7 +39,7 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
|
|||||||
emit(state.copyWith(activeMode: mode, status: status));
|
emit(state.copyWith(activeMode: mode, status: status));
|
||||||
|
|
||||||
if (status == ConnectionStatus.connected) {
|
if (status == ConnectionStatus.connected) {
|
||||||
// 3. 只有连接成功时,才初始化唯一的广播流
|
// 3. 只有连接成功时,才初始化广播流
|
||||||
_ensureStreamInitialized();
|
_ensureStreamInitialized();
|
||||||
_startMonitoring();
|
_startMonitoring();
|
||||||
} else {
|
} else {
|
||||||
@@ -54,17 +55,10 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
|
|||||||
// 核心:强制初始化,确保 transform 只跑一次
|
// 核心:强制初始化,确保 transform 只跑一次
|
||||||
void _ensureStreamInitialized() {
|
void _ensureStreamInitialized() {
|
||||||
if (_activeConnection != null && _broadcastPacketStream == null) {
|
if (_activeConnection != null && _broadcastPacketStream == null) {
|
||||||
print("DEBUG: 正在创建唯一的广播流转换器");
|
_broadcastPacketStream = _activeConnection!.rawStream
|
||||||
|
.transform(PacketParserTransformer())
|
||||||
// 确保 rawStream 是有效的
|
.asBroadcastStream();
|
||||||
if (_activeConnection!.rawStream != null) {
|
}
|
||||||
_broadcastPacketStream = _activeConnection!.rawStream
|
|
||||||
.transform(PacketParserTransformer())
|
|
||||||
.asBroadcastStream();
|
|
||||||
} else {
|
|
||||||
print("DEBUG: rawStream is null,无法初始化广播流");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -80,9 +74,9 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
|
|||||||
_resetWatchdog();
|
_resetWatchdog();
|
||||||
if (packet.cmdType == 0xFF) {
|
if (packet.cmdType == 0xFF) {
|
||||||
sendCommand(0xFF, null);
|
sendCommand(0xFF, null);
|
||||||
sl<ILoggerService>().log("receive heartbeat,has response...");
|
sl<ILoggerService>().log("收到心跳包,已回复...");
|
||||||
}
|
}
|
||||||
}, onError: (e) => print("流监听错误: $e"));
|
}, onError: (e) => sl<ILoggerService>().log("流监听错误: $e"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getter 也要改,保证它只返回已经创建好的流
|
// Getter 也要改,保证它只返回已经创建好的流
|
||||||
@@ -94,8 +88,8 @@ class ConnectivityCubit extends Cubit<ConnectivityState> {
|
|||||||
// --- 其他逻辑保持不变 ---
|
// --- 其他逻辑保持不变 ---
|
||||||
void _resetWatchdog() {
|
void _resetWatchdog() {
|
||||||
_watchdogTimer?.cancel();
|
_watchdogTimer?.cancel();
|
||||||
_watchdogTimer = Timer(const Duration(seconds: 10), () {
|
_watchdogTimer = Timer(const Duration(seconds: _watchdogTimerDuration), () {
|
||||||
print("警告:10秒内数据,触发断开逻辑");
|
sl<ILoggerService>().log("$_watchdogTimerDuration秒内未收到心跳包...");
|
||||||
emit(state.copyWith(status: ConnectionStatus.disconnected));
|
emit(state.copyWith(status: ConnectionStatus.disconnected));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,5 @@ class ConnectivityState extends Equatable{
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// TODO: implement props
|
|
||||||
List<Object?> get props => [activeMode, status];
|
List<Object?> get props => [activeMode, status];
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ class DeviceEntity extends Equatable {
|
|||||||
final int isBind; // 是否被绑定过
|
final int isBind; // 是否被绑定过
|
||||||
final int onlineStatus; // 在线状态
|
final int onlineStatus; // 在线状态
|
||||||
|
|
||||||
DeviceEntity({
|
const DeviceEntity({
|
||||||
required this.deviceName,
|
required this.deviceName,
|
||||||
required this.productId,
|
required this.productId,
|
||||||
required this.productName,
|
required this.productName,
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
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/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/domain/entities/running_status_entity.dart';
|
||||||
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.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/entities/machine_control_entity.dart';
|
||||||
import '../../domain/repositories/remote_control_repository.dart';
|
import '../../domain/repositories/remote_control_repository.dart';
|
||||||
|
|
||||||
class RemoteControlCubit extends Cubit<RemoteControlState> {
|
class RemoteControlCubit extends Cubit<RemoteControlState> {
|
||||||
final RemoteControlRepository _repository;
|
final RemoteControlRepository _repository;
|
||||||
|
final ConnectivityCubit connectivityCubit;
|
||||||
|
|
||||||
// 定时器与流订阅管理
|
// 定时器与流订阅管理
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
StreamSubscription<RunningStatusEntity>? _statusSubscription;
|
StreamSubscription<RunningStatusEntity>? _statusSubscription;
|
||||||
StreamSubscription<PacketEntity>? _responseSubscription;
|
StreamSubscription<PacketEntity>? _responseSubscription;
|
||||||
|
|
||||||
RemoteControlCubit(this._repository)
|
RemoteControlCubit(this._repository,this.connectivityCubit)
|
||||||
: super(
|
: super(
|
||||||
RemoteControlState(
|
RemoteControlState(
|
||||||
controlEntity: MachineControlEntity(),
|
controlEntity: MachineControlEntity(),
|
||||||
@@ -153,6 +157,32 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
|||||||
void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip));
|
void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip));
|
||||||
void toggleRightPip() => emit(state.copyWith(showRightPip: !state.showRightPip));
|
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)
|
// 4. 销毁与清理 (Cleanup)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -164,4 +194,5 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
|
|||||||
_responseSubscription?.cancel();
|
_responseSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ class RemoteControlState extends Equatable {
|
|||||||
// --- 安全与锁定 ---
|
// --- 安全与锁定 ---
|
||||||
final bool isEmergency; // 紧急停止状态
|
final bool isEmergency; // 紧急停止状态
|
||||||
final bool isLocked; // 控制锁定状态
|
final bool isLocked; // 控制锁定状态
|
||||||
final String obstacleFlag; // 障碍物标志位文字 (对应 Entity 里的 0/1)
|
final String obstacleFlag; // 障碍物标志位文字(这是机器传过来的) (对应 Entity 里的 0/1)
|
||||||
|
|
||||||
// --- 网络与连接 ---
|
// --- 网络与连接 ---
|
||||||
final int ping; // 延迟/心跳
|
final int ping; // 延迟/心跳
|
||||||
@@ -28,6 +28,12 @@ class RemoteControlState extends Equatable {
|
|||||||
// --- UI 视图配置 ---
|
// --- UI 视图配置 ---
|
||||||
final bool showLeftPip; // 左侧画中画显示
|
final bool showLeftPip; // 左侧画中画显示
|
||||||
final bool showRightPip; // 右侧画中画显示
|
final bool showRightPip; // 右侧画中画显示
|
||||||
|
final bool topRightIsExpanded; // 顶部右侧按钮展开状态
|
||||||
|
final bool obstacleRecognitionFlag; // 障碍物识别标志位(这是UI显示的)
|
||||||
|
|
||||||
|
final String mainStreamUrl;
|
||||||
|
final String aiStreamUrl;
|
||||||
|
final bool isVideoEnabled;
|
||||||
|
|
||||||
const RemoteControlState({
|
const RemoteControlState({
|
||||||
this.status = RemoteControlStatus.initial,
|
this.status = RemoteControlStatus.initial,
|
||||||
@@ -44,10 +50,15 @@ class RemoteControlState extends Equatable {
|
|||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.showLeftPip = true,
|
this.showLeftPip = true,
|
||||||
this.showRightPip = 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({
|
RemoteControlState copyWith({
|
||||||
RemoteControlStatus? status,
|
RemoteControlStatus? status,
|
||||||
@@ -64,6 +75,11 @@ class RemoteControlState extends Equatable {
|
|||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
bool? showLeftPip,
|
bool? showLeftPip,
|
||||||
bool? showRightPip,
|
bool? showRightPip,
|
||||||
|
bool? topRightIsExpanded,
|
||||||
|
bool? obstacleRecognitionFlag,
|
||||||
|
bool? isVideoEnabled,
|
||||||
|
String? mainStreamUrl,
|
||||||
|
String? aiStreamUrl
|
||||||
}) {
|
}) {
|
||||||
return RemoteControlState(
|
return RemoteControlState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -80,6 +96,11 @@ class RemoteControlState extends Equatable {
|
|||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
showLeftPip: showLeftPip ?? this.showLeftPip,
|
showLeftPip: showLeftPip ?? this.showLeftPip,
|
||||||
showRightPip: showRightPip ?? this.showRightPip,
|
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,
|
errorMessage,
|
||||||
showLeftPip,
|
showLeftPip,
|
||||||
showRightPip,
|
showRightPip,
|
||||||
|
topRightIsExpanded,
|
||||||
|
obstacleRecognitionFlag,
|
||||||
|
isVideoEnabled,
|
||||||
|
mainStreamUrl,
|
||||||
|
aiStreamUrl
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -49,125 +49,153 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
|||||||
final deviceState = context.watch<DevicesCubit>().state;
|
final deviceState = context.watch<DevicesCubit>().state;
|
||||||
final currentDevice = deviceState.selectedDevice;
|
final currentDevice = deviceState.selectedDevice;
|
||||||
|
|
||||||
if (currentDevice == null) {
|
if (currentDevice != null &&
|
||||||
return _buildOfflineScaffold();
|
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(
|
return PopScope(
|
||||||
backgroundColor: Colors.black,
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
body: Stack(
|
if (didPop) {
|
||||||
children: [
|
// 无论怎么退出的,都强制清理视频和控制循环
|
||||||
// 在 Stack 的最底层替换:
|
context.read<RemoteControlCubit>().stopControlLoop();
|
||||||
Positioned.fill(
|
context.read<RemoteControlCubit>().disposeVideo();
|
||||||
child: BlocBuilder<RemoteControlCubit, RemoteControlState>(
|
}
|
||||||
// 只有当显示隐藏状态改变时才重构,内部的拖拽由组件自身 State 处理,不影响这里
|
},
|
||||||
buildWhen: (p, c) =>
|
child: Scaffold(
|
||||||
p.showLeftPip != c.showLeftPip ||
|
backgroundColor: Colors.black,
|
||||||
p.showRightPip != c.showRightPip,
|
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) {
|
builder: (context, state) {
|
||||||
return WebRTCLocalPlayer(
|
return state.isEmergency
|
||||||
// 这里的 URL 拼接根据你的后端规则
|
? const Positioned.fill(child: EmergencyOverlay())
|
||||||
// streamUrl: "webrtc://${TCPConsts.TCP_IP}/live/livestream/${currentDevice.deviceName}?token=${userState.user!.token}",
|
: const SizedBox.shrink();
|
||||||
streamUrl:
|
|
||||||
"webrtc://${TCPConsts.TCP_IP}/live/livestream/111?token=${userState.user!.token}",
|
|
||||||
showLeftPip: state.showLeftPip, // 从 Cubit 状态中读取
|
|
||||||
showRightPip: state.showRightPip, // 从 Cubit 状态中读取
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
BlocBuilder<RemoteControlCubit, RemoteControlState>(
|
SafeArea(
|
||||||
buildWhen: (p, c) => p.isEmergency != c.isEmergency,
|
child: Column(
|
||||||
builder: (context, state) {
|
children: [
|
||||||
return state.isEmergency
|
const TopStatusBar(),
|
||||||
? const Positioned.fill(child: EmergencyOverlay())
|
Expanded(
|
||||||
: const SizedBox.shrink();
|
child: LayoutBuilder(
|
||||||
},
|
builder: (context, constraints) {
|
||||||
),
|
final double sideAreaWidth =
|
||||||
|
constraints.maxWidth * 0.25;
|
||||||
|
final double centerAreaWidth =
|
||||||
|
constraints.maxWidth * 0.5;
|
||||||
|
|
||||||
SafeArea(
|
// 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page
|
||||||
child: Column(
|
return BlocBuilder<
|
||||||
children: [
|
RemoteControlCubit,
|
||||||
const TopStatusBar(),
|
RemoteControlState
|
||||||
Expanded(
|
>(
|
||||||
child: LayoutBuilder(
|
// 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理
|
||||||
builder: (context, constraints) {
|
buildWhen: (p, c) => p.isLocked != c.isLocked,
|
||||||
final double sideAreaWidth = constraints.maxWidth * 0.25;
|
builder: (context, remoteState) {
|
||||||
final double centerAreaWidth = constraints.maxWidth * 0.5;
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
// 关键:使用 BlocBuilder 局部刷新摇杆,不要 watch 整个 Page
|
child: Row(
|
||||||
return BlocBuilder<
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
RemoteControlCubit,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
RemoteControlState
|
children: [
|
||||||
>(
|
// 左摇杆
|
||||||
// 只有 isLocked 改变时才重构摇杆区域,摇杆坐标改变由内部处理
|
SizedBox(
|
||||||
buildWhen: (p, c) => p.isLocked != c.isLocked,
|
width: sideAreaWidth,
|
||||||
builder: (context, remoteState) {
|
child: Align(
|
||||||
return Padding(
|
alignment: Alignment.bottomCenter,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
child: LeftJoystickArea(
|
||||||
child: Row(
|
isLocked: remoteState.isLocked,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
width: sideAreaWidth * 0.5 * 0.7,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
),
|
||||||
children: [
|
|
||||||
// 左摇杆
|
|
||||||
SizedBox(
|
|
||||||
width: sideAreaWidth,
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
child: LeftJoystickArea(
|
|
||||||
isLocked: remoteState.isLocked,
|
|
||||||
width: sideAreaWidth * 0.5 * 0.7,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// 中间区
|
// 中间区
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: centerAreaWidth,
|
width: centerAreaWidth,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: CenterControlArea(
|
child: CenterControlArea(
|
||||||
totalWidth: centerAreaWidth,
|
totalWidth: centerAreaWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// 右摇杆
|
// 右摇杆
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: sideAreaWidth,
|
width: sideAreaWidth,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: RightJoystickArea(
|
child: RightJoystickArea(
|
||||||
isLocked: remoteState.isLocked,
|
isLocked: remoteState.isLocked,
|
||||||
width: sideAreaWidth * 0.5 * 0.7,
|
width: sideAreaWidth * 0.5 * 0.7,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// 权限弹窗
|
// 权限弹窗
|
||||||
BlocBuilder<RemoteControlCubit, RemoteControlState>(
|
BlocBuilder<RemoteControlCubit, RemoteControlState>(
|
||||||
buildWhen: (p, c) =>
|
buildWhen: (p, c) =>
|
||||||
p.showPermissionRequestDialog != c.showPermissionRequestDialog,
|
p.showPermissionRequestDialog !=
|
||||||
builder: (context, state) {
|
c.showPermissionRequestDialog,
|
||||||
if (state.showPermissionRequestDialog) {
|
builder: (context, state) {
|
||||||
return _buildPermissionDialog(context, state);
|
if (state.showPermissionRequestDialog) {
|
||||||
}
|
return _buildPermissionDialog(context, state);
|
||||||
return const SizedBox.shrink();
|
}
|
||||||
},
|
return const SizedBox.shrink();
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ class TopStatusBar extends StatelessWidget {
|
|||||||
// 监听局部遥控状态
|
// 监听局部遥控状态
|
||||||
final remoteState = context.watch<RemoteControlCubit>().state;
|
final remoteState = context.watch<RemoteControlCubit>().state;
|
||||||
|
|
||||||
|
var _remoteControlCubit = context.read<RemoteControlCubit>();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// 1. 返回按钮 (对应 SimpleSmallFunctionButton)
|
// 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),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
// 2. 控制状态 (对应 StatusChipLeft)
|
// 2. 控制状态 (对应 StatusChipLeft)
|
||||||
@@ -39,7 +45,7 @@ class TopStatusBar extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
if (!remoteState.hasPermission) {
|
if (!remoteState.hasPermission) {
|
||||||
// 弹出请求权限对话框逻辑
|
// 弹出请求权限对话框逻辑
|
||||||
context.read<RemoteControlCubit>().togglePermissionDialog(true);
|
_remoteControlCubit.togglePermissionDialog(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -50,7 +56,7 @@ class TopStatusBar extends StatelessWidget {
|
|||||||
remoteState.isLocked
|
remoteState.isLocked
|
||||||
? "assets/svgs/remote_lock.svg"
|
? "assets/svgs/remote_lock.svg"
|
||||||
: "assets/svgs/remote_lock_open.svg",
|
: "assets/svgs/remote_lock_open.svg",
|
||||||
() => context.read<RemoteControlCubit>().toggleLock(),
|
() => _remoteControlCubit.toggleLock(),
|
||||||
isSelected: remoteState.isLocked,
|
isSelected: remoteState.isLocked,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -60,16 +66,65 @@ class TopStatusBar extends StatelessWidget {
|
|||||||
|
|
||||||
const Spacer(),
|
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)
|
// 5. 信号延迟 (对应 pingStatusChip)
|
||||||
_buildPingChip(remoteState.ping),
|
// _buildPingChip(remoteState.ping),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// 6. 电量 (对应 StatusChipRight)
|
// 6. 电量 (对应 StatusChipRight)
|
||||||
@@ -80,7 +135,8 @@ class TopStatusBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIconButton(
|
Widget _buildIconButton(
|
||||||
String svgAsset, // 改为 String 路径,例如 'assets/icons/stop.svg'
|
String svgAsset,
|
||||||
|
// 改为 String 路径,例如 'assets/icons/stop.svg'
|
||||||
VoidCallback onTap, {
|
VoidCallback onTap, {
|
||||||
bool isSelected = false,
|
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) {
|
Widget _buildPingChip(int ping) {
|
||||||
Color color = ping < 100
|
Color color = ping < 100
|
||||||
? Colors.green
|
? Colors.green
|
||||||
@@ -132,12 +272,12 @@ class TopStatusBar extends StatelessWidget {
|
|||||||
displayText = 'TCP模式';
|
displayText = 'TCP模式';
|
||||||
break;
|
break;
|
||||||
case ControlMode.NONE:
|
case ControlMode.NONE:
|
||||||
displayText = '无模式';
|
displayText = '无';
|
||||||
break;
|
break;
|
||||||
case ControlMode.OTHER:
|
case ControlMode.OTHER:
|
||||||
displayText = '其他模式';
|
displayText = '其他模式';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return StatusChip(
|
return StatusChip(
|
||||||
text: displayText,
|
text: displayText,
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
class WebRTCLocalPlayer extends StatefulWidget {
|
class WebRTCLocalPlayer extends StatefulWidget {
|
||||||
final String streamUrl;
|
final String streamUrl; // 第一个流(四分屏)
|
||||||
|
final String? secondStreamUrl; // 第二条完整流,可选
|
||||||
final bool showLeftPip;
|
final bool showLeftPip;
|
||||||
final bool showRightPip;
|
final bool showRightPip;
|
||||||
|
final bool obstacleRecognitionFlag;
|
||||||
|
|
||||||
const WebRTCLocalPlayer({
|
const WebRTCLocalPlayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.streamUrl,
|
required this.streamUrl,
|
||||||
|
this.secondStreamUrl,
|
||||||
this.showLeftPip = true,
|
this.showLeftPip = true,
|
||||||
this.showRightPip = true,
|
this.showRightPip = true,
|
||||||
|
this.obstacleRecognitionFlag = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,19 +25,17 @@ class WebRTCLocalPlayer extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||||
final RTCVideoRenderer _renderer = RTCVideoRenderer();
|
final RTCVideoRenderer _renderer = RTCVideoRenderer(); // 第一个流(四分屏)
|
||||||
|
RTCVideoRenderer? _secondRenderer; // 第二条完整流
|
||||||
|
|
||||||
RTCPeerConnection? _peerConnection;
|
RTCPeerConnection? _peerConnection;
|
||||||
|
RTCPeerConnection? _secondPeerConnection;
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _isFrontMain = true;
|
bool _isFrontMain = true; // true=前,false=后
|
||||||
|
|
||||||
// 使用 ValueNotifier 配合局部刷新,提升拖拽性能
|
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(20, 80));
|
||||||
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(
|
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(200, 80));
|
||||||
const Offset(20, 80),
|
|
||||||
);
|
|
||||||
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(
|
|
||||||
const Offset(200, 80),
|
|
||||||
);
|
|
||||||
bool _isPosInitialized = false;
|
bool _isPosInitialized = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -45,42 +47,44 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
|||||||
Future<void> _prepareAndConnect() async {
|
Future<void> _prepareAndConnect() async {
|
||||||
try {
|
try {
|
||||||
await _renderer.initialize();
|
await _renderer.initialize();
|
||||||
if (!mounted) return;
|
await _initWebRTCConnection(widget.streamUrl, _renderer, true);
|
||||||
await _initWebRTCConnection();
|
|
||||||
|
if (widget.obstacleRecognitionFlag && widget.secondStreamUrl != null) {
|
||||||
|
await _startSecondStream(widget.secondStreamUrl!);
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _isInitialized = true);
|
setState(() => _isInitialized = true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("初始化失败: $e");
|
debugPrint("初始化失败: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initWebRTCConnection() async {
|
Future<void> _initWebRTCConnection(
|
||||||
_peerConnection = await createPeerConnection({
|
String url, RTCVideoRenderer renderer, bool isMain) async {
|
||||||
|
final pc = await createPeerConnection({
|
||||||
"sdpSemantics": "unified-plan",
|
"sdpSemantics": "unified-plan",
|
||||||
"iceServers": [
|
"iceServers": [
|
||||||
{"urls": "stun:stun.l.google.com:19302"},
|
{"urls": "stun:stun.l.google.com:19302"},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await _peerConnection!.addTransceiver(
|
// 只接收视频
|
||||||
|
await pc.addTransceiver(
|
||||||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||||
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
||||||
);
|
);
|
||||||
|
|
||||||
_peerConnection!.onTrack = (RTCTrackEvent event) {
|
pc.onTrack = (RTCTrackEvent event) {
|
||||||
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
|
if (event.streams.isNotEmpty && event.track.kind == 'video') {
|
||||||
if (mounted) setState(() => _renderer.srcObject = event.streams[0]);
|
if (mounted) setState(() => renderer.srcObject = event.streams[0]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
RTCSessionDescription offer = await _peerConnection!.createOffer({
|
RTCSessionDescription offer = await pc.createOffer({'offerToReceiveVideo': 1});
|
||||||
'offerToReceiveVideo': 1,
|
await pc.setLocalDescription(offer);
|
||||||
});
|
|
||||||
await _peerConnection!.setLocalDescription(offer);
|
|
||||||
|
|
||||||
Uri uri = Uri.parse(
|
Uri uri = Uri.parse(url.replaceFirst('webrtc://', 'http://'));
|
||||||
widget.streamUrl.replaceFirst('webrtc://', 'http://'),
|
|
||||||
);
|
|
||||||
String apiUrl = "http://${uri.host}:1985/rtc/v1/play/";
|
String apiUrl = "http://${uri.host}:1985/rtc/v1/play/";
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
@@ -88,7 +92,7 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
"api": apiUrl,
|
"api": apiUrl,
|
||||||
"streamurl": widget.streamUrl,
|
"streamurl": url,
|
||||||
"clientip": null,
|
"clientip": null,
|
||||||
"sdp": offer.sdp,
|
"sdp": offer.sdp,
|
||||||
}),
|
}),
|
||||||
@@ -96,179 +100,155 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
|||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
await _peerConnection!.setRemoteDescription(
|
await pc.setRemoteDescription(
|
||||||
RTCSessionDescription(data['sdp'], 'answer'),
|
RTCSessionDescription(data['sdp'], 'answer'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("信令错误: $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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_peerConnection?.close();
|
||||||
_peerConnection?.dispose();
|
_peerConnection?.dispose();
|
||||||
|
_secondPeerConnection?.close();
|
||||||
|
_secondPeerConnection?.dispose();
|
||||||
|
|
||||||
_renderer.srcObject = null;
|
_renderer.srcObject = null;
|
||||||
_renderer.dispose();
|
_renderer.dispose();
|
||||||
|
_secondRenderer?.srcObject = null;
|
||||||
|
_secondRenderer?.dispose();
|
||||||
|
|
||||||
_leftPosNotifier.dispose();
|
_leftPosNotifier.dispose();
|
||||||
_rightPosNotifier.dispose();
|
_rightPosNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 四分屏裁剪视图
|
// -------------------- 渲染逻辑 --------------------
|
||||||
Widget _buildQuadrantView({required Alignment alignment}) {
|
|
||||||
if (!_isInitialized || _renderer.srcObject == null)
|
|
||||||
return Container(color: Colors.black);
|
|
||||||
|
|
||||||
return RepaintBoundary(
|
RTCVideoRenderer getFrontRenderer() {
|
||||||
child: ClipRect(
|
if (widget.obstacleRecognitionFlag && _secondRenderer != null) {
|
||||||
child: FractionallySizedBox(
|
return _secondRenderer!;
|
||||||
widthFactor: 2.0,
|
}
|
||||||
heightFactor: 2.0,
|
return _renderer;
|
||||||
alignment: alignment,
|
}
|
||||||
child: RTCVideoView(
|
|
||||||
_renderer,
|
RTCVideoRenderer getBackRenderer() => _renderer;
|
||||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
RTCVideoRenderer getLeftRenderer() => _renderer;
|
||||||
mirror: false,
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned.fill(
|
||||||
);
|
child: BackdropFilter(
|
||||||
}
|
filter: ImageFilter.blur(sigmaX: 45, sigmaY: 45),
|
||||||
|
child: Container(color: Colors.black.withOpacity(0.55)),
|
||||||
// 主视角的矩形边缘羽化效果
|
),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
// 中心主视角 + 羽化
|
||||||
|
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
|
void _correctPosition(ValueNotifier<Offset> notifier, BoxConstraints constraints, double w, double h) {
|
||||||
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,
|
|
||||||
) {
|
|
||||||
double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity);
|
double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity);
|
||||||
double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity);
|
double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity);
|
||||||
double newX = notifier.value.dx.clamp(0.0, maxX);
|
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>(
|
return ValueListenableBuilder<Offset>(
|
||||||
valueListenable: notifier,
|
valueListenable: notifier,
|
||||||
builder: (context, pos, child) {
|
builder: (context, pos, child) {
|
||||||
@@ -295,7 +268,6 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onPanUpdate: (details) {
|
onPanUpdate: (details) {
|
||||||
// 关键修正:使用 notifier.value.dx 替换 pos.dx,消除位移丢失导致的阻尼感
|
|
||||||
final double nextX = (notifier.value.dx + details.delta.dx).clamp(
|
final double nextX = (notifier.value.dx + details.delta.dx).clamp(
|
||||||
0.0,
|
0.0,
|
||||||
(constraints.maxWidth - w).clamp(0.0, double.infinity),
|
(constraints.maxWidth - w).clamp(0.0, double.infinity),
|
||||||
@@ -315,16 +287,57 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
|||||||
height: h,
|
height: h,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
boxShadow: [
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.5), blurRadius: 20, offset: const Offset(0, 8))],
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.5),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
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 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
|
||||||
|
|
||||||
import 'core/di/injection.dart';
|
import 'core/di/injection.dart';
|
||||||
|
import 'core/network/base/i_connection.dart';
|
||||||
import 'features/auth/presentation/bloc/login_cubit.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 {
|
void main() async {
|
||||||
// 1. 使用 runZonedGuarded 捕获所有未处理的异步错误
|
// 1. 使用 runZonedGuarded 捕获所有未处理的异步错误
|
||||||
@@ -55,12 +58,60 @@ class MyApp extends StatelessWidget {
|
|||||||
sl<DevicesCubit>()
|
sl<DevicesCubit>()
|
||||||
..fetchAllDevices(sl<AppUserCubit>().state.user!.username),
|
..fetchAllDevices(sl<AppUserCubit>().state.user!.username),
|
||||||
),
|
),
|
||||||
|
BlocProvider(create: (_) => sl<ConnectivityCubit>()),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
title: 'Maibu Satabot',
|
title: 'Maibu Satabot',
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
routerConfig: sl<GoRouter>(),
|
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