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 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 = []; final vp9 = []; final h264 = []; 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 = [...vp8, ...vp9, ...h264]; if (ordered.isEmpty) return sdp; final result = []; 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 _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 _attachWebRtcDecodeProbe(String trackId) async { debugPrint('[WebRtcController] Attaching decode probe for track: $trackId'); try { await _decodeProbeChannel.invokeMethod( 'attachWebRtcDecodeProbe', {'trackId': trackId}); } catch (e) { debugPrint('[decodeProbe] attach failed: $e'); } } Future _detachWebRtcDecodeProbe() async { try { await _decodeProbeChannel.invokeMethod('detachWebRtcDecodeProbe'); } catch (e) { debugPrint('[decodeProbe] detach failed: $e'); } } Future<_DecodeProbeStats?> _getWebRtcDecodeStats() async { try { final res = await _decodeProbeChannel.invokeMethod('getWebRtcDecodeStats'); if (res is Map) { final m = res.cast(); 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 _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 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 handleAnswer(String sdp) async { final answer = RTCSessionDescription(sdp, 'answer'); await _pc?.setRemoteDescription(answer); } /// 处理来自信令服务器的 ICE 候选。 Future handleIceCandidate(Map 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 模式下的解码耗时。 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 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 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 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 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; _prevTotalDecodeTime = 0.0; _prevFramesDecoded = 0; _webRtcDecodeProbe = null; _selfCodecWidth = 0; _selfCodecHeight = 0; _connectedAt = null; } }