docs(webrtc_controller_flutter): 更新项目文档以反映重构后的架构

AGENTS.md 与 README.md 同步更新:根据实际代码结构重写目录树、技术栈、架构分层及编码规范,移除旧版内联示例并补充新的开发约定与代码生成命令。
This commit is contained in:
2026-08-03 16:12:44 +08:00
parent 1918e5738e
commit d5e66a1777
51 changed files with 4711 additions and 1458 deletions

View File

@@ -0,0 +1,859 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../../../app/constants/app_constants.dart';
import '../../../app/constants/ice_servers.dart';
import '../../../core/proto/control_message.pb.dart';
import '../../../core/utils/control_commands.dart';
import '../domain/signal_message.dart';
import 'self_codec_decoder.dart';
import 'signaling_client.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
///
/// 职责:
/// - 创建 PeerConnectionrecvonly 视频 + 控制用 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;
/// 被控端上报当前采集帧率与支持的帧率档位(仅 REPORT_RESOLUTION 触发)。
void Function(int width, int height, int fps, List<int> supportedFps)?
onFpsReport;
/// 当前平台Windows / Linux / Web / iOS 等)不支持原生硬解时回调。
void Function()? onSelfCodecNotSupported;
static const String kVideoChannelLabel = 'video_channel';
static const int streamModeWebRtc = 0;
static const int streamModeSelfCodec = 1;
/// 由服务端下发的 TURN 短期凭证覆盖默认 ICE 配置(为空则用 kIceServers
static List<Map<String, dynamic>>? iceServersOverride;
WebRtcController({
required this.signaling,
required this.deviceId,
required this.targetDeviceId,
this.authType,
this.authValue,
});
/// 初始化渲染器、PeerConnection并创建 Offer。
Future<void> initialize() async {
await renderer.initialize();
final iceServers = iceServersOverride ?? kIceServers;
final configuration = {
'iceServers': iceServers,
'sdpSemantics': 'unified-plan',
'iceCandidatePoolSize': 10,
// 增强复杂网络下的稳定性,参考 Android 端配置。
'continualGatheringPolicy': 'gatherContinually',
'iceTransportsType': 'all',
'tcpCandidatePolicy': 'enabled',
};
final iceUrls = iceServers
.map((e) => '${e['username'] != null ? '(${e['username']})' : ''}${e['urls']}')
.join(', ');
debugPrint('[WebRtcController] signaling server: ${signaling.serverUrl} | '
'deviceId=$deviceId target=$targetDeviceId');
debugPrint('[WebRtcController] ICE servers: $iceUrls');
_pc = await createPeerConnection(configuration);
_pc!.onIceCandidate = _onIceCandidate;
_pc!.onIceConnectionState = _onIceConnectionState;
_pc!.onIceGatheringState = (state) {
debugPrint('[WebRtcController] ICE gathering state: $state');
};
_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 {
debugPrint('[WebRtcController] Attaching decode probe for track: $trackId');
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);
// 同步帧率信息(当前帧率 + 被控端按刷新率筛选的帧率档位)
onFpsReport?.call(
msg.width, msg.height, msg.fps, msg.supportedFps.toList());
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) {
debugPrint('[WebRtcController] local ICE candidate: ${candidate.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);
}
/// 打印 ICE candidate-pair 统计,用于诊断连接失败原因。
Future<void> _dumpIceStats() async {
try {
final stats = await _pc?.getStats();
final pairs = <String>[];
for (final report in stats ?? []) {
if (report.type == 'candidate-pair') {
final state = report.values['state'] ?? report.values['nominated'];
pairs.add('${report.values['localCandidateId']}->'
'${report.values['remoteCandidateId']} state=$state');
}
}
debugPrint('[WebRtcController] ICE Failed. candidate-pair 数量=${pairs.length}');
for (final p in pairs) {
debugPrint('[WebRtcController] pair: $p');
}
} catch (e) {
debugPrint('[WebRtcController] getStats 失败: $e');
}
}
void _onIceConnectionState(RTCIceConnectionState state) {
// ignore: avoid_print
print('ICE connection state: $state');
if (state == RTCIceConnectionState.RTCIceConnectionStateChecking) {
debugPrint('[WebRtcController] ICE Checking: 正在收集/交换候选,等待连通...');
} else if (state == RTCIceConnectionState.RTCIceConnectionStateConnected) {
_connectedAt = DateTime.now().millisecondsSinceEpoch;
onConnectionEstablished?.call();
} else if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
onIceDisconnected?.call('ICE 连接已断开');
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
_dumpIceStats();
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?,
);
debugPrint('[WebRtcController] remote ICE candidate: ${candidate.candidate}');
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 模式下的解码耗时。
double _prevTotalDecodeTime = 0.0;
int _prevFramesDecoded = 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;
double currentTotalDecodeTime = 0.0;
int currentFramesDecoded = 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') {
// 关键数据打印,用于排查 totalDecodeTime 是否存在于当前平台的统计中
debugPrint('[WebRtcController] inbound-rtp: totalDecodeTime=${values['totalDecodeTime']}, '
'googDecodeMs=${values['googDecodeMs']}, '
'framesDecoded=${values['framesDecoded']}, '
'fps=${values['framesPerSecond']}');
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;
// 核心:解析解码统计(兼容标准字段与旧版 Google 私有字段)
currentTotalDecodeTime = (values['totalDecodeTime'] as num?)?.toDouble() ??
((values['googDecodeMs'] as num?)?.toDouble() ?? 0.0) / 1000.0;
currentFramesDecoded = (values['framesDecoded'] as num?)?.toInt() ??
(values['googFramesDecoded'] as num?)?.toInt() ?? 0;
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);
// —— 分模式统计解码耗时 ——
String? decodeInfo;
// 基础行要展示的解码耗时WebRTC 模式用 inbound-rtp 估算,自编码模式用原生平均。
double nativeAvg = 0;
if (_currentStreamMode == streamModeSelfCodec) {
// 【自编码模式】:视频走 DataChannel 透传裸码流WebRTC 的视频统计为空。
// 使用原生解码器上报的真实分辨率、帧率与耗时。
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';
}
}
// 自编码模式下WebRTC 的相关计数建议重置,避免切回时出现跳变。
_prevTotalDecodeTime = currentTotalDecodeTime;
_prevFramesDecoded = currentFramesDecoded;
} else {
// 【WebRTC 模式】:优先使用标准的 getStats() 增量计算耗时。
double avg = 0;
if (currentFramesDecoded > 0) {
if (_prevFramesDecoded > 0 && currentFramesDecoded > _prevFramesDecoded) {
final deltaMs = (currentTotalDecodeTime - _prevTotalDecodeTime) * 1000.0;
final deltaFrames = currentFramesDecoded - _prevFramesDecoded;
if (deltaFrames > 0) avg = deltaMs / deltaFrames;
} else if (currentTotalDecodeTime > 0) {
// 兜底:第一次采样或帧数无增量时,用全量平均作为初始参考
avg = (currentTotalDecodeTime * 1000.0) / currentFramesDecoded;
}
}
if (avg > 0) {
nativeAvg = avg;
decodeInfo = '解码耗时 (stats): ${avg.toStringAsFixed(1)} ms\n'
'解码帧率: $fps fps 已解码: $currentFramesDecoded';
} else {
// 备选方案:如果 getStats 没报数据(部分平台/环境限制),则回退到原生 VideoSink 探针实测模式。
_webRtcDecodeProbe = await _getWebRtcDecodeStats();
final p = _webRtcDecodeProbe;
if (p != null && p.totalFrames > 0) {
nativeAvg = p.avgMs;
decodeInfo =
'解码耗时 (probe): ${p.lastMs.toStringAsFixed(1)} ms 平均: ${p.avgMs.toStringAsFixed(1)} ms 峰值: ${p.peakMs.toStringAsFixed(1)} ms\n'
'解码帧率: ${p.fpsNow} fps (平均 ${p.fpsAvg} fps) 已解码: ${p.totalFrames}';
}
}
// WebRTC 模式下,自编码的计数重置。
_prevDecodeCount = 0;
_prevTotalDecodeTime = currentTotalDecodeTime;
_prevFramesDecoded = currentFramesDecoded;
}
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;
onFpsReport = null;
onSelfCodecNotSupported = null;
_pc = null;
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。
_prevBytesReceived = 0;
_prevBytesSent = 0;
_prevStatsTimestamp = 0;
_prevDecodeCount = 0;
_prevTotalDecodeTime = 0.0;
_prevFramesDecoded = 0;
_webRtcDecodeProbe = null;
_selfCodecWidth = 0;
_selfCodecHeight = 0;
_connectedAt = null;
}
}