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

222 lines
6.7 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_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;
}
}