diff --git a/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt b/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt index 5c5a44b..19b2ffb 100644 --- a/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt +++ b/webrtc_controller_flutter/android/app/src/main/kotlin/com/ttstd/fluttercontroller/SelfCodecDecoderPlugin.kt @@ -16,6 +16,10 @@ import io.flutter.view.TextureRegistry import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder +import org.webrtc.VideoFrame +import org.webrtc.VideoSink +import org.webrtc.VideoTrack +import com.cloudwebrtc.webrtc.FlutterWebRTCPlugin private const val CHANNEL = "com.ttstd.fluttercontroller/self_codec" private const val TAG = "SelfCodecDecoderPlugin" @@ -27,6 +31,8 @@ private const val UNIT_TYPE_CONFIG = 1 private const val UNIT_TYPE_FRAME = 2 private const val PLACEHOLDER_W = 1920 private const val PLACEHOLDER_H = 1080 +// 解码耗时滑动平均窗口(帧数)。 +private const val DECODE_STATS_WINDOW = 90 /// 单个单元的分片重组缓冲。 private class ChunkBuffer(val seq: Int, val total: Int) { @@ -52,6 +58,80 @@ private class ChunkBuffer(val seq: Int, val total: Int) { private class Frame(val data: ByteArray, val pts: Long, val key: Boolean) +/// WebRTC 全托管模式下的解码耗时探针:给远端视频轨道额外挂一个 [VideoSink], +/// 逐帧记录真实到达时间,计算帧间耗时(≈真实解码+渲染周期)的 last/avg/peak 与帧率。 +/// 与自编码解码器不同,这里拿不到 MediaCodec 的单帧解码开销,只能测"连续两帧被解码 +/// 交付"的真实时间间隔——它是原生层实测值,不会像 totalDecodeTime 那样恒为 0。 +private class DecodeProbeSink { + private val lock = Any() + private var lastArrivalNs: Long = 0 + private var sumMs = 0.0 + private var count = 0 + private var lastMs = 0.0 + private var avgMs = 0.0 + private var peakMs = 0.0 + private var lastFpsStampMs = 0L + private var framesSinceFps = 0 + private var fpsNow = 0 + + fun onFrame() { + val now = System.nanoTime() + synchronized(lock) { + if (lastArrivalNs != 0L) { + val deltaMs = (now - lastArrivalNs) / 1_000_000.0 + // 丢弃异常跳变(首帧 / 长时间卡顿后的大间隔),避免污染统计。 + if (deltaMs in 0.5..1000.0) { + lastMs = deltaMs + if (peakMs < deltaMs) peakMs = deltaMs + count++ + sumMs += deltaMs + avgMs = sumMs / count + // 1 秒滑动窗口估算当前帧率。 + val nowMs = System.currentTimeMillis() + framesSinceFps++ + if (lastFpsStampMs != 0L && nowMs - lastFpsStampMs >= 1000) { + fpsNow = framesSinceFps * 1000 / (nowMs - lastFpsStampMs) + framesSinceFps = 0 + lastFpsStampMs = nowMs + } else if (lastFpsStampMs == 0L) { + lastFpsStampMs = nowMs + } + } + } + lastArrivalNs = now + } + } + + fun snapshot(): Map = synchronized(lock) { + mapOf( + "lastMs" to lastMs, + "avgMs" to avgMs, + "peakMs" to peakMs, + "totalFrames" to count, + "fpsNow" to fpsNow, + "fpsAvg" to if (avgMs > 0) (1000.0 / avgMs).toInt() else 0 + ) + } + + fun reset() = synchronized(lock) { + lastArrivalNs = 0 + sumMs = 0.0 + count = 0 + lastMs = 0.0 + avgMs = 0.0 + peakMs = 0.0 + lastFpsStampMs = 0L + framesSinceFps = 0 + fpsNow = 0 + } +} + +private data class ProbeHolder( + val track: VideoTrack, + val sink: VideoSink, + val stats: DecodeProbeSink +) + /// 自编码解码器(控制端原生实现)。 /// /// 对应 WebRTCController 的 SelfCodecDecoder.java:从 `video` DataChannel 接收 @@ -63,6 +143,10 @@ class SelfCodecDecoderPlugin : private var methodChannel: MethodChannel? = null private var textureRegistry: TextureRegistry? = null + /// WebRTC 全托管模式的解码耗时探针(独立通道,复用同一 MethodCallHandler)。 + private var probeChannel: MethodChannel? = null + private var probeHolder: ProbeHolder? = null + private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null private var surfaceTexture: SurfaceTexture? = null private var surface: Surface? = null @@ -77,17 +161,34 @@ class SelfCodecDecoderPlugin : private val frameQueue = ArrayList() private val inputQueue = ArrayList() + // 已完成重组的最大单元序号,用于丢弃明显过期的旧单元。 + private var lastSeq = -1 + + // —— 解码耗时统计 —— + // 记录每帧从 queueInputBuffer 到 onOutputBufferAvailable 的时延(毫秒)。 + private val statsLock = Any() + private val decodeStartNs = HashMap() // pts(us) -> 入队 nanoTime + private val recentDecodeMs = ArrayDeque() // 最近若干帧耗时,用于滑动平均 + private var lastDecodeMs = 0.0 + private var maxDecodeMs = 0.0 + private var decodedFrames = 0L + private val mainHandler = Handler(Looper.getMainLooper()) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { textureRegistry = binding.textureRegistry methodChannel = MethodChannel(binding.binaryMessenger, CHANNEL) methodChannel?.setMethodCallHandler(this) + // 解码耗时探针(WebRTC 全托管模式):独立通道,复用同一 handler。 + probeChannel = MethodChannel(binding.binaryMessenger, "webrtc_decode_probe") + probeChannel?.setMethodCallHandler(this) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { methodChannel?.setMethodCallHandler(null) methodChannel = null + probeChannel?.setMethodCallHandler(null) + probeChannel = null textureRegistry = null } @@ -126,14 +227,75 @@ class SelfCodecDecoderPlugin : if (!enabled) releaseDecoder() result.success(null) } + "getStats" -> { + result.success(collectStats()) + } "dispose" -> { releaseAll() result.success(null) } + "attachWebRtcDecodeProbe" -> { + val trackId = call.argument("trackId") + if (trackId == null) { + result.error("PROBE", "trackId is null", null) + } else { + attachWebRtcDecodeProbe(trackId, result) + } + } + "detachWebRtcDecodeProbe" -> detachWebRtcDecodeProbe(result) + "getWebRtcDecodeStats" -> getWebRtcDecodeStats(result) else -> result.notImplemented() } } + // —— WebRTC 全托管模式解码耗时探针 —— + + private fun attachWebRtcDecodeProbe(trackId: String, result: MethodChannel.Result) { + try { + val plugin = FlutterWebRTCPlugin.sharedSingleton + val track = plugin?.getRemoteTrack(trackId) + if (track !is VideoTrack) { + result.error("PROBE", "remote video track not found: $trackId", null) + return + } + detachWebRtcDecodeProbeInternal() + val stats = DecodeProbeSink() + val sink = VideoSink { stats.onFrame() } + track.addSink(sink) + probeHolder = ProbeHolder(track, sink, stats) + result.success(null) + } catch (e: Exception) { + Log.e(TAG, "attachWebRtcDecodeProbe failed", e) + result.error("PROBE", e.message, null) + } + } + + private fun detachWebRtcDecodeProbeInternal() { + probeHolder?.let { (track, sink, stats) -> + try { + track.removeSink(sink) + } catch (e: Exception) { + Log.w(TAG, "removeSink failed", e) + } + stats.reset() + } + probeHolder = null + } + + private fun detachWebRtcDecodeProbe(result: MethodChannel.Result) { + detachWebRtcDecodeProbeInternal() + result.success(null) + } + + private fun getWebRtcDecodeStats(result: MethodChannel.Result) { + val holder = probeHolder + if (holder == null) { + result.success(null) + } else { + result.success(holder.stats.snapshot()) + } + } + private fun createInternal(): Int { if (textureEntry != null) return textureEntry!!.id().toInt() val entry = textureRegistry?.createSurfaceTexture() ?: return -1 @@ -178,14 +340,26 @@ class SelfCodecDecoderPlugin : } private fun handleChunk(seq: Int, total: Int, idx: Int, data: ByteArray) { + if (total <= 0) return + // 过期检查:同一"单元"的所有分片共用同一个 seq(以 idx 区分), + // 因此不能用 seq <= lastSeq 丢弃——否则同一单元除首片外的分片都会被误删, + // 导致大帧永远无法重组、自编码串流黑屏。这里只丢弃明显更旧(seq 明显小于 lastSeq) + // 的旧单元分片,相同 seq(同单元的其它分片)放行。 + if (lastSeq != -1 && seq < lastSeq && (lastSeq - seq) < 1000) return var buf = chunkBuffers[seq] if (buf == null) { buf = ChunkBuffer(seq, total) chunkBuffers[seq] = buf + // 防止内存堆积:重组缓冲超过 24 个未完成单元时丢弃最旧的。 + if (chunkBuffers.size > 24) { + val minKey = chunkBuffers.keys.minOrNull() + if (minKey != null) chunkBuffers.remove(minKey) + } } buf.add(idx, data) if (buf.isComplete()) { chunkBuffers.remove(seq) + if (seq > lastSeq) lastSeq = seq handleUnit(buf.join()) } } @@ -208,9 +382,21 @@ class SelfCodecDecoderPlugin : private fun handleConfig(config: ByteArray) { synchronized(configLock) { - if (configured) return // 忽略重复参数集 + // 去重:仅当队列中尚无相同参数集时加入,避免关键帧前重发造成堆积。 + // 关键:绝不能因为 configured 就 return —— 编码端把 SPS(csd-0) 与 PPS(csd-1) + // 作为两个独立的 CONFIG 单元分别发送。若第一个 CONFIG(SPS) 到达后即置 + // configured=true 而直接丢弃第二个 CONFIG(PPS),解码器只拿到 SPS 缺少 PPS, + // H.264 将无法解码 → 永久黑屏(这正是"自编码无法显示"的根因)。 + for (c in pendingConfigs) { + if (c.contentEquals(config)) return + } pendingConfigs.add(config) - tryConfigure() + if (!configured) { + tryConfigure() + } else { + // 已配置时也尝试喂入,以应对中途的参数集更新。 + feed() + } } } @@ -240,6 +426,8 @@ class SelfCodecDecoderPlugin : override fun onOutputBufferAvailable( codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { + // 用输出帧的 presentationTimeUs 匹配入队时间戳,统计解码耗时。 + recordDecodeTime(info.presentationTimeUs) try { codec.releaseOutputBuffer(index, true) } catch (e: Exception) { @@ -311,6 +499,12 @@ class SelfCodecDecoderPlugin : val flags = if (frame.key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0 decoder?.queueInputBuffer( index, 0, frame.data.size, frame.pts, flags) + // 记录该帧入队时间,供输出回调计算解码耗时。 + synchronized(statsLock) { + decodeStartNs[frame.pts] = System.nanoTime() + // 防止乱序/丢帧导致 map 泄漏。 + if (decodeStartNs.size > 300) decodeStartNs.clear() + } } catch (e: Exception) { Log.w(TAG, "queue frame failed", e) } @@ -318,6 +512,33 @@ class SelfCodecDecoderPlugin : } } + /// 输出帧到达时计算解码耗时(毫秒),并更新滑动平均/峰值/计数。 + private fun recordDecodeTime(pts: Long) { + synchronized(statsLock) { + val startNs = decodeStartNs.remove(pts) ?: return + val ms = (System.nanoTime() - startNs) / 1_000_000.0 + lastDecodeMs = ms + if (ms > maxDecodeMs) maxDecodeMs = ms + decodedFrames++ + recentDecodeMs.addLast(ms) + while (recentDecodeMs.size > DECODE_STATS_WINDOW) recentDecodeMs.removeFirst() + } + } + + /// 汇总解码统计供 Dart 侧读取(毫秒)。 + private fun collectStats(): Map { + synchronized(statsLock) { + val avg = if (recentDecodeMs.isEmpty()) 0.0 + else recentDecodeMs.sum() / recentDecodeMs.size + return mapOf( + "lastMs" to lastDecodeMs, + "avgMs" to avg, + "maxMs" to maxDecodeMs, + "count" to decodedFrames + ) + } + } + private fun releaseDecoder() { synchronized(configLock) { try { @@ -333,6 +554,17 @@ class SelfCodecDecoderPlugin : pendingConfigs.clear() frameQueue.clear() inputQueue.clear() + // 重置序号并清空重组缓冲,避免重连/重启后首帧被误判为过期而丢弃。 + lastSeq = -1 + chunkBuffers.clear() + // 重置解码耗时统计。 + synchronized(statsLock) { + decodeStartNs.clear() + recentDecodeMs.clear() + lastDecodeMs = 0.0 + maxDecodeMs = 0.0 + decodedFrames = 0L + } } } diff --git a/webrtc_controller_flutter/lib/webrtc/self_codec_decoder.dart b/webrtc_controller_flutter/lib/webrtc/self_codec_decoder.dart index a6f31dc..c07ef45 100644 --- a/webrtc_controller_flutter/lib/webrtc/self_codec_decoder.dart +++ b/webrtc_controller_flutter/lib/webrtc/self_codec_decoder.dart @@ -100,6 +100,22 @@ class SelfCodecDecoder { } } + /// 读取解码耗时统计。返回包含以下键的 Map(毫秒),失败返回 null: + /// - lastMs:最近一帧解码耗时; + /// - avgMs:最近窗口内的平均解码耗时; + /// - maxMs:峰值解码耗时; + /// - count:累计已解码帧数。 + Future?> getStats() async { + if (_disposed || _textureId == null) return null; + try { + final res = await _channel.invokeMethod('getStats'); + if (res is Map) return res.cast(); + } catch (_) { + // 解码未就绪或被释放时忽略。 + } + return null; + } + /// 释放原生解码资源。重复调用安全。 Future dispose() async { if (_disposed) return; diff --git a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart index cf50956..04ae9f9 100644 --- a/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart +++ b/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'self_codec_decoder.dart'; @@ -9,6 +10,24 @@ 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)。 /// /// 职责: @@ -204,6 +223,53 @@ class WebRtcController { } 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 { + 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) { @@ -264,7 +330,11 @@ class WebRtcController { _creatingDecoder = true; try { final decoder = SelfCodecDecoder( - onSize: (w, h) => onResolutionReported?.call(w, h), + onSize: (w, h) { + _selfCodecWidth = w; + _selfCodecHeight = h; + onResolutionReported?.call(w, h); + }, onError: (err) => debugPrint('自编码解码器: $err'), ); final textureId = await decoder.create(); @@ -294,6 +364,10 @@ class WebRtcController { 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: @@ -394,6 +468,16 @@ class WebRtcController { int _prevBytesSent = 0; int _prevStatsTimestamp = 0; + /// 上一次采样时的累计解码帧数,用于计算自编码模式下的解码帧率。 + int _prevDecodeCount = 0; + + /// WebRTC 全托管模式下,由原生 VideoSink 探针实测的解码耗时统计缓存。 + _DecodeProbeStats? _webRtcDecodeProbe; + + /// 解码器上报的真实分辨率(自编码模式下 WebRTC 视频统计为空,需用此覆盖)。 + int _selfCodecWidth = 0; + int _selfCodecHeight = 0; + /// 连接建立时的时间戳(毫秒),用于计算连接时长。 int? _connectedAt; @@ -425,6 +509,11 @@ class WebRtcController { 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; @@ -482,17 +571,13 @@ class WebRtcController { } // 计算每秒网速(与上次采样差值)。 - final now = DateTime.now().millisecondsSinceEpoch; String downSpeed = '-'; String upSpeed = '-'; - if (_prevStatsTimestamp != 0) { - final dt = (now - _prevStatsTimestamp) / 1000.0; - if (dt > 0) { - final downBps = (bytesReceived - _prevBytesReceived) / dt; - final upBps = (bytesSent - _prevBytesSent) / dt; - downSpeed = _formatSpeed(downBps); - upSpeed = _formatSpeed(upBps); - } + if (dtSec > 0) { + final downBps = (bytesReceived - _prevBytesReceived) / dtSec; + final upBps = (bytesSent - _prevBytesSent) / dtSec; + downSpeed = _formatSpeed(downBps); + upSpeed = _formatSpeed(upBps); } _prevBytesReceived = bytesReceived; _prevBytesSent = bytesSent; @@ -522,11 +607,66 @@ class WebRtcController { ? '已连接' : (dcState == null ? '未连接' : dcState.name); - return '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms\n' + // —— 自编码(自建 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 中继描述。 @@ -591,6 +731,9 @@ class WebRtcController { try { await _dataChannel?.close(); } catch (_) {} + try { + await _detachWebRtcDecodeProbe(); + } catch (_) {} try { await _pc?.close(); } catch (_) {} @@ -617,6 +760,10 @@ class WebRtcController { _prevBytesReceived = 0; _prevBytesSent = 0; _prevStatsTimestamp = 0; + _prevDecodeCount = 0; + _webRtcDecodeProbe = null; + _selfCodecWidth = 0; + _selfCodecHeight = 0; _connectedAt = null; } }