Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
L
laki_icu_app
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
张宏
laki_icu_app
Commits
9895a233
Commit
9895a233
authored
Jun 20, 2026
by
张宏
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
加入全屏页面
parent
f7ca117d
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
93 additions
and
558 deletions
+93
-558
camera_controls.dart
lib/views/home/index/widgets/camera_controls.dart
+0
-137
monitoring_screen.dart
lib/views/home/index/widgets/monitoring_screen.dart
+0
-421
webrtc_fullscreen_page.dart
lib/views/home/index/widgets/webrtc_fullscreen_page.dart
+59
-0
webrtc_video_widget.dart
lib/views/home/index/widgets/webrtc_video_widget.dart
+34
-0
No files found.
lib/views/home/index/widgets/camera_controls.dart
deleted
100644 → 0
View file @
f7ca117d
import
'package:flutter/material.dart'
;
import
'package:flutter/services.dart'
;
import
'package:flutter_screenutil/flutter_screenutil.dart'
;
/// 摄像头控制按钮组件
///
/// 职责:
/// - 提供拍照、录像、刷新三个操作按钮
/// - 录像按钮支持录制中/停止录制两种状态切换
/// - 摄像头未就绪时按钮置灰不可操作
///
/// 涉及页面:首页监护舱页面 - 监控画面底部控制栏
class
CameraControls
extends
StatelessWidget
{
/// 是否正在录像
final
bool
isRecording
;
/// 摄像头是否已就绪(已打开)
final
bool
cameraReady
;
/// 是否已全屏
final
bool
isFullscreen
;
/// 拍照回调
final
VoidCallback
?
onTakePhoto
;
/// 切换录像状态回调(开始/停止)
final
VoidCallback
?
onToggleRecord
;
/// 刷新回调
final
VoidCallback
?
onRefresh
;
/// 切换全屏回调
final
VoidCallback
?
onToggleFullscreen
;
const
CameraControls
({
super
.
key
,
this
.
isRecording
=
false
,
this
.
cameraReady
=
false
,
this
.
isFullscreen
=
false
,
this
.
onTakePhoto
,
this
.
onToggleRecord
,
this
.
onRefresh
,
this
.
onToggleFullscreen
,
});
@override
Widget
build
(
BuildContext
context
)
{
return
Row
(
mainAxisSize:
MainAxisSize
.
min
,
children:
[
_buildControlButton
(
icon:
Icons
.
refresh
,
onPressed:
onRefresh
,
tooltip:
'刷新画面'
,
enabled:
true
,
// 刷新按钮始终可用
),
SizedBox
(
width:
16
.
w
),
_buildControlButton
(
icon:
isFullscreen
?
Icons
.
fullscreen_exit
:
Icons
.
fullscreen
,
onPressed:
onToggleFullscreen
,
tooltip:
isFullscreen
?
'退出全屏'
:
'全屏放大'
,
enabled:
cameraReady
,
),
// _buildControlButton(
// icon: Icons.camera_alt,
// onPressed: cameraReady ? onTakePhoto : null,
// tooltip: '拍照',
// enabled: cameraReady,
// ),
// SizedBox(width: 16.w),
// _buildControlButton(
// icon: isRecording ? Icons.stop : Icons.videocam,
// onPressed: cameraReady ? onToggleRecord : null,
// tooltip: isRecording ? '停止录像' : '开始录像',
// isActive: isRecording,
// enabled: cameraReady,
// ),
],
);
}
/// 构建单个控制按钮
Widget
_buildControlButton
({
required
IconData
icon
,
VoidCallback
?
onPressed
,
String
?
tooltip
,
bool
isActive
=
false
,
bool
enabled
=
true
,
})
{
final
effectiveOpacity
=
enabled
?
1.0
:
0.35
;
return
Tooltip
(
message:
tooltip
??
''
,
child:
GestureDetector
(
onTap:
enabled
?
()
{
HapticFeedback
.
lightImpact
();
onPressed
?.
call
();
}
:
null
,
child:
Opacity
(
opacity:
effectiveOpacity
,
child:
AnimatedContainer
(
duration:
const
Duration
(
milliseconds:
200
),
width:
60
.
w
,
height:
60
.
w
,
decoration:
BoxDecoration
(
color:
isActive
?
const
Color
(
0xCCFF4444
)
:
const
Color
(
0x331E10B6
),
borderRadius:
BorderRadius
.
circular
(
30
.
r
),
border:
Border
.
all
(
color:
isActive
?
Colors
.
redAccent
:
Colors
.
white24
,
width:
1
,
),
boxShadow:
isActive
?
[
BoxShadow
(
color:
Colors
.
redAccent
.
withValues
(
alpha:
0.4
),
blurRadius:
12
.
r
,
spreadRadius:
2
,
),
]
:
null
,
),
child:
Icon
(
icon
,
color:
Colors
.
white
,
size:
32
.
w
,
),
),
),
),
);
}
}
lib/views/home/index/widgets/monitoring_screen.dart
deleted
100644 → 0
View file @
f7ca117d
import
'dart:async'
;
import
'package:flutter/material.dart'
;
import
'package:flutter_screenutil/flutter_screenutil.dart'
;
import
'package:flutter_usbcamera/flutter_usbcamera.dart'
;
import
'package:laki_icu_app/services/usb_camera_service.dart'
;
import
'camera_controls.dart'
;
/// 监控画面组件 —— 集成 USB 摄像头预览画面
///
/// 职责:
/// - 通过 [UsbCameraPreview] 嵌入 USB 摄像头实时预览画面
/// - 监听 [UsbCameraService.eventStream] 处理设备热插拔和状态变化
/// - 底部控制栏:刷新、全屏按钮
///
/// 涉及页面:首页监护舱页面
class
MonitoringScreen
extends
StatefulWidget
{
final
VoidCallback
?
onRefresh
;
final
VoidCallback
?
onSnapshot
;
final
VoidCallback
?
onRecord
;
const
MonitoringScreen
({
super
.
key
,
this
.
onRefresh
,
this
.
onSnapshot
,
this
.
onRecord
,
});
@override
State
<
MonitoringScreen
>
createState
()
=>
_MonitoringScreenState
();
}
class
_MonitoringScreenState
extends
State
<
MonitoringScreen
>
{
final
UsbCameraService
_cameraService
=
UsbCameraService
.
instance
;
StreamSubscription
<
CameraEvent
>?
_eventSub
;
bool
_cameraReady
=
false
;
bool
_isRecording
=
false
;
bool
_isFullscreen
=
false
;
/// 用于强制重建 PlatformView 的计数器
/// flutter_usbcamera 底层使用单例 [mCameraView] 绑定唯一 TextureView,
/// 退出全屏时 generation+1 使原始 UsbCameraPreview 以新 Key 重建,
/// 触发原生 onCameraViewCreated → mCameraView 重新指向此处的 TextureView
int
_cameraViewGeneration
=
0
;
OverlayEntry
?
_fullscreenEntry
;
@override
void
initState
()
{
super
.
initState
();
_initCamera
();
}
Future
<
void
>
_initCamera
()
async
{
// 监听摄像头事件流
_eventSub
=
_cameraService
.
eventStream
.
listen
(
_onCameraEvent
);
// 初始化 USB 监听
await
_cameraService
.
initialize
();
// 检查是否已有设备连接
_refreshState
();
}
void
_onCameraEvent
(
CameraEvent
event
)
{
if
(!
mounted
)
return
;
switch
(
event
.
type
)
{
case
CameraEventType
.
onAttachDev
:
// 设备插入
setState
(()
{});
break
;
case
CameraEventType
.
onDetachDev
:
case
CameraEventType
.
onDisConnectDev
:
// 设备拔出
setState
(()
{
_cameraReady
=
false
;
_isRecording
=
false
;
});
break
;
case
CameraEventType
.
onConnectDev
:
// 权限通过,摄像头即将打开
setState
(()
{});
break
;
case
CameraEventType
.
onCancelDev
:
// 权限被拒绝
setState
(()
{
_cameraReady
=
false
;
});
break
;
case
CameraEventType
.
onCameraState
:
if
(
event
.
state
==
'opened'
)
{
setState
(()
=>
_cameraReady
=
true
);
}
else
if
(
event
.
state
==
'closed'
||
event
.
state
==
'error'
)
{
setState
(()
{
_cameraReady
=
false
;
_isRecording
=
false
;
});
}
break
;
case
CameraEventType
.
onCaptureComplete
:
setState
(()
=>
_isRecording
=
false
);
if
(
event
.
path
!=
null
&&
mounted
)
{
final
isVideo
=
event
.
captureType
==
'video'
||
(
event
.
path
?.
endsWith
(
'.mp4'
)
??
false
);
_showSnackBar
(
isVideo
?
'录像已保存'
:
'拍照成功'
);
}
break
;
case
CameraEventType
.
onCaptureError
:
setState
(()
=>
_isRecording
=
false
);
if
(
mounted
)
{
_showSnackBar
(
'拍摄失败:
${event.error ?? '未知错误'}
'
);
}
break
;
default
:
break
;
}
}
void
_refreshState
()
{
setState
(()
{
_cameraReady
=
_cameraService
.
isOpened
;
_isRecording
=
_cameraService
.
isRecording
;
});
}
@override
void
dispose
()
{
_eventSub
?.
cancel
();
_fullscreenEntry
?.
remove
();
_fullscreenEntry
=
null
;
_cameraService
.
dispose
();
super
.
dispose
();
}
@override
Widget
build
(
BuildContext
context
)
{
// AspectRatio 放在最外层,让整个 Container 宽度贴合 16:9 视频比例,
// 避免父级 Expanded 撑宽后出现右侧空白
return
ClipRRect
(
borderRadius:
BorderRadius
.
circular
(
20
.
r
),
child:
AspectRatio
(
aspectRatio:
16
/
9
,
child:
Container
(
decoration:
BoxDecoration
(
gradient:
const
LinearGradient
(
begin:
Alignment
.
topCenter
,
end:
Alignment
.
bottomCenter
,
colors:
[
Color
(
0xCC63A0FF
),
Color
(
0xCC0024C4
)],
),
border:
Border
.
all
(
color:
Colors
.
white24
,
width:
4
),
),
child:
Stack
(
children:
[
// 摄像头预览画面
_buildCameraPreview
(),
// 摄像头未就绪时的占位提示
if
(!
_cameraReady
)
_buildPlaceholder
(),
// 底部控制按钮
Positioned
(
bottom:
24
.
h
,
right:
24
.
w
,
child:
CameraControls
(
isRecording:
_isRecording
,
cameraReady:
_cameraReady
,
isFullscreen:
_isFullscreen
,
onTakePhoto:
_onTakePhoto
,
onToggleRecord:
_onToggleRecord
,
onRefresh:
_onRefresh
,
onToggleFullscreen:
_onToggleFullscreen
,
),
),
],
),
),
),
);
}
/// 构建 USB 摄像头预览画面(通过 flutter_usbcamera 插件)
///
/// 使用 [_cameraViewGeneration] 作为 Key 的一部分:
/// 退出全屏时 generation+1 使 Widget 以新 Key 重建,
/// 对应的 PlatformView 也随之重建并触发 onCameraViewCreated
Widget
_buildCameraPreview
()
{
return
UsbCameraPreview
(
key:
ValueKey
(
'camera_
$_cameraViewGeneration
'
),
);
}
/// 未连接 USB 摄像头时的占位提示
Widget
_buildPlaceholder
()
{
return
Center
(
child:
Column
(
mainAxisAlignment:
MainAxisAlignment
.
center
,
children:
[
Icon
(
Icons
.
videocam_off
,
size:
60
.
w
,
color:
Colors
.
white38
,
),
SizedBox
(
height:
16
.
h
),
Text
(
'USB 摄像头未连接'
,
style:
TextStyle
(
fontSize:
24
.
sp
,
color:
Colors
.
white54
,
fontWeight:
FontWeight
.
w500
,
),
),
SizedBox
(
height:
8
.
h
),
Text
(
'请插入 USB 摄像头设备'
,
style:
TextStyle
(
fontSize:
18
.
sp
,
color:
Colors
.
white38
,
),
),
],
),
);
}
/// 拍照
Future
<
void
>
_onTakePhoto
()
async
{
if
(!
_cameraReady
)
{
_showSnackBar
(
'摄像头未就绪'
);
return
;
}
final
path
=
await
_cameraService
.
takePhoto
();
if
(
path
!=
null
&&
mounted
)
{
_showSnackBar
(
'拍照成功'
);
}
widget
.
onSnapshot
?.
call
();
}
/// 切换录像状态
Future
<
void
>
_onToggleRecord
()
async
{
if
(!
_cameraReady
)
{
_showSnackBar
(
'摄像头未就绪'
);
return
;
}
if
(
_isRecording
)
{
final
path
=
await
_cameraService
.
stopRecord
();
if
(
path
!=
null
&&
mounted
)
{
_showSnackBar
(
'录像已保存'
);
}
setState
(()
=>
_isRecording
=
false
);
}
else
{
final
success
=
await
_cameraService
.
startRecord
();
if
(
success
)
{
setState
(()
=>
_isRecording
=
true
);
if
(
mounted
)
_showSnackBar
(
'开始录像'
);
}
else
{
if
(
mounted
)
_showSnackBar
(
'开始录像失败'
);
}
}
widget
.
onRecord
?.
call
();
}
/// 刷新画面
void
_onRefresh
()
{
_refreshState
();
widget
.
onRefresh
?.
call
();
}
// ==================== 全屏逻辑 ====================
/// 切换全屏模式
///
/// flutter_usbcamera 底层将 [mCameraView] 作为单例绑定唯一 TextureView,
/// 不能同时在两个 Widget 中渲染 [UsbCameraPreview](第二个会黑屏)。
///
/// 解决思路:关闭摄像头 → 切换 View 容器 → 重新打开摄像头。
/// - 进入全屏:关摄像头 → 插 Overlay(含 UsbCameraPreview)→ 开摄像头
/// - 退出全屏:关摄像头 → 移 Overlay → 增 generation 强制重建原始
/// PlatformView → 开摄像头
Future
<
void
>
_onToggleFullscreen
()
async
{
if
(!
_cameraReady
)
{
_showSnackBar
(
'摄像头未就绪'
);
return
;
}
if
(
_isFullscreen
)
{
await
_exitFullscreen
();
}
else
{
await
_enterFullscreen
();
}
}
/// 进入全屏
Future
<
void
>
_enterFullscreen
()
async
{
final
deviceId
=
_cameraService
.
controller
.
currentDeviceId
;
if
(
deviceId
==
null
)
return
;
// 在 await 之前捕获 overlay,避免 use_build_context_synchronously
final
overlay
=
Overlay
.
of
(
context
);
// 1. 关闭摄像头,释放当前 PlatformView 的绑定
await
_cameraService
.
closeCamera
();
// 2. 插入全屏 Overlay —— 其 UsbCameraPreview 会创建新 PlatformView
// 并触发原生 onCameraViewCreated → mCameraView 指向新 TextureView
setState
(()
=>
_isFullscreen
=
true
);
_fullscreenEntry
=
OverlayEntry
(
builder:
(
_
)
=>
_FullscreenOverlay
(
onExit:
_exitFullscreen
),
);
overlay
.
insert
(
_fullscreenEntry
!);
// 3. 等待 PlatformView 初始化完成
await
Future
.
delayed
(
const
Duration
(
milliseconds:
150
));
// 4. 重新打开摄像头,渲染到 Overlay 中的 TextureView
await
_cameraService
.
openCamera
(
deviceId
);
}
/// 退出全屏
Future
<
void
>
_exitFullscreen
()
async
{
final
deviceId
=
_cameraService
.
controller
.
currentDeviceId
;
// 1. 关闭摄像头
if
(
deviceId
!=
null
)
{
await
_cameraService
.
closeCamera
();
}
// 2. 移除全屏 Overlay(其 PlatformView 被销毁)
_fullscreenEntry
?.
remove
();
_fullscreenEntry
=
null
;
// 3. 增加 generation → rebuild 时 UsbCameraPreview 的 Key 变化
// → 旧 PlatformView 被销毁,新 PlatformView 被创建
// → onCameraViewCreated → mCameraView 指向此处的 TextureView
setState
(()
{
_isFullscreen
=
false
;
_cameraViewGeneration
++;
});
// 4. 等待新 PlatformView 初始化完成
await
Future
.
delayed
(
const
Duration
(
milliseconds:
150
));
// 5. 重新打开摄像头,渲染到原始位置的 TextureView
if
(
deviceId
!=
null
)
{
await
_cameraService
.
openCamera
(
deviceId
);
}
}
void
_showSnackBar
(
String
message
)
{
ScaffoldMessenger
.
of
(
context
).
showSnackBar
(
SnackBar
(
content:
Text
(
message
),
duration:
const
Duration
(
seconds:
2
),
behavior:
SnackBarBehavior
.
floating
,
margin:
EdgeInsets
.
all
(
24
.
w
),
shape:
RoundedRectangleBorder
(
borderRadius:
BorderRadius
.
circular
(
20
.
r
),
),
),
);
}
}
/// 全屏摄像头预览浮层
///
/// 通过 [OverlayEntry] 插入到当前页面,而非通过 Navigator.push 创建新路由,
/// 避免 flutter_usbcamera 的 PlatformView 冲突。
class
_FullscreenOverlay
extends
StatelessWidget
{
final
VoidCallback
onExit
;
const
_FullscreenOverlay
({
required
this
.
onExit
});
@override
Widget
build
(
BuildContext
context
)
{
return
Material
(
color:
Colors
.
black
,
child:
Stack
(
children:
[
// 全屏摄像头画面(16:9 居中)
const
Center
(
child:
AspectRatio
(
aspectRatio:
16
/
9
,
child:
UsbCameraPreview
(),
),
),
// 顶部退出按钮
Positioned
(
top:
MediaQuery
.
of
(
context
).
padding
.
top
+
16
.
h
,
left:
16
.
w
,
child:
GestureDetector
(
onTap:
onExit
,
child:
Container
(
width:
48
.
w
,
height:
48
.
w
,
decoration:
BoxDecoration
(
color:
Colors
.
black38
,
borderRadius:
BorderRadius
.
circular
(
24
.
r
),
),
child:
Icon
(
Icons
.
fullscreen_exit
,
color:
Colors
.
white
,
size:
28
.
w
,
),
),
),
),
],
),
);
}
}
lib/views/home/index/widgets/webrtc_fullscreen_page.dart
0 → 100644
View file @
9895a233
import
'package:flutter/material.dart'
;
import
'package:flutter_webrtc/flutter_webrtc.dart'
;
/// WebRTC 视频全屏播放页面
///
/// 全屏展示远程摄像头实时画面,点击任意位置退出全屏。
class
WebrtcFullscreenPage
extends
StatelessWidget
{
final
RTCVideoRenderer
renderer
;
const
WebrtcFullscreenPage
({
super
.
key
,
required
this
.
renderer
});
@override
Widget
build
(
BuildContext
context
)
{
return
Scaffold
(
backgroundColor:
Colors
.
black
,
body:
GestureDetector
(
onTap:
()
=>
Navigator
.
of
(
context
).
pop
(),
child:
Stack
(
fit:
StackFit
.
expand
,
children:
[
// 全屏视频画面
Center
(
child:
RTCVideoView
(
renderer
,
objectFit:
RTCVideoViewObjectFit
.
RTCVideoViewObjectFitContain
,
),
),
// 顶部提示栏
Positioned
(
top:
0
,
left:
0
,
right:
0
,
child:
Container
(
padding:
const
EdgeInsets
.
fromLTRB
(
20
,
48
,
20
,
12
),
decoration:
const
BoxDecoration
(
gradient:
LinearGradient
(
begin:
Alignment
.
topCenter
,
end:
Alignment
.
bottomCenter
,
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
),
),
],
),
),
),
],
),
),
);
}
}
lib/views/home/index/widgets/webrtc_video_widget.dart
View file @
9895a233
...
@@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
...
@@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import
'package:flutter_webrtc/flutter_webrtc.dart'
;
import
'package:flutter_webrtc/flutter_webrtc.dart'
;
import
'../../../../services/webrtc_service.dart'
;
import
'../../../../services/webrtc_service.dart'
;
import
'webrtc_fullscreen_page.dart'
;
/// WebRTC 实时视频播放组件
/// WebRTC 实时视频播放组件
///
///
...
@@ -49,6 +50,9 @@ class WebrtcVideoWidget extends StatelessWidget {
...
@@ -49,6 +50,9 @@ class WebrtcVideoWidget extends StatelessWidget {
_buildVideoLayer
(),
_buildVideoLayer
(),
// 状态指示层
// 状态指示层
_buildStatusOverlay
(),
_buildStatusOverlay
(),
// 全屏按钮(仅连接状态下显示)
if
(
connectionState
==
WebrtcConnectionState
.
connected
)
_buildFullscreenButton
(
context
),
],
],
),
),
),
),
...
@@ -143,6 +147,36 @@ class WebrtcVideoWidget extends StatelessWidget {
...
@@ -143,6 +147,36 @@ class WebrtcVideoWidget extends StatelessWidget {
);
);
}
}
/// 全屏按钮(右下角)
Widget
_buildFullscreenButton
(
BuildContext
context
)
{
return
Positioned
(
right:
12
.
w
,
bottom:
12
.
h
,
child:
GestureDetector
(
onTap:
()
{
Navigator
.
of
(
context
).
push
(
MaterialPageRoute
(
builder:
(
_
)
=>
WebrtcFullscreenPage
(
renderer:
renderer
),
),
);
},
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
,
),
),
),
);
}
/// 未连接时的占位
/// 未连接时的占位
Widget
_buildPlaceholder
()
{
Widget
_buildPlaceholder
()
{
return
Center
(
return
Center
(
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment