build: 控制端功能移植到flutter版本
This commit is contained in:
221
webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart
Normal file
221
webrtc_controller_flutter/lib/webrtc/webrtc_controller.dart
Normal 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)。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 创建 PeerConnection(recvonly 视频 + 控制用 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user