1. 新增自编码与全托管模式的解码耗时探针(VideoSink),统计帧率与解码耗时。 2. 修复 SPS 与 PPS 作为独立 CONFIG 单元发送时被误丢弃导致 H.264 永久黑屏的问题。 3. 优化分片重组缓冲,丢弃过期单元并限制缓冲堆积以防内存泄漏。
770 lines
27 KiB
Dart
770 lines
27 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||
|
||
import 'self_codec_decoder.dart';
|
||
|
||
import '../config/ice_servers.dart';
|
||
import '../models/signal_message.dart';
|
||
import '../proto/control_message.pb.dart';
|
||
import '../signaling/signaling_client.dart';
|
||
import '../utils/control_commands.dart';
|
||
|
||
/// WebRTC 全托管模式下,由原生 VideoSink 探针逐帧实测得到的解码耗时统计。
|
||
class _DecodeProbeStats {
|
||
final double lastMs;
|
||
final double avgMs;
|
||
final double peakMs;
|
||
final int totalFrames;
|
||
final int fpsNow;
|
||
final int fpsAvg;
|
||
const _DecodeProbeStats({
|
||
required this.lastMs,
|
||
required this.avgMs,
|
||
required this.peakMs,
|
||
required this.totalFrames,
|
||
required this.fpsNow,
|
||
required this.fpsAvg,
|
||
});
|
||
}
|
||
|
||
/// 封装 WebRTC 连接逻辑(对应 Android 端 WebRtcClient)。
|
||
///
|
||
/// 职责:
|
||
/// - 创建 PeerConnection(recvonly 视频 + 控制用 DataChannel)
|
||
/// - 创建 Offer 并通过信令发送
|
||
/// - 处理 Answer / ICE 候选
|
||
/// - 通过 DataChannel 发送控制指令(触摸/滑动/按键)
|
||
/// - 采集并解析连接统计信息
|
||
///
|
||
/// 本类同时兼容 Android 与 iOS(基于 flutter_webrtc)。
|
||
class WebRtcController {
|
||
final SignalingClient signaling;
|
||
final String deviceId;
|
||
final String targetDeviceId;
|
||
final String? authType;
|
||
final String? authValue;
|
||
|
||
RTCPeerConnection? _pc;
|
||
RTCDataChannel? _dataChannel;
|
||
RTCDataChannel? _videoDataChannel;
|
||
SelfCodecDecoder? _selfCodecDecoder;
|
||
int _currentStreamMode = streamModeWebRtc;
|
||
bool _creatingDecoder = false;
|
||
|
||
/// 远端视频渲染器,由调用方持有并显示。
|
||
final RTCVideoRenderer renderer = RTCVideoRenderer();
|
||
|
||
void Function()? onConnectionEstablished;
|
||
void Function(String error)? onConnectionFailed;
|
||
void Function()? onDisconnected;
|
||
|
||
/// ICE 连接断开(如网络中断、被控端退出),用于提示用户并返回连接设置。
|
||
void Function(String message)? onIceDisconnected;
|
||
|
||
/// 远端视频流就绪(renderer 已绑定视频轨道)。
|
||
void Function(RTCVideoRenderer renderer)? onRemoteStream;
|
||
|
||
/// 自编码解码纹理已就绪(textureId >= 0)。
|
||
void Function(int)? onSelfCodecReady;
|
||
|
||
/// 自编码解码器已释放(连接断开时)。
|
||
void Function()? onSelfCodecLost;
|
||
|
||
/// 被控端上报当前生效的串流模式。
|
||
void Function(int mode)? onStreamModeReport;
|
||
|
||
/// 被控端上报分辨率(自编码解码器输出尺寸变化时也会触发)。
|
||
void Function(int width, int height)? onResolutionReported;
|
||
|
||
/// 当前平台(Windows / Linux / Web / iOS 等)不支持原生硬解时回调。
|
||
void Function()? onSelfCodecNotSupported;
|
||
|
||
static const String kVideoChannelLabel = 'video_channel';
|
||
static const int streamModeWebRtc = 0;
|
||
static const int streamModeSelfCodec = 1;
|
||
|
||
WebRtcController({
|
||
required this.signaling,
|
||
required this.deviceId,
|
||
required this.targetDeviceId,
|
||
this.authType,
|
||
this.authValue,
|
||
});
|
||
|
||
/// 初始化渲染器、PeerConnection,并创建 Offer。
|
||
Future<void> initialize() async {
|
||
await renderer.initialize();
|
||
|
||
final configuration = {
|
||
'iceServers': kIceServers,
|
||
'sdpSemantics': 'unified-plan',
|
||
'iceCandidatePoolSize': 10,
|
||
// 增强复杂网络下的稳定性,参考 Android 端配置。
|
||
'continualGatheringPolicy': 'gatherContinually',
|
||
'iceTransportsType': 'all',
|
||
'tcpCandidatePolicy': 'enabled',
|
||
};
|
||
|
||
_pc = await createPeerConnection(configuration);
|
||
|
||
_pc!.onIceCandidate = _onIceCandidate;
|
||
_pc!.onIceConnectionState = _onIceConnectionState;
|
||
_pc!.onTrack = _onTrack;
|
||
_pc!.onDataChannel = _onDataChannel;
|
||
|
||
// 仅接收远端视频(recvonly)。
|
||
await _pc!.addTransceiver(
|
||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||
init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly),
|
||
);
|
||
|
||
// 创建控制用 DataChannel。
|
||
// 使用非可靠、无序模式以降低延迟,解决“不跟手”问题。
|
||
final dcInit = RTCDataChannelInit()
|
||
..ordered = false
|
||
..maxRetransmits = 0;
|
||
_dataChannel = await _pc!.createDataChannel(kDataChannelLabel, dcInit);
|
||
_setupDataChannel(_dataChannel!);
|
||
|
||
// 创建自编码视频透传通道(无序、不可靠,类似 RTP,最低延迟)。
|
||
// 被控端仅在收到 SET_STREAM_MODE=自编码 后才经此通道推送 H.264 裸流。
|
||
final videoInit = RTCDataChannelInit()
|
||
..ordered = false
|
||
..maxRetransmits = 0;
|
||
_videoDataChannel = await _pc!.createDataChannel(
|
||
kVideoChannelLabel,
|
||
videoInit,
|
||
);
|
||
_setupVideoDataChannel(_videoDataChannel!);
|
||
|
||
// 创建并发送 Offer。
|
||
final constraints = {
|
||
'mandatory': {
|
||
'OfferToReceiveVideo': true,
|
||
'OfferToReceiveAudio': false,
|
||
},
|
||
'optional': [],
|
||
};
|
||
final offer = await _pc!.createOffer(constraints);
|
||
// 与 WebRTCControlled 的 optimizeSdp 保持一致:Offer 的 m=video 行优先 VP8/VP9,
|
||
// H264 兜底,确保各控制端(桌面/浏览器/Chromium/Linux)都能稳定解码,
|
||
// 即编码端(被控端)与解码端(控制端)协商出一致的、可解码的视频格式。
|
||
final reorderedSdp = _preferVideoCodecs(offer.sdp!);
|
||
final offerWithPref = RTCSessionDescription(reorderedSdp, offer.type);
|
||
await _pc!.setLocalDescription(offerWithPref);
|
||
_sendOffer(reorderedSdp, authType: authType, authValue: authValue);
|
||
}
|
||
|
||
/// 重排 Offer SDP 中 `m=video` 行的视频编解码器顺序:VP8/VP9 优先,H264 兜底。
|
||
///
|
||
/// 与 Android 被控端 [WebRtcClient.optimizeSdp] 的编解码优先级保持一致,
|
||
/// 保证协商出的编码格式控制端一定可解码(编码/解码一致性)。
|
||
String _preferVideoCodecs(String sdp) {
|
||
final lines = sdp.split('\r\n');
|
||
final vp8 = <String>[];
|
||
final vp9 = <String>[];
|
||
final h264 = <String>[];
|
||
for (final line in lines) {
|
||
final t = line.trim();
|
||
if (t.startsWith('a=rtpmap:')) {
|
||
final payload = t.split(':')[1].split(' ')[0];
|
||
if (t.contains('VP8/90000')) {
|
||
vp8.add(payload);
|
||
} else if (t.contains('VP9/90000')) {
|
||
vp9.add(payload);
|
||
} else if (t.contains('H264/90000')) {
|
||
h264.add(payload);
|
||
}
|
||
}
|
||
}
|
||
final ordered = <String>[...vp8, ...vp9, ...h264];
|
||
if (ordered.isEmpty) return sdp;
|
||
|
||
final result = <String>[];
|
||
for (final line in lines) {
|
||
final t = line.trim();
|
||
if (t.startsWith('m=video')) {
|
||
final parts = t.split(' ');
|
||
if (parts.length > 3) {
|
||
final head = '${parts[0]} ${parts[1]} ${parts[2]}';
|
||
final rest = parts
|
||
.skip(3)
|
||
.where((p) => !ordered.contains(p))
|
||
.toList();
|
||
result.add([head, ...ordered, ...rest].join(' '));
|
||
continue;
|
||
}
|
||
}
|
||
result.add(line);
|
||
}
|
||
return result.join('\r\n');
|
||
}
|
||
|
||
void _onTrack(RTCTrackEvent event) {
|
||
if (event.track.kind != 'video') return;
|
||
_bindRemoteVideo(event);
|
||
}
|
||
|
||
/// 绑定远端视频轨道到渲染器。
|
||
///
|
||
/// 与 Web 端 WebRtcController.ontrack 的兜底逻辑保持一致:
|
||
/// 某些平台/协商场景下(Unified Plan + recvonly)`event.streams` 可能为空,
|
||
/// 此时必须用 `event.track` 自行构造 MediaStream,否则 renderer.srcObject
|
||
/// 为空 -> 控制端拿不到画面(黑屏/一直显示“等待画面”),但控制通道不受影响。
|
||
Future<void> _bindRemoteVideo(RTCTrackEvent event) async {
|
||
MediaStream stream;
|
||
if (event.streams.isNotEmpty) {
|
||
stream = event.streams[0];
|
||
} else {
|
||
// event.streams 为空:用 track 自行构造 MediaStream。
|
||
stream = await createLocalMediaStream('remoteVideo');
|
||
await stream.addTrack(event.track);
|
||
}
|
||
renderer.srcObject = stream;
|
||
onRemoteStream?.call(renderer);
|
||
// 仅在 WebRTC 全托管模式 attach 解码耗时探针(自编码模式用自己的解码器统计)。
|
||
if (_selfCodecDecoder == null) {
|
||
final trackId = event.track.id;
|
||
if (trackId != null) _attachWebRtcDecodeProbe(trackId);
|
||
}
|
||
}
|
||
|
||
/// 解码耗时探针通道(原生侧给远端视频轨道 attach 一个测量 VideoSink)。
|
||
static const MethodChannel _decodeProbeChannel =
|
||
MethodChannel('webrtc_decode_probe');
|
||
|
||
Future<void> _attachWebRtcDecodeProbe(String trackId) async {
|
||
try {
|
||
await _decodeProbeChannel.invokeMethod<void>(
|
||
'attachWebRtcDecodeProbe', {'trackId': trackId});
|
||
} catch (e) {
|
||
debugPrint('[decodeProbe] attach failed: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _detachWebRtcDecodeProbe() async {
|
||
try {
|
||
await _decodeProbeChannel.invokeMethod<void>('detachWebRtcDecodeProbe');
|
||
} catch (e) {
|
||
debugPrint('[decodeProbe] detach failed: $e');
|
||
}
|
||
}
|
||
|
||
Future<_DecodeProbeStats?> _getWebRtcDecodeStats() async {
|
||
try {
|
||
final res =
|
||
await _decodeProbeChannel.invokeMethod<dynamic>('getWebRtcDecodeStats');
|
||
if (res is Map) {
|
||
final m = res.cast<String, dynamic>();
|
||
return _DecodeProbeStats(
|
||
lastMs: (m['lastMs'] as num? ?? 0).toDouble(),
|
||
avgMs: (m['avgMs'] as num? ?? 0).toDouble(),
|
||
peakMs: (m['peakMs'] as num? ?? 0).toDouble(),
|
||
totalFrames: (m['totalFrames'] as num? ?? 0).toInt(),
|
||
fpsNow: (m['fpsNow'] as num? ?? 0).toInt(),
|
||
fpsAvg: (m['fpsAvg'] as num? ?? 0).toInt(),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[decodeProbe] getStats failed: $e');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
void _onDataChannel(RTCDataChannel channel) {
|
||
// 被控端也可能主动创建 DataChannel,按标签统一处理。
|
||
if (channel.label == kDataChannelLabel) {
|
||
_dataChannel ??= channel;
|
||
_setupDataChannel(channel);
|
||
} else if (channel.label == kVideoChannelLabel) {
|
||
_videoDataChannel ??= channel;
|
||
_setupVideoDataChannel(channel);
|
||
}
|
||
}
|
||
|
||
void _setupDataChannel(RTCDataChannel channel) {
|
||
channel.onMessage = (RTCDataChannelMessage message) {
|
||
if (message.isBinary) {
|
||
try {
|
||
final command = ControlMessage.fromBuffer(message.binary);
|
||
_handleControlMessage(command);
|
||
} catch (e) {
|
||
// ignore: avoid_print
|
||
print('DataChannel message decode failed: $e');
|
||
}
|
||
} else {
|
||
// ignore: avoid_print
|
||
print('DataChannel text message: ${message.text}');
|
||
}
|
||
};
|
||
channel.onDataChannelState = (RTCDataChannelState state) {
|
||
// ignore: avoid_print
|
||
print('DataChannel state: $state');
|
||
};
|
||
}
|
||
|
||
void _setupVideoDataChannel(RTCDataChannel channel) {
|
||
channel.onMessage = (RTCDataChannelMessage message) {
|
||
if (message.isBinary) {
|
||
_selfCodecDecoder?.feed(message.binary);
|
||
}
|
||
};
|
||
channel.onDataChannelState = (RTCDataChannelState state) {
|
||
if (state == RTCDataChannelState.RTCDataChannelOpen) {
|
||
_onVideoChannelOpen();
|
||
} else if (state == RTCDataChannelState.RTCDataChannelClosed) {
|
||
_selfCodecDecoder?.setEnabled(false);
|
||
}
|
||
};
|
||
}
|
||
|
||
void _onVideoChannelOpen() {
|
||
_ensureSelfCodecDecoder().then((_) {
|
||
_selfCodecDecoder?.setEnabled(_currentStreamMode == streamModeSelfCodec);
|
||
});
|
||
}
|
||
|
||
Future<void> _ensureSelfCodecDecoder() async {
|
||
if (_selfCodecDecoder != null || _creatingDecoder) return;
|
||
_creatingDecoder = true;
|
||
try {
|
||
final decoder = SelfCodecDecoder(
|
||
onSize: (w, h) {
|
||
_selfCodecWidth = w;
|
||
_selfCodecHeight = h;
|
||
onResolutionReported?.call(w, h);
|
||
},
|
||
onError: (err) => debugPrint('自编码解码器: $err'),
|
||
);
|
||
final textureId = await decoder.create();
|
||
if (textureId != null) {
|
||
_selfCodecDecoder = decoder;
|
||
onSelfCodecReady?.call(textureId);
|
||
} else {
|
||
// 当前平台(Windows / Linux / Web / iOS 等)无原生硬解能力,回退 WebRTC 媒体流。
|
||
onSelfCodecNotSupported?.call();
|
||
}
|
||
} finally {
|
||
_creatingDecoder = false;
|
||
}
|
||
}
|
||
|
||
void _handleControlMessage(ControlMessage msg) {
|
||
switch (msg.action) {
|
||
case Action.REPORT_STREAM_MODE:
|
||
_currentStreamMode = msg.streamMode;
|
||
if (msg.streamMode == streamModeSelfCodec) {
|
||
_ensureSelfCodecDecoder().then((_) {
|
||
_selfCodecDecoder?.setEnabled(true);
|
||
});
|
||
} else {
|
||
_selfCodecDecoder?.setEnabled(false);
|
||
}
|
||
onStreamModeReport?.call(msg.streamMode);
|
||
break;
|
||
case Action.REPORT_RESOLUTION:
|
||
if (msg.width > 0 && msg.height > 0) {
|
||
_selfCodecWidth = msg.width;
|
||
_selfCodecHeight = msg.height;
|
||
}
|
||
onResolutionReported?.call(msg.width, msg.height);
|
||
break;
|
||
default:
|
||
// ignore: avoid_print
|
||
print('DataChannel message: ${msg.action}');
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// 请求被控端切换屏幕串流模式(SET_STREAM_MODE 指令)。
|
||
/// [mode] 为 0=WebRTC 全托管 / 1=自编码。
|
||
Future<void> sendStreamMode(int mode) async {
|
||
_currentStreamMode = mode;
|
||
if (mode == streamModeSelfCodec) {
|
||
await _ensureSelfCodecDecoder();
|
||
await _selfCodecDecoder?.setEnabled(true);
|
||
} else {
|
||
await _selfCodecDecoder?.setEnabled(false);
|
||
}
|
||
sendControlCommand(ControlCommands.streamMode(mode));
|
||
}
|
||
|
||
void _onIceCandidate(RTCIceCandidate candidate) {
|
||
final payload = {
|
||
'sdpMid': candidate.sdpMid,
|
||
'sdpMLineIndex': candidate.sdpMLineIndex,
|
||
'candidate': candidate.candidate,
|
||
};
|
||
final msg = SignalMessage.withPayload(
|
||
type: 'ICE_CANDIDATE',
|
||
fromDeviceId: deviceId,
|
||
toDeviceId: targetDeviceId,
|
||
deviceType: 'CONTROLLER',
|
||
payload: payload,
|
||
);
|
||
signaling.send(msg);
|
||
}
|
||
|
||
void _onIceConnectionState(RTCIceConnectionState state) {
|
||
// ignore: avoid_print
|
||
print('ICE connection state: $state');
|
||
if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
|
||
_connectedAt = DateTime.now().millisecondsSinceEpoch;
|
||
onConnectionEstablished?.call();
|
||
} else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
|
||
onIceDisconnected?.call('ICE 连接已断开');
|
||
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
|
||
onConnectionFailed?.call('ICE 连接失败');
|
||
onDisconnected?.call();
|
||
}
|
||
}
|
||
|
||
void _sendOffer(String sdp, {String? authType, String? authValue}) {
|
||
final payload = {'sdp': sdp};
|
||
final msg = SignalMessage.withPayload(
|
||
type: 'OFFER',
|
||
fromDeviceId: deviceId,
|
||
toDeviceId: targetDeviceId,
|
||
deviceType: 'CONTROLLER',
|
||
payload: payload,
|
||
authType: authType,
|
||
authValue: authValue,
|
||
);
|
||
signaling.send(msg);
|
||
}
|
||
|
||
/// 处理来自信令服务器的 Answer。
|
||
Future<void> handleAnswer(String sdp) async {
|
||
final answer = RTCSessionDescription(sdp, 'answer');
|
||
await _pc?.setRemoteDescription(answer);
|
||
}
|
||
|
||
/// 处理来自信令服务器的 ICE 候选。
|
||
Future<void> handleIceCandidate(Map<String, dynamic> payload) async {
|
||
final candidate = RTCIceCandidate(
|
||
payload['candidate'] as String,
|
||
payload['sdpMid'] as String?,
|
||
payload['sdpMLineIndex'] as int?,
|
||
);
|
||
await _pc?.addCandidate(candidate);
|
||
}
|
||
|
||
/// 通过 DataChannel 发送控制指令(protobuf 二进制)。
|
||
void sendControlCommand(ControlMessage command) {
|
||
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) {
|
||
debugPrint('WebRtcController: Sending command -> ${command.action}');
|
||
_dataChannel!.send(RTCDataChannelMessage.fromBinary(command.writeToBuffer()));
|
||
}
|
||
}
|
||
|
||
/// 请求被控端切换屏幕采集分辨率(SET_RESOLUTION 指令)。
|
||
void sendResolutionChange(int width, int height, int fps) {
|
||
sendControlCommand(ControlCommands.setResolution(width, height, fps));
|
||
}
|
||
|
||
/// 上一次统计采样的字节数与时间戳,用于计算每秒网速。
|
||
int _prevBytesReceived = 0;
|
||
int _prevBytesSent = 0;
|
||
int _prevStatsTimestamp = 0;
|
||
|
||
/// 上一次采样时的累计解码帧数,用于计算自编码模式下的解码帧率。
|
||
int _prevDecodeCount = 0;
|
||
|
||
/// WebRTC 全托管模式下,由原生 VideoSink 探针实测的解码耗时统计缓存。
|
||
_DecodeProbeStats? _webRtcDecodeProbe;
|
||
|
||
/// 解码器上报的真实分辨率(自编码模式下 WebRTC 视频统计为空,需用此覆盖)。
|
||
int _selfCodecWidth = 0;
|
||
int _selfCodecHeight = 0;
|
||
|
||
/// 连接建立时的时间戳(毫秒),用于计算连接时长。
|
||
int? _connectedAt;
|
||
|
||
/// 采集连接统计信息,回调格式化后的文本(对应 Android 端 updateStats)。
|
||
///
|
||
/// 包含:分辨率/帧率/延迟/解码格式、抖动、丢包率、丢帧、
|
||
/// 每秒上下行网速、连接类型(P2P/TURN)+候选协议、连接时长、
|
||
/// 控制 DataChannel 状态与收发消息数。
|
||
Future<String> getStatsText() async {
|
||
if (_pc == null) return '';
|
||
final reports = await _pc!.getStats();
|
||
|
||
dynamic width = '-';
|
||
dynamic height = '-';
|
||
dynamic fps = '-';
|
||
dynamic delay = '-';
|
||
dynamic codec = '-';
|
||
dynamic jitter = '-';
|
||
dynamic lossRate = '-';
|
||
dynamic framesDropped = '-';
|
||
|
||
int bytesReceived = 0;
|
||
int bytesSent = 0;
|
||
String? localCandidateId;
|
||
String? remoteCandidateId;
|
||
bool hasNominatedPair = false;
|
||
|
||
// 控制 DataChannel 统计(仅统计与对端的控制通道)。
|
||
int dcMessagesSent = 0;
|
||
int dcMessagesReceived = 0;
|
||
|
||
// 与上次采样的时间差(秒),供循环内估算解码帧率,也供下方计算每秒网速。
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final double dtSec =
|
||
_prevStatsTimestamp != 0 ? (now - _prevStatsTimestamp) / 1000.0 : 0;
|
||
|
||
for (final report in reports) {
|
||
final values = report.values;
|
||
final type = report.type;
|
||
if (type == 'inbound-rtp' && values['kind'] == 'video') {
|
||
width = values['frameWidth'] ?? '-';
|
||
height = values['frameHeight'] ?? '-';
|
||
fps = values['framesPerSecond'] ?? '-';
|
||
|
||
final j = values['jitter'];
|
||
if (j is num) jitter = (j * 1000).toStringAsFixed(0);
|
||
|
||
final pr = values['packetsReceived'];
|
||
final pl = values['packetsLost'];
|
||
if (pr is num && pl is num) {
|
||
final total = pr + pl;
|
||
lossRate = total > 0 ? ((pl / total) * 100).toStringAsFixed(1) : '0.0';
|
||
}
|
||
|
||
final fd = values['framesDropped'];
|
||
if (fd is num) framesDropped = fd;
|
||
|
||
final codecId = values['codecId'];
|
||
if (codecId != null) {
|
||
final codecReport = reports.where((r) => r.id == codecId).firstOrNull;
|
||
final mime = codecReport?.values['mimeType'];
|
||
if (mime is String && mime.startsWith('video/')) {
|
||
codec = mime.substring(6);
|
||
}
|
||
}
|
||
} else if (type == 'candidate-pair') {
|
||
if (values['nominated'] == true) {
|
||
hasNominatedPair = true;
|
||
final rtt = values['currentRoundTripTime'];
|
||
if (rtt is num) {
|
||
delay = (rtt * 1000).toStringAsFixed(0);
|
||
}
|
||
// candidate-pair 的 bytesReceived/bytesSent 为整条连接(视频+DataChannel+ICE)的累计值。
|
||
final br = values['bytesReceived'];
|
||
final bs = values['bytesSent'];
|
||
if (br is num) bytesReceived = br.toInt();
|
||
if (bs is num) bytesSent = bs.toInt();
|
||
final lc = values['localCandidateId'];
|
||
final rc = values['remoteCandidateId'];
|
||
if (lc is String) localCandidateId = lc;
|
||
if (rc is String) remoteCandidateId = rc;
|
||
}
|
||
} else if (type == 'data-channel') {
|
||
if (values['label'] == kDataChannelLabel) {
|
||
final ms = values['messagesSent'];
|
||
final mr = values['messagesReceived'];
|
||
if (ms is num) dcMessagesSent = ms.toInt();
|
||
if (mr is num) dcMessagesReceived = mr.toInt();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 计算每秒网速(与上次采样差值)。
|
||
String downSpeed = '-';
|
||
String upSpeed = '-';
|
||
if (dtSec > 0) {
|
||
final downBps = (bytesReceived - _prevBytesReceived) / dtSec;
|
||
final upBps = (bytesSent - _prevBytesSent) / dtSec;
|
||
downSpeed = _formatSpeed(downBps);
|
||
upSpeed = _formatSpeed(upBps);
|
||
}
|
||
_prevBytesReceived = bytesReceived;
|
||
_prevBytesSent = bytesSent;
|
||
_prevStatsTimestamp = now;
|
||
|
||
// 连接类型(P2P/TURN)与候选协议(udp/tcp)。
|
||
String connType = '-';
|
||
String protocol = '-';
|
||
if (hasNominatedPair) {
|
||
connType =
|
||
_resolveConnectionType(reports, localCandidateId, remoteCandidateId);
|
||
protocol = _resolveProtocol(reports, localCandidateId, remoteCandidateId);
|
||
}
|
||
|
||
// 连接时长。
|
||
String duration = '-';
|
||
if (_connectedAt != null) {
|
||
final secs = ((now - _connectedAt!) / 1000).floor();
|
||
final m = (secs ~/ 60).toString().padLeft(2, '0');
|
||
final s = (secs % 60).toString().padLeft(2, '0');
|
||
duration = '$m:$s';
|
||
}
|
||
|
||
// 控制 DataChannel 状态。
|
||
final dcState = _dataChannel?.state;
|
||
final dcStatus = dcState == RTCDataChannelState.RTCDataChannelOpen
|
||
? '已连接'
|
||
: (dcState == null ? '未连接' : dcState.name);
|
||
|
||
// —— 自编码(自建 MediaCodec 硬解)补充:真实分辨率 + 解码耗时 ——
|
||
// 自编码模式视频走 DataChannel 透传裸码流,WebRTC 的视频统计(分辨率/帧率/
|
||
// 解码格式)均为空。这里只要解码器处于活动状态就读取原生解码统计,用解码器
|
||
// 上报的真实分辨率/解码帧率覆盖 WebRTC 的空值,并把解码耗时一并显示,
|
||
// 确保「分辨率」与「解码耗时」这些信息显示在一起。
|
||
String? decodeInfo;
|
||
// 基础行要展示的解码耗时:WebRTC 模式用 inbound-rtp 估算,自编码模式用原生平均。
|
||
double nativeAvg = 0;
|
||
if (_selfCodecDecoder != null) {
|
||
final ds = await _selfCodecDecoder!.getStats();
|
||
if (ds != null) {
|
||
final last = (ds['lastMs'] as num?)?.toDouble() ?? 0;
|
||
final avg = (ds['avgMs'] as num?)?.toDouble() ?? 0;
|
||
final peak = (ds['maxMs'] as num?)?.toDouble() ?? 0;
|
||
final cnt = (ds['count'] as num?)?.toInt() ?? 0;
|
||
nativeAvg = avg;
|
||
if (_selfCodecWidth > 0 && _selfCodecHeight > 0) {
|
||
width = _selfCodecWidth;
|
||
height = _selfCodecHeight;
|
||
}
|
||
double decodeFps = 0;
|
||
if (dtSec > 0 && cnt > 0) {
|
||
decodeFps = (cnt - _prevDecodeCount) / dtSec;
|
||
}
|
||
_prevDecodeCount = cnt;
|
||
if (decodeFps > 0) fps = decodeFps.round();
|
||
decodeInfo = '解码耗时: ${last.toStringAsFixed(1)} ms 平均: ${avg.toStringAsFixed(1)} ms 峰值: ${peak.toStringAsFixed(1)} ms\n'
|
||
'解码帧率: ${decodeFps.toStringAsFixed(0)} fps 已解码: $cnt 帧';
|
||
}
|
||
}
|
||
// 无活动解码器时清零计数,避免重连后帧率计算异常。
|
||
if (_selfCodecDecoder == null) _prevDecodeCount = 0;
|
||
|
||
// WebRTC 全托管模式:flutter_webrtc 1.5.2 不报 totalDecodeTime / framesDecoded,
|
||
// 改为由原生 VideoSink 探针逐帧实测真实解码耗时(last/avg/peak)与帧率。
|
||
// 探针在远端视频轨道额外挂一个 VideoSink,记录每帧真实到达时间,算出帧间
|
||
// 耗时(≈ 真实解码+渲染周期),是原生层实测值,不会恒为 0。
|
||
if (_selfCodecDecoder == null) {
|
||
_webRtcDecodeProbe = await _getWebRtcDecodeStats();
|
||
final p = _webRtcDecodeProbe;
|
||
if (p != null && p.totalFrames > 0) {
|
||
nativeAvg = p.avgMs;
|
||
decodeInfo =
|
||
'解码耗时: ${p.lastMs.toStringAsFixed(1)} ms 平均: ${p.avgMs.toStringAsFixed(1)} ms 峰值: ${p.peakMs.toStringAsFixed(1)} ms\n'
|
||
'解码帧率: ${p.fpsNow} fps (平均 ${p.fpsAvg} fps) 已解码: ${p.totalFrames} 帧';
|
||
}
|
||
}
|
||
|
||
String decodeTimeText = '-';
|
||
if (decodeInfo != null && nativeAvg > 0) {
|
||
decodeTimeText = '${nativeAvg.toStringAsFixed(1)} ms';
|
||
}
|
||
|
||
final base = '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms 解码耗时: $decodeTimeText\n'
|
||
'解码格式: $codec 抖动: $jitter ms 丢包: $lossRate%\n'
|
||
'丢帧: $framesDropped ↓下载: $downSpeed ↑上传: $upSpeed\n'
|
||
'连接类型: $connType ($protocol) 时长: $duration\n'
|
||
'控制通道: $dcStatus 收发: $dcMessagesSent/$dcMessagesReceived';
|
||
|
||
return decodeInfo != null ? '$base\n$decodeInfo' : base;
|
||
}
|
||
|
||
/// 依据选定候选对的本地/远端候选类型,返回 P2P / TURN 中继描述。
|
||
String _resolveConnectionType(
|
||
List<StatsReport> reports,
|
||
String? localCandidateId,
|
||
String? remoteCandidateId,
|
||
) {
|
||
String? typeOf(String? id) {
|
||
if (id == null) return null;
|
||
final report = reports.where((r) => r.id == id).firstOrNull;
|
||
final t = report?.values['candidateType'];
|
||
return t is String ? t : null;
|
||
}
|
||
|
||
final local = typeOf(localCandidateId);
|
||
final remote = typeOf(remoteCandidateId);
|
||
final isRelay = local == 'relay' || remote == 'relay';
|
||
|
||
if (isRelay) {
|
||
// 进一步用本地候选具体类型细化(优先说明本端是否经 TURN 中继)。
|
||
if (local == 'relay') return 'TURN 中继 (本端经服务器)';
|
||
return 'TURN 中继';
|
||
}
|
||
// host: 同一局域网直连; srflx/prflx: 经 NAT 打洞的 P2P。
|
||
return 'P2P 直连';
|
||
}
|
||
|
||
/// 返回选定候选对的传输协议(udp/tcp)。
|
||
String _resolveProtocol(
|
||
List<StatsReport> reports,
|
||
String? localCandidateId,
|
||
String? remoteCandidateId,
|
||
) {
|
||
String protoOf(String? id) {
|
||
if (id == null) return '-';
|
||
final report = reports.where((r) => r.id == id).firstOrNull;
|
||
final p = report?.values['protocol'];
|
||
return p is String ? p : '-';
|
||
}
|
||
|
||
final local = protoOf(localCandidateId);
|
||
final remote = protoOf(remoteCandidateId);
|
||
if (local != '-' && remote != '-' && local != remote) {
|
||
return '$local/$remote';
|
||
}
|
||
return local != '-' ? local : remote;
|
||
}
|
||
|
||
/// 将字节/秒格式化为人类可读字符串(B/s、KB/s、MB/s)。
|
||
String _formatSpeed(double bytesPerSecond) {
|
||
if (bytesPerSecond >= 1024 * 1024) {
|
||
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s';
|
||
} else if (bytesPerSecond >= 1024) {
|
||
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
|
||
}
|
||
return '${bytesPerSecond.toStringAsFixed(0)} B/s';
|
||
}
|
||
|
||
/// 释放所有资源。
|
||
Future<void> close() async {
|
||
try {
|
||
await _dataChannel?.close();
|
||
} catch (_) {}
|
||
try {
|
||
await _detachWebRtcDecodeProbe();
|
||
} catch (_) {}
|
||
try {
|
||
await _pc?.close();
|
||
} catch (_) {}
|
||
try {
|
||
await renderer.dispose();
|
||
} catch (_) {}
|
||
_dataChannel = null;
|
||
try {
|
||
await _videoDataChannel?.close();
|
||
} catch (_) {}
|
||
_videoDataChannel = null;
|
||
try {
|
||
await _selfCodecDecoder?.dispose();
|
||
} catch (_) {}
|
||
_selfCodecDecoder = null;
|
||
_currentStreamMode = streamModeWebRtc;
|
||
onSelfCodecLost?.call();
|
||
onSelfCodecReady = null;
|
||
onStreamModeReport = null;
|
||
onResolutionReported = null;
|
||
onSelfCodecNotSupported = null;
|
||
_pc = null;
|
||
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。
|
||
_prevBytesReceived = 0;
|
||
_prevBytesSent = 0;
|
||
_prevStatsTimestamp = 0;
|
||
_prevDecodeCount = 0;
|
||
_webRtcDecodeProbe = null;
|
||
_selfCodecWidth = 0;
|
||
_selfCodecHeight = 0;
|
||
_connectedAt = null;
|
||
}
|
||
}
|