228 lines
7.0 KiB
Dart
228 lines
7.0 KiB
Dart
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;
|
||
|
||
/// ICE 连接断开(如网络中断、被控端退出),用于提示用户并返回连接设置。
|
||
void Function(String message)? onIceDisconnected;
|
||
|
||
/// 远端视频流就绪(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.RTCIceConnectionStateDisconnected) {
|
||
onIceDisconnected?.call('ICE 连接已断开');
|
||
} else if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
|
||
onConnectionFailed?.call('ICE 连接失败');
|
||
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;
|
||
}
|
||
}
|