build: 控制端功能移植到flutter版本

This commit is contained in:
2026-07-14 02:02:05 +08:00
parent 5f024be736
commit e260c02629
24 changed files with 1278 additions and 118 deletions

View File

@@ -0,0 +1,221 @@
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../config/ice_servers.dart';
import '../models/signal_message.dart';
import '../signaling/signaling_client.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;
RTCPeerConnection? _pc;
RTCDataChannel? _dataChannel;
/// 远端视频渲染器,由调用方持有并显示。
final RTCVideoRenderer renderer = RTCVideoRenderer();
void Function()? onConnectionEstablished;
void Function(String error)? onConnectionFailed;
void Function()? onDisconnected;
/// 远端视频流就绪renderer 已绑定视频轨道)。
void Function(RTCVideoRenderer renderer)? onRemoteStream;
WebRtcController({
required this.signaling,
required this.deviceId,
required this.targetDeviceId,
});
/// 初始化渲染器、PeerConnection并创建 Offer。
Future<void> initialize() async {
await renderer.initialize();
final configuration = {
'iceServers': kIceServers,
'sdpSemantics': 'unified-plan',
'iceCandidatePoolSize': 10,
};
_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 = true;
_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!);
}
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) {
// 处理来自被控端的消息(本控制端主要发送,此处仅做日志记录)。
// ignore: avoid_print
print('DataChannel 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) {
onConnectionEstablished?.call();
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
onDisconnected?.call();
}
}
void _sendOffer(String sdp) {
final payload = {'sdp': sdp};
final msg = SignalMessage.withPayload(
type: 'OFFER',
fromDeviceId: deviceId,
toDeviceId: targetDeviceId,
deviceType: 'CONTROLLER',
payload: payload,
);
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 发送控制指令JSON 字符串)。
void sendControlCommand(String commandJson) {
if (_dataChannel?.state == RTCDataChannelState.RTCDataChannelOpen) {
_dataChannel!.send(RTCDataChannelMessage(commandJson));
}
}
/// 采集连接统计信息,回调格式化后的文本(对应 Android 端 updateStats
Future<String> getStatsText() async {
if (_pc == null) return '';
final reports = await _pc!.getStats();
dynamic width = '-';
dynamic height = '-';
dynamic fps = '-';
dynamic delay = '-';
dynamic codec = '-';
for (final report in reports) {
final values = report.values;
if (report.type == 'inbound-rtp' && values['kind'] == 'video') {
width = values['frameWidth'] ?? '-';
height = values['frameHeight'] ?? '-';
fps = values['framesPerSecond'] ?? '-';
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 (report.type == 'candidate-pair') {
if (values['nominated'] == true) {
final rtt = values['currentRoundTripTime'];
if (rtt is num) {
delay = (rtt * 1000).toStringAsFixed(0);
}
}
}
}
return '分辨率: ${width}x$height 帧率: $fps 延迟: $delay ms\n'
'解码格式: $codec';
}
/// 释放所有资源。
Future<void> close() async {
try {
await _dataChannel?.close();
} catch (_) {}
try {
await _pc?.close();
} catch (_) {}
try {
await renderer.dispose();
} catch (_) {}
_dataChannel = null;
_pc = null;
}
}