优化+摄像头+扫码绑定
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// 视频加载状态
|
||||
enum WebRTCLoadState { loading, playing, error }
|
||||
|
||||
class WebRTCLocalPlayer extends StatefulWidget {
|
||||
final String streamUrl;
|
||||
final bool showLeftPip;
|
||||
@@ -11,6 +15,16 @@ class WebRTCLocalPlayer extends StatefulWidget {
|
||||
final bool isFrontMain;
|
||||
final Alignment? mainViewAlignment; // 🔥 主视角对齐方式(指定后覆盖 isFrontMain)
|
||||
final VoidCallback? onDoubleTap; // 🔥 添加双击回调
|
||||
final VoidCallback? onTap; // 🔥 单击回调(播放中点击停止拉流)
|
||||
|
||||
/// 🔥 视频加载状态回调(loading/playing/error)
|
||||
/// - loading:正在建立连接
|
||||
/// - playing:已收到视频流,开始播放
|
||||
/// - error:连接失败或超时未收到画面
|
||||
final ValueChanged<WebRTCLoadState>? onLoadingStateChanged;
|
||||
|
||||
/// 🔥 等待视频流的最大时长,超时判定为失败(默认 8 秒)
|
||||
final Duration connectTimeout;
|
||||
|
||||
const WebRTCLocalPlayer({
|
||||
super.key,
|
||||
@@ -20,6 +34,9 @@ class WebRTCLocalPlayer extends StatefulWidget {
|
||||
this.isFrontMain = true,
|
||||
this.mainViewAlignment, // 🔥 可选,指定要显示的象限
|
||||
this.onDoubleTap, // 🔥 可选回调
|
||||
this.onTap, // 🔥 可选,播放中单击停止拉流
|
||||
this.onLoadingStateChanged,
|
||||
this.connectTimeout = const Duration(seconds: 8),
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -29,18 +46,132 @@ class WebRTCLocalPlayer extends StatefulWidget {
|
||||
class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
// 🔥 显示器:只初始化一次,终生使用
|
||||
final RTCVideoRenderer _renderer = RTCVideoRenderer();
|
||||
|
||||
|
||||
// 🔥 信号线:可以更换,但必须先拔后插
|
||||
RTCPeerConnection? _peerConnection;
|
||||
|
||||
|
||||
// 🔥 URL 缓存:用于判断是否需要换线
|
||||
String? _currentStreamUrl;
|
||||
|
||||
// 画中画位置管理
|
||||
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(const Offset(20, 80));
|
||||
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(const Offset(200, 80));
|
||||
final ValueNotifier<Offset> _leftPosNotifier = ValueNotifier(
|
||||
const Offset(20, 80),
|
||||
);
|
||||
final ValueNotifier<Offset> _rightPosNotifier = ValueNotifier(
|
||||
const Offset(200, 80),
|
||||
);
|
||||
bool _isPosInitialized = false;
|
||||
|
||||
// 🔥 加载状态相关
|
||||
WebRTCLoadState _loadState = WebRTCLoadState.loading;
|
||||
Timer? _connectTimer; // 超时判定计时器
|
||||
Timer? _frameCheckTimer; // 🔥 真实视频帧检测计时器
|
||||
|
||||
/// 🔥 统一更新加载状态并通知外部
|
||||
void _updateLoadState(WebRTCLoadState state) {
|
||||
if (_loadState == state) return;
|
||||
_loadState = state;
|
||||
widget.onLoadingStateChanged?.call(state);
|
||||
}
|
||||
|
||||
/// 🔥 启动超时计时器:超过 connectTimeout 仍未渲染出真实画面,判定为失败
|
||||
void _startConnectTimer() {
|
||||
_connectTimer?.cancel();
|
||||
_connectTimer = Timer(widget.connectTimeout, () {
|
||||
if (_loadState != WebRTCLoadState.playing) {
|
||||
debugPrint('⏰ [WebRTC] 连接超时,未渲染出真实画面,判定为失败');
|
||||
_updateLoadState(WebRTCLoadState.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 🔥 取消超时计时器
|
||||
void _cancelConnectTimer() {
|
||||
_connectTimer?.cancel();
|
||||
_connectTimer = null;
|
||||
}
|
||||
|
||||
/// 🔥 启动真实视频流检测:通过 WebRTC getStats 检查 inbound-rtp 的 bytesReceived 是否在增长
|
||||
/// 只有真实有数据包流入,才算真的有视频(避免 track 建立了但帧是黑的被误判为有视频)
|
||||
/// - videoWidth/videoHeight 在 SDP 协商成功后就 > 0,无法区分黑屏流,不可靠
|
||||
/// - bytesReceived 持续增长 = 真实数据流;为 0 或停止增长 = 黑屏流
|
||||
void _startFrameCheck() {
|
||||
_frameCheckTimer?.cancel();
|
||||
int lastBytesReceived = -1;
|
||||
int stableCount = 0; // bytesReceived 未增长的连续次数
|
||||
|
||||
_frameCheckTimer = Timer.periodic(const Duration(milliseconds: 500), (
|
||||
_,
|
||||
) async {
|
||||
if (!mounted || _peerConnection == null) {
|
||||
_frameCheckTimer?.cancel();
|
||||
_frameCheckTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final stats = await _peerConnection!.getStats();
|
||||
// 查找视频入站 RTP 统计
|
||||
int currentBytes = 0;
|
||||
for (final report in stats) {
|
||||
if (report.type == 'inbound-rtp') {
|
||||
final kind = report.values['kind'];
|
||||
final bytes = report.values['bytesReceived'];
|
||||
if (kind == 'video' && bytes is num) {
|
||||
currentBytes = bytes.toInt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 还没收到任何字节,继续等
|
||||
if (currentBytes == 0) {
|
||||
debugPrint('⏳ [WebRTC] bytesReceived=0,等待数据流入...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 字节数在增长 → 真实数据流 → 判定为 playing
|
||||
if (currentBytes > lastBytesReceived && lastBytesReceived >= 0) {
|
||||
debugPrint(
|
||||
'✅ [WebRTC] 检测到真实视频流,bytesReceived: $currentBytes,判定为 playing',
|
||||
);
|
||||
_frameCheckTimer?.cancel();
|
||||
_frameCheckTimer = null;
|
||||
_cancelConnectTimer();
|
||||
_updateLoadState(WebRTCLoadState.playing);
|
||||
return;
|
||||
}
|
||||
|
||||
// 字节数未增长
|
||||
if (currentBytes == lastBytesReceived) {
|
||||
stableCount++;
|
||||
debugPrint(
|
||||
'⚠️ [WebRTC] bytesReceived 未增长 ($currentBytes),stableCount=$stableCount',
|
||||
);
|
||||
// 连续 2 次未增长(约 1 秒)→ 判定为黑屏流
|
||||
if (stableCount >= 2) {
|
||||
debugPrint('❌ [WebRTC] 字节流停止增长,判定为黑屏流');
|
||||
_frameCheckTimer?.cancel();
|
||||
_frameCheckTimer = null;
|
||||
_cancelConnectTimer();
|
||||
_updateLoadState(WebRTCLoadState.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
lastBytesReceived = currentBytes;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [WebRTC] 获取 stats 失败: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 🔥 取消帧检测计时器
|
||||
void _cancelFrameCheck() {
|
||||
_frameCheckTimer?.cancel();
|
||||
_frameCheckTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -62,9 +193,10 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
@override
|
||||
void didUpdateWidget(WebRTCLocalPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
|
||||
// 🔥 只有 URL 真正变化时才换线
|
||||
if (oldWidget.streamUrl != widget.streamUrl && widget.streamUrl.isNotEmpty) {
|
||||
if (oldWidget.streamUrl != widget.streamUrl &&
|
||||
widget.streamUrl.isNotEmpty) {
|
||||
debugPrint('🔄 [WebRTC] URL 变化,准备换线');
|
||||
_connectSignal();
|
||||
}
|
||||
@@ -75,22 +207,27 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
// 1️⃣ 如果 URL 为空,不插线
|
||||
if (widget.streamUrl.isEmpty) {
|
||||
debugPrint('⚠️ [WebRTC] URL 为空,跳过连接');
|
||||
_updateLoadState(WebRTCLoadState.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 2️⃣ 如果 URL 没变,不换线(防止重复连接)
|
||||
if (_currentStreamUrl == widget.streamUrl && _peerConnection != null) {
|
||||
debugPrint('✅ [WebRTC] URL 未变化,保持当前连接');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 3️⃣ 拔掉旧线(彻底释放旧连接)
|
||||
await _disconnectSignal();
|
||||
|
||||
|
||||
// 🔥 进入 loading 态,启动超时计时器
|
||||
_updateLoadState(WebRTCLoadState.loading);
|
||||
_startConnectTimer();
|
||||
|
||||
// 4️⃣ 插上新线(创建新连接)
|
||||
try {
|
||||
debugPrint('🔌 [WebRTC] 建立新连接...');
|
||||
|
||||
|
||||
_peerConnection = await createPeerConnection({
|
||||
"sdpSemantics": "unified-plan",
|
||||
"iceServers": [
|
||||
@@ -106,48 +243,70 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
// 🔥 收到视频流时,直接连接到显示器
|
||||
_peerConnection!.onTrack = (RTCTrackEvent event) {
|
||||
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
|
||||
debugPrint('✅ [WebRTC] 收到视频流,连接到显示器');
|
||||
debugPrint('✅ [WebRTC] 收到视频 track,连接到显示器,等待真实帧渲染');
|
||||
if (mounted) {
|
||||
setState(() => _renderer.srcObject = event.streams[0]);
|
||||
// 🔥 关键:收到 track 不等于有画面,必须等真实帧渲染才判定 playing
|
||||
_startFrameCheck();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 🔥 SDP 协商
|
||||
RTCSessionDescription offer = await _peerConnection!.createOffer({'offerToReceiveVideo': 1});
|
||||
RTCSessionDescription offer = await _peerConnection!.createOffer({
|
||||
'offerToReceiveVideo': 1,
|
||||
});
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
Uri uri = Uri.parse(widget.streamUrl.replaceFirst('webrtc://', 'http://'));
|
||||
Uri uri = Uri.parse(
|
||||
widget.streamUrl.replaceFirst('webrtc://', 'http://'),
|
||||
);
|
||||
String apiUrl = "http://${uri.host}:1985/rtc/v1/play/";
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(apiUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
"api": apiUrl,
|
||||
"streamurl": widget.streamUrl,
|
||||
"clientip": null,
|
||||
"sdp": offer.sdp
|
||||
"api": apiUrl,
|
||||
"streamurl": widget.streamUrl,
|
||||
"clientip": null,
|
||||
"sdp": offer.sdp,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final sdp = data['sdp'];
|
||||
|
||||
|
||||
if (sdp != null && sdp is String && sdp.isNotEmpty) {
|
||||
await _peerConnection!.setRemoteDescription(RTCSessionDescription(sdp, 'answer'));
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(sdp, 'answer'),
|
||||
);
|
||||
_currentStreamUrl = widget.streamUrl; // 🔥 记录当前 URL
|
||||
debugPrint('✅ [WebRTC] 连接成功');
|
||||
debugPrint('✅ [WebRTC] SDP 协商成功');
|
||||
// 注意:此处仅代表协商成功,真正判定为 playing 须等 onTrack 收到画面
|
||||
// 超时定时器仍在运行,未收到画面会自动判定为 error
|
||||
} else {
|
||||
debugPrint('❌ [WebRTC] 返回的 sdp 为空,判定为失败');
|
||||
_cancelConnectTimer();
|
||||
_updateLoadState(WebRTCLoadState.error);
|
||||
}
|
||||
} else {
|
||||
debugPrint('❌ [WebRTC] HTTP 状态码: ${response.statusCode},判定为失败');
|
||||
_cancelConnectTimer();
|
||||
_updateLoadState(WebRTCLoadState.error);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("❌ [WebRTC] 连接失败: $e");
|
||||
_cancelConnectTimer();
|
||||
_updateLoadState(WebRTCLoadState.error);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 拔线逻辑:彻底释放旧连接
|
||||
Future<void> _disconnectSignal() async {
|
||||
_cancelConnectTimer(); // 🔥 拔线时一并取消超时计时器
|
||||
_cancelFrameCheck(); // 🔥 一并取消帧检测计时器
|
||||
if (_peerConnection != null) {
|
||||
debugPrint('🗑️ [WebRTC] 释放旧连接...');
|
||||
await _peerConnection?.close();
|
||||
@@ -174,7 +333,11 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
// 🔥 俯视(center):显示完整视频帧,不做2倍裁剪
|
||||
if (alignment == Alignment.center) {
|
||||
return RepaintBoundary(
|
||||
child: RTCVideoView(_renderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false),
|
||||
child: RTCVideoView(
|
||||
_renderer,
|
||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
mirror: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,7 +348,11 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
widthFactor: 2.0,
|
||||
heightFactor: 2.0,
|
||||
alignment: alignment,
|
||||
child: RTCVideoView(_renderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false),
|
||||
child: RTCVideoView(
|
||||
_renderer,
|
||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
mirror: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -198,7 +365,12 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.white, Colors.white, Colors.transparent],
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.white,
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [0.0, 0.15, 0.85, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
@@ -208,20 +380,30 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
return const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Colors.transparent, Colors.white, Colors.white, Colors.transparent],
|
||||
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: widget.mainViewAlignment ?? (widget.isFrontMain ? Alignment.topLeft : Alignment.topRight)),
|
||||
child: _buildQuadrantView(
|
||||
alignment:
|
||||
widget.mainViewAlignment ??
|
||||
(widget.isFrontMain ? Alignment.topLeft : Alignment.topRight),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔥 如果正在初始化,显示加载指示器
|
||||
if (_peerConnection == null && _currentStreamUrl == null) {
|
||||
// 🔥 在真正渲染出视频帧(playing 态)之前,始终显示加载指示器
|
||||
// 避免出现"track 已收到但画面是黑的"黑屏情况
|
||||
if (_loadState != WebRTCLoadState.playing) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
@@ -255,26 +437,47 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
|
||||
if (!_isPosInitialized) {
|
||||
_leftPosNotifier.value = const Offset(20, 80);
|
||||
_rightPosNotifier.value = Offset(constraints.maxWidth - pipW - 20, 80);
|
||||
_rightPosNotifier.value = Offset(
|
||||
constraints.maxWidth - pipW - 20,
|
||||
80,
|
||||
);
|
||||
_isPosInitialized = true;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// ② 主视频层(🔥 恢复双击切换前后视角)
|
||||
// ② 主视频层(🔥 双击切换前后视角 / 单击停止拉流)
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap, // 🔥 播放中单击 → 停止拉流
|
||||
onDoubleTap: () {
|
||||
debugPrint('👆 [WebRTC] 双击屏幕,切换前后视角');
|
||||
widget.onDoubleTap?.call(); // 🔥 调用父组件回调
|
||||
},
|
||||
child: AspectRatio(aspectRatio: 16 / 9, child: _buildMainViewWithFeathering()),
|
||||
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),
|
||||
if (widget.showLeftPip)
|
||||
_buildFastPip(
|
||||
_leftPosNotifier,
|
||||
Alignment.bottomLeft,
|
||||
constraints,
|
||||
pipW,
|
||||
pipH,
|
||||
),
|
||||
if (widget.showRightPip)
|
||||
_buildFastPip(
|
||||
_rightPosNotifier,
|
||||
Alignment.bottomRight,
|
||||
constraints,
|
||||
pipW,
|
||||
pipH,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -282,7 +485,12 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
void _correctPosition(ValueNotifier<Offset> notifier, BoxConstraints constraints, double w, double h) {
|
||||
void _correctPosition(
|
||||
ValueNotifier<Offset> notifier,
|
||||
BoxConstraints constraints,
|
||||
double w,
|
||||
double h,
|
||||
) {
|
||||
double maxX = (constraints.maxWidth - w).clamp(0.0, double.infinity);
|
||||
double maxY = (constraints.maxHeight - h).clamp(0.0, double.infinity);
|
||||
double newX = notifier.value.dx.clamp(0.0, maxX);
|
||||
@@ -292,7 +500,13 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFastPip(ValueNotifier<Offset> notifier, Alignment align, BoxConstraints constraints, double w, double h) {
|
||||
Widget _buildFastPip(
|
||||
ValueNotifier<Offset> notifier,
|
||||
Alignment align,
|
||||
BoxConstraints constraints,
|
||||
double w,
|
||||
double h,
|
||||
) {
|
||||
return ValueListenableBuilder<Offset>(
|
||||
valueListenable: notifier,
|
||||
builder: (context, pos, child) {
|
||||
@@ -302,8 +516,14 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanUpdate: (details) {
|
||||
final double nextX = (notifier.value.dx + details.delta.dx).clamp(0.0, (constraints.maxWidth - w).clamp(0.0, double.infinity));
|
||||
final double nextY = (notifier.value.dy + details.delta.dy).clamp(0.0, (constraints.maxHeight - h).clamp(0.0, double.infinity));
|
||||
final double nextX = (notifier.value.dx + details.delta.dx).clamp(
|
||||
0.0,
|
||||
(constraints.maxWidth - w).clamp(0.0, double.infinity),
|
||||
);
|
||||
final double nextY = (notifier.value.dy + details.delta.dy).clamp(
|
||||
0.0,
|
||||
(constraints.maxHeight - h).clamp(0.0, double.infinity),
|
||||
);
|
||||
notifier.value = Offset(nextX, nextY);
|
||||
},
|
||||
child: child!,
|
||||
@@ -315,7 +535,13 @@ class _WebRTCLocalPlayerState extends State<WebRTCLocalPlayer> {
|
||||
height: h,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.5), blurRadius: 20, offset: const Offset(0, 8))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildQuadrantView(alignment: align),
|
||||
|
||||
Reference in New Issue
Block a user