Files
VibeCoding/webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart

424 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.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 连接逻辑(对应 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;
/// 远端视频渲染器,由调用方持有并显示。
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;
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!);
// 创建并发送 Offer。
final constraints = {
'mandatory': {
'OfferToReceiveVideo': true,
'OfferToReceiveAudio': false,
},
'optional': [],
};
final offer = await _pc!.createOffer(constraints);
await _pc!.setLocalDescription(offer);
_sendOffer(offer.sdp!, authType: authType, authValue: authValue);
}
void _onTrack(RTCTrackEvent event) {
if (event.track.kind == 'video' && event.streams.isNotEmpty) {
renderer.srcObject = event.streams[0];
onRemoteStream?.call(renderer);
}
}
void _onDataChannel(RTCDataChannel channel) {
// 被控端也可能主动创建 DataChannel统一处理。
_setupDataChannel(channel);
}
void _setupDataChannel(RTCDataChannel channel) {
channel.onMessage = (RTCDataChannelMessage message) {
// 处理来自被控端的消息(本控制端主要发送,此处仅做日志记录)。
if (message.isBinary) {
try {
final command = ControlMessage.fromBuffer(message.binary);
// ignore: avoid_print
print('DataChannel message: ${command.action}');
} 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 _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? _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;
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();
}
}
}
// 计算每秒网速(与上次采样差值)。
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);
}
}
_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);
return '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms\n'
'解码格式: $codec 抖动: $jitter ms 丢包: $lossRate%\n'
'丢帧: $framesDropped ↓下载: $downSpeed ↑上传: $upSpeed\n'
'连接类型: $connType ($protocol) 时长: $duration\n'
'控制通道: $dcStatus 收发: $dcMessagesSent/$dcMessagesReceived';
}
/// 依据选定候选对的本地/远端候选类型,返回 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 _pc?.close();
} catch (_) {}
try {
await renderer.dispose();
} catch (_) {}
_dataChannel = null;
_pc = null;
// 重置网速统计基线,避免下次连接首帧显示错误的瞬时速率。
_prevBytesReceived = 0;
_prevBytesSent = 0;
_prevStatsTimestamp = 0;
_connectedAt = null;
}
}