Commit d697b832 authored by akari's avatar akari
parents e21c3fc0 29eea2f6
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/services/monitoring_service.dart';
import 'package:laki_icu_app/services/p2p_video_service.dart';
import 'package:laki_icu_app/services/webrtc_service.dart';
import 'monitoring_index_state.dart';
/// 设备凭据 —— Mock 阶段使用占位值,后续接口接入后替换
/// TODO: 从舱详情接口获取真实的 cameraSn 和 wifiPwd
class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
final MonitoringService _monitoringService;
final WebrtcService _webrtcService;
final P2pVideoService _p2pVideoService;
StreamSubscription<WebrtcConnectionState>? _webrtcStateSub;
StreamSubscription<P2pServiceState>? _p2pStateSub;
/// 连接代际计数器 —— 每次切换模式时 +1,
/// 防止旧连接的异步结果污染当前模式的状态。
int _switchGen = 0;
MonitoringIndexCubit()
: _monitoringService = MonitoringService(),
_webrtcService = WebrtcService(),
_p2pVideoService = P2pVideoService(),
super(const MonitoringIndexState()) {
_listenWebrtcState();
_listenP2pState();
loadData();
}
/// 监听 WebRTC 连接状态变化并同步到 State
void _listenWebrtcState() {
_webrtcStateSub = _webrtcService.connectionStateStream.listen((state) {
if (isClosed) return;
emit(this.state.copyWith(videoConnectionState: state));
});
}
/// 监听 P2P 视频服务状态变化并同步到 State
void _listenP2pState() {
_p2pStateSub = _p2pVideoService.stateStream.listen((state) {
if (isClosed) return;
emit(this.state.copyWith(
isP2pConnected: state == P2pServiceState.connected,
));
});
}
Future<void> loadData() async {
emit(state.copyWith(
status: MonitoringIndexStatus.loading,
......@@ -38,6 +78,9 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
menuItems: menuItems,
error: null,
));
// 数据加载完成后,按当前模式初始化视频连接
_initVideoByMode();
} catch (e) {
if (isClosed) return;
emit(state.copyWith(
......@@ -47,4 +90,132 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
));
}
}
/// 依据当前 [VideoStreamMode] 启动对应的视频连接
void _initVideoByMode() {
if (state.videoStreamMode == VideoStreamMode.p2p) {
_initP2p();
} else {
_initWebrtc();
}
}
/// 初始化 WebRTC 视频连接
Future<void> _initWebrtc() async {
await _webrtcService.connect(
deviceNo: 'VE10085871QOXG',
password: '143548',
);
}
/// 手动重试 WebRTC
Future<void> retryWebrtc() async {
await _initWebrtc();
}
/// 初始化 P2P 视频连接
Future<void> _initP2p() async {
await _p2pVideoService.connect(
deviceId: 'VE10085871QOXG',
username: 'admin',
password: '143548',
);
}
// ==================== 模式切换 ====================
/// 切换视频传输模式
///
/// 先停止当前模式 → 更新状态 → 启动新模式。
/// 使用 [_switchGen] 代际计数器防止旧连接的异步结果污染当前状态。
Future<void> switchStreamMode(VideoStreamMode mode) async {
if (state.isSwitchingMode) return;
if (state.videoStreamMode == mode) return;
final currentGen = ++_switchGen;
debugPrint(
'[MonitoringIndexCubit] 切换模式: ${state.videoStreamMode}$mode (gen=$currentGen)');
// 1. 标记切换中
emit(state.copyWith(isSwitchingMode: true));
// 2. 停止当前模式(用 stop 停连接,保留 StreamController 可复用)
if (state.videoStreamMode == VideoStreamMode.p2p) {
await _p2pVideoService.stop();
} else {
await _webrtcService.stop();
}
// 3. 竞态检查:如果在清理期间又触发了一次切换则放弃本次
if (currentGen != _switchGen || isClosed) return;
// 4. 切换到新模式
emit(state.copyWith(
videoStreamMode: mode,
isSwitchingMode: false,
isP2pConnected: false,
videoConnectionState: WebrtcConnectionState.disconnected,
));
// 5. 启动新模式视频连接
if (mode == VideoStreamMode.p2p) {
await _initP2p();
} else {
await _initWebrtc();
}
}
/// 手动重试当前模式的视频连接
Future<void> retryCurrentMode() async {
if (state.videoStreamMode == VideoStreamMode.p2p) {
await _p2pVideoService.stop();
await _initP2p();
} else {
await _webrtcService.stop();
await _initWebrtc();
}
}
Future<void> refreshData() async {
try {
final metrics = await _monitoringService.getMetrics();
if (isClosed) return;
final patientInfo = await _monitoringService.getPatientInfo();
if (isClosed) return;
final alerts = await _monitoringService.getAlerts();
if (isClosed) return;
final menuItems = await _monitoringService.getMenuItems();
if (isClosed) return;
emit(state.copyWith(
status: MonitoringIndexStatus.success,
metrics: metrics,
patientInfo: patientInfo,
alerts: alerts,
menuItems: menuItems,
error: null,
));
} catch (e) {
if (isClosed) return;
emit(state.copyWith(
status: MonitoringIndexStatus.failure,
error: e.toString(),
));
}
}
/// 获取 WebRTC 渲染器供 UI 层使用
WebrtcService get webrtcService => _webrtcService;
/// 获取 P2P 视频播放控制器供 UI 层使用
P2pVideoService get p2pVideoService => _p2pVideoService;
@override
Future<void> close() {
_webrtcStateSub?.cancel();
_p2pStateSub?.cancel();
_webrtcService.dispose();
_p2pVideoService.dispose();
return super.close();
}
}
import 'package:equatable/equatable.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/models/bo/monitoring_bo.dart';
import 'package:laki_icu_app/services/webrtc_service.dart';
enum MonitoringIndexStatus {
initial,
......@@ -17,6 +19,18 @@ class MonitoringIndexState extends Equatable {
final List<AlertInfoBO> alerts;
final List<MonitoringMenuItemBO> menuItems;
/// WebRTC 视频连接状态
final WebrtcConnectionState videoConnectionState;
/// 当前视频传输模式
final VideoStreamMode videoStreamMode;
/// 是否正在切换传输模式(用于 UI loading 状态)
final bool isSwitchingMode;
/// P2P 模式是否已连接
final bool isP2pConnected;
const MonitoringIndexState({
this.status = MonitoringIndexStatus.initial,
this.isLoading = false,
......@@ -25,6 +39,10 @@ class MonitoringIndexState extends Equatable {
this.patientInfo,
this.alerts = const [],
this.menuItems = const [],
this.videoConnectionState = WebrtcConnectionState.disconnected,
this.videoStreamMode = VideoStreamMode.p2p,
this.isSwitchingMode = false,
this.isP2pConnected = false,
});
MonitoringIndexState copyWith({
......@@ -35,6 +53,10 @@ class MonitoringIndexState extends Equatable {
PatientInfoBO? patientInfo,
List<AlertInfoBO>? alerts,
List<MonitoringMenuItemBO>? menuItems,
WebrtcConnectionState? videoConnectionState,
VideoStreamMode? videoStreamMode,
bool? isSwitchingMode,
bool? isP2pConnected,
}) {
return MonitoringIndexState(
status: status ?? this.status,
......@@ -44,6 +66,11 @@ class MonitoringIndexState extends Equatable {
patientInfo: patientInfo ?? this.patientInfo,
alerts: alerts ?? this.alerts,
menuItems: menuItems ?? this.menuItems,
videoConnectionState:
videoConnectionState ?? this.videoConnectionState,
videoStreamMode: videoStreamMode ?? this.videoStreamMode,
isSwitchingMode: isSwitchingMode ?? this.isSwitchingMode,
isP2pConnected: isP2pConnected ?? this.isP2pConnected,
);
}
......@@ -56,5 +83,9 @@ class MonitoringIndexState extends Equatable {
patientInfo,
alerts,
menuItems,
videoConnectionState,
videoStreamMode,
isSwitchingMode,
isP2pConnected,
];
}
......@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/models/bo/monitoring_bo.dart';
import 'cubit/monitoring_index_cubit.dart';
......@@ -11,7 +12,7 @@ import 'widgets/monitoring_bottom_menu.dart';
import 'widgets/monitoring_metric_card.dart';
import 'widgets/monitoring_pet_info_card.dart';
import 'widgets/monitoring_top_status_bar.dart';
import 'widgets/monitoring_video_card.dart';
import 'widgets/video_player_widget.dart';
@RoutePage()
class MonitoringIndexView extends StatelessWidget {
......@@ -157,7 +158,18 @@ class _MonitoringIndexContentState extends State<MonitoringIndexContent> {
SizedBox(width: gap),
Expanded(
flex: 10,
child: const MonitoringVideoCard(),
child: VideoPlayerWidget(
streamMode: state.videoStreamMode,
isSwitchingMode: state.isSwitchingMode,
webrtcRenderer:
_monitoringIndexCubit.webrtcService.renderer,
webrtcConnectionState: state.videoConnectionState,
p2pController: _monitoringIndexCubit
.p2pVideoService.playerController,
isP2pConnected: state.isP2pConnected,
onRetry: () => _monitoringIndexCubit.retryCurrentMode(),
onToggleMode: _onToggleStreamMode,
),
),
],
),
......@@ -191,6 +203,16 @@ class _MonitoringIndexContentState extends State<MonitoringIndexContent> {
);
}
/// 模式切换回调
void _onToggleStreamMode() {
final cubit = _monitoringIndexCubit;
final currentMode = cubit.state.videoStreamMode;
final nextMode = currentMode == VideoStreamMode.p2p
? VideoStreamMode.webrtc
: VideoStreamMode.p2p;
cubit.switchStreamMode(nextMode);
}
List<MonitoringMetricBO> _environmentMetrics(
List<MonitoringMetricBO> metrics) {
final matched = metrics
......
......@@ -107,7 +107,7 @@ class MonitoringMetricCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"设定24度",
"设定24"+metric.unit,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
......
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:vsdk/app_player.dart';
import '../../../../enums/video_stream_mode_enum.dart';
/// 通用视频全屏播放页面(兼容 WebRTC + P2P)
///
/// 全屏展示摄像头实时画面,点击任意位置退出全屏。
///
/// 涉及页面:监控页 - 监控画面
class VideoFullscreenPage extends StatelessWidget {
/// 当前视频传输模式
final VideoStreamMode mode;
/// WebRTC 模式下的视频渲染器
final RTCVideoRenderer? webrtcRenderer;
/// P2P 模式下的播放控制器
final AppPlayerController? p2pController;
const VideoFullscreenPage({
super.key,
required this.mode,
this.webrtcRenderer,
this.p2pController,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Stack(
fit: StackFit.expand,
children: [
// 根据模式渲染不同的视频画面
_buildVideoContent(),
// 底部提示栏
_buildBottomBar(),
],
),
),
);
}
/// 按模式渲染视频内容
Widget _buildVideoContent() {
if (mode == VideoStreamMode.p2p && p2pController != null) {
return AppPlayerView(controller: p2pController!);
}
if (mode == VideoStreamMode.webrtc && webrtcRenderer != null) {
return RTCVideoView(
webrtcRenderer!,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
);
}
return const SizedBox.shrink();
}
/// 底部提示栏(左下角)
Widget _buildBottomBar() {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 48),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black54, Colors.transparent],
),
),
child: const Row(
children: [
Icon(Icons.fullscreen_exit, color: Colors.white70, size: 20),
SizedBox(width: 8),
Text(
'点击任意位置退出全屏',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:vsdk/app_player.dart';
import '../../../../enums/video_stream_mode_enum.dart';
import '../../../../services/webrtc_service.dart';
import 'video_fullscreen_page.dart';
import 'video_stream_switch.dart';
/// 通用实时视频播放组件(兼容 WebRTC + P2P 双模式)
///
/// 职责:
/// - WebRTC 模式:通过 [RTCVideoView] 渲染远程摄像头实时画面
/// - P2P 模式:通过 [AppPlayerView] 渲染 Texture 视频画面
/// - 依据当前模式的连接状态显示对应的状态指示器
/// - 右下角提供模式切换按钮(P2P/RTC)和全屏按钮
///
/// 涉及页面:监控页 - 监控画面
class VideoPlayerWidget extends StatelessWidget {
/// 当前视频传输模式
final VideoStreamMode streamMode;
/// 是否正在切换模式
final bool isSwitchingMode;
/// WebRTC 视频渲染器(WebRTC 模式时使用)
final RTCVideoRenderer? webrtcRenderer;
/// WebRTC 连接状态
final WebrtcConnectionState webrtcConnectionState;
/// P2P 视频播放控制器(P2P 模式时使用)
final AppPlayerController? p2pController;
/// P2P 模式是否已连接
final bool isP2pConnected;
/// 连接失败时点击重试回调
final VoidCallback? onRetry;
/// 模式切换回调
final VoidCallback? onToggleMode;
const VideoPlayerWidget({
super.key,
required this.streamMode,
this.isSwitchingMode = false,
this.webrtcRenderer,
required this.webrtcConnectionState,
this.p2pController,
this.isP2pConnected = false,
this.onRetry,
this.onToggleMode,
});
/// 当前模式视频是否正在显示
bool get _isVideoShowing {
if (streamMode == VideoStreamMode.webrtc) {
return webrtcConnectionState == WebrtcConnectionState.connected;
}
return isP2pConnected && p2pController != null;
}
@override
Widget build(BuildContext context) {
return ClipRRect(
child: Container(
padding: EdgeInsets.only(top: 15.h,bottom: 35.h,left: 18.w,right: 18.w),
decoration: BoxDecoration(
image: const DecorationImage(
image: AssetImage('lib/assets/monitoring/box_bg_674x509.png'),
fit: BoxFit.fill,
),
border: Border.all(color: Colors.red, width: 1),
),
child: Stack(
fit: StackFit.expand,
children: [
// 视频画面层
_buildVideoLayer(),
// 状态指示层
_buildStatusOverlay(),
// 模式切换按钮(右下角倒数第二个位置)
if (onToggleMode != null && !isSwitchingMode)
_buildToggleModeButton(),
// 全屏按钮(右下角最后一个位置)
if (_isVideoShowing) _buildFullscreenButton(context),
],
),
),
);
}
// ==================== 视频画面层 ====================
/// 视频画面
Widget _buildVideoLayer() {
if (streamMode == VideoStreamMode.webrtc) {
if (webrtcConnectionState == WebrtcConnectionState.connected &&
webrtcRenderer != null) {
return RTCVideoView(
webrtcRenderer!,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
);
}
return _buildPlaceholder();
}
// P2P 模式
if (isP2pConnected && p2pController != null) {
return AppPlayerView(controller: p2pController!);
}
return _buildPlaceholder();
}
// ==================== 状态遮罩层 ====================
/// 状态遮罩层
Widget _buildStatusOverlay() {
if (isSwitchingMode) {
return _buildSwitchingOverlay();
}
if (streamMode == VideoStreamMode.webrtc) {
switch (webrtcConnectionState) {
case WebrtcConnectionState.connecting:
return _buildLoadingOverlay('正在连接摄像头...');
case WebrtcConnectionState.reconnecting:
return _buildLoadingOverlay('正在重新连接...');
case WebrtcConnectionState.failed:
return _buildErrorOverlay();
default:
return const SizedBox.shrink();
}
}
// P2P 模式:未连接显示加载
if (!isP2pConnected) {
return _buildLoadingOverlay('正在 P2P 连接...');
}
return const SizedBox.shrink();
}
/// 模式切换中遮罩
Widget _buildSwitchingOverlay() {
return Container(
color: Colors.black26,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: Colors.white70),
SizedBox(height: 16.h),
Text(
'正在切换传输模式...',
style: TextStyle(fontSize: 18.sp, color: Colors.white70),
),
],
),
),
);
}
/// 加载中遮罩
Widget _buildLoadingOverlay(String message) {
return Container(
color: Colors.black26,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: Colors.white70),
SizedBox(height: 16.h),
Text(
message,
style: TextStyle(fontSize: 18.sp, color: Colors.white70),
),
],
),
),
);
}
/// 错误遮罩
Widget _buildErrorOverlay() {
return Container(
color: Colors.black38,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48.w, color: Colors.white54),
SizedBox(height: 16.h),
Text(
'视频连接失败',
style: TextStyle(
fontSize: 20.sp,
color: Colors.white70,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 24.h),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('重新连接'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3A87FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.r),
),
),
),
],
),
),
);
}
// ==================== 叠加按钮 ====================
/// 全屏按钮(右下角)
Widget _buildFullscreenButton(BuildContext context) {
return Positioned(
right: 12.w,
bottom: 12.h,
child: GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => VideoFullscreenPage(
mode: streamMode,
webrtcRenderer: webrtcRenderer,
p2pController: p2pController,
),
),
);
},
child: Container(
width: 40.w,
height: 40.w,
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(8.r),
),
child: Icon(
Icons.fullscreen,
color: Colors.white,
size: 22.w,
),
),
),
);
}
/// 模式切换按钮(全屏按钮左侧 8px 间距)
Widget _buildToggleModeButton() {
return Positioned(
right: 12.w + 40.w + 8.w,
bottom: 12.h,
child: VideoStreamSwitch(
currentMode: streamMode,
onToggle: onToggleMode!,
),
);
}
/// 未连接时的占位
Widget _buildPlaceholder() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.videocam_off, size: 48.w, color: Colors.white38),
SizedBox(height: 16.h),
// Text(
// '暂无视频画面',
// style: TextStyle(
// fontSize: 20.sp,
// color: Colors.white54,
// fontWeight: FontWeight.w500,
// ),
// ),
],
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../../enums/video_stream_mode_enum.dart';
/// P2P/WebRTC 模式切换控件
///
/// 显示当前传输模式(P2P / RTC),点击切换至另一种模式。
/// 位置:视频容器右下角,全屏按钮左侧。
///
/// 涉及页面:监控页 - 监控画面
class VideoStreamSwitch extends StatelessWidget {
/// 当前视频传输模式
final VideoStreamMode currentMode;
/// 点击切换回调
final VoidCallback onToggle;
const VideoStreamSwitch({
super.key,
required this.currentMode,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final isP2p = currentMode == VideoStreamMode.p2p;
return GestureDetector(
onTap: onToggle,
child: Container(
width: 40.w,
height: 40.w,
decoration: BoxDecoration(
color: isP2p
? const Color(0xFF3A87FF).withValues(alpha: 0.6)
: Colors.black38,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isP2p ? Colors.white54 : Colors.white24,
width: 1,
),
),
child: Center(
child: Text(
isP2p ? 'P2P' : 'RTC',
style: TextStyle(
fontSize: 10.sp,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment