Я создаю приложение для голосовых/видеозвонков на Android с использованием WebRTC, где кандидаты SDP и ICE обмениваются через сервер WebSocket. Поток кандидатов SDP и ICE между двумя пользователями (A и B) выглядит правильно в журналах WebSocket, но соединение, похоже, не устанавливается полностью.Журнал Websocket
User = A Send User B =>> offer of SDP
User = A Send User B =>> offer of ICE
User = A Send User B =>> offer of ICE
User = A Send User B =>> offer of ICE
User = A Send User B =>> offer of ICE
User = A Send User B =>> offer of ICE
User = A Send User B =>> offer of ICE
User = A Send User B =>> offer of ICE
User = A Send User B =>> offer of ICE
User = B Send User A =>> answer of SDP
User = B Send User A =>> answer of ICE
User = B Send User A =>> answer of ICE
User = B Send User A =>> answer of ICE
User = B Send User A =>> answer of ICE
User = B Send User A =>> answer of ICE
User = B Send User A =>> answer of ICE
User = B Send User A =>> answer of ICE
User = B Send User A =>> answer of ICE
Однако в журналах Android состояние соединения не меняется должным образом после завершения обмена ICE. Соединение остается в следующих состояниях на неопределенный срок:
public class Video_Voice_Call_Fragment extends Fragment {
private static final String TAG = "Call_Fragment";
private static final int PERMISSION_REQUEST_CODE = 1;
public static final int VIDEO_RESOLUTION_WIDTH = 1280;
public static final int VIDEO_RESOLUTION_HEIGHT = 720;
public static final int FPS = 30;
private EglBase rootEglBase;
private PeerConnectionFactory peerConnectionFactory;
private VideoCapturer videoCapturer;
private VideoSource videoSource;
private VideoTrack localVideoTrack;
private AudioTrack localAudioTrack;
private SurfaceViewRenderer localVideoView;
private SurfaceTextureHelper surfaceTextureHelper;
private SurfaceViewRenderer remoteVideoView;
private ImageButton greenCallButton;
private ImageButton redCallButton;
private MediaStream localMediaStream;
OkHttpClient client;
PeerConnection peerConnection,peerConnectionRemote;
private String FCMToken_Other;
TextView CallStatus;
ImageView UserImage;
boolean isCaller = true;
boolean isVoiceCall;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_video_voice_call, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
localVideoView = view.findViewById(R.id.call_remoteSurfaceView);
greenCallButton = view.findViewById(R.id.call_connect);
redCallButton = view.findViewById(R.id.call_disconnect);
if (isCaller){
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) redCallButton.getLayoutParams();
params.startToStart = ConstraintSet.PARENT_ID;
params.setMarginEnd(0);
redCallButton.setLayoutParams(params);
greenCallButton.setVisibility(View.GONE);
}
UserImage = view.findViewById(R.id.call_UserImage);
CallStatus = view.findViewById(R.id.call_Status);
TextView CallName = view.findViewById(R.id.call_callerName);
isVoiceCall = getArguments().getBoolean("VoiceCall");
String ImagePath = getArguments().getString("image");
String Name = getArguments().getString("name");
FCMToken_Other = getArguments().getString("fcm");
int OtherUserID = getArguments().getInt("OtherID");
Session.setOtherUserID(OtherUserID);
Glide.with(getActivity()).load(ImagePath).into(UserImage);
CallStatus.setText("Connecting...");
CallName.setText(Name);
client = new OkHttpClient();
initializePeerConnectionFactory(isVoiceCall);
setupLocalTracks(isVoiceCall);
setupUI(view);
startCall(isVoiceCall);
}
private void initializePeerConnectionFactory(boolean isVoiceCall) {
// Initialize EGL context for rendering
rootEglBase = EglBase.create();
// Initialize PeerConnectionFactory
PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(requireContext())
.setEnableInternalTracer(true)
.createInitializationOptions());
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
PeerConnectionFactory.Builder builder = PeerConnectionFactory.builder();
options.networkIgnoreMask = 16;
options.disableNetworkMonitor = true;
if (!isVoiceCall){
//Video Call
DefaultVideoEncoderFactory videoEncoderFactory = new DefaultVideoEncoderFactory(rootEglBase.getEglBaseContext(),true,true);
DefaultVideoDecoderFactory videoDecoderFactory = new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext());
builder.setVideoEncoderFactory(videoEncoderFactory);
builder.setVideoDecoderFactory(videoDecoderFactory);
//Set EGL context
localVideoView.init(rootEglBase.getEglBaseContext(), null);
localVideoView.setMirror(true);
getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
peerConnectionFactory = builder.setOptions(options).createPeerConnectionFactory();
}
private void setupLocalTracks(boolean isVoiceCall) {
localMediaStream = peerConnectionFactory.createLocalMediaStream("local_stream");
if (!isVoiceCall){
// Create video capturer
videoCapturer = createVideoCapturer();
// Create video source
videoSource = peerConnectionFactory.createVideoSource(false);
localVideoTrack = peerConnectionFactory.createVideoTrack("local_video", videoSource);
surfaceTextureHelper = SurfaceTextureHelper.create("SurfaceHelper",rootEglBase.getEglBaseContext());
videoCapturer.initialize(surfaceTextureHelper,getActivity(),videoSource.getCapturerObserver());
if (videoCapturer!=null){
videoCapturer.startCapture(VIDEO_RESOLUTION_WIDTH,VIDEO_RESOLUTION_HEIGHT,FPS);
localVideoTrack.addSink(localVideoView);
}
localMediaStream.addTrack(localVideoTrack);
}
// Create audio source and track
AudioSource audioSource = peerConnectionFactory.createAudioSource(new MediaConstraints());
localAudioTrack = peerConnectionFactory.createAudioTrack("local_audio", audioSource);
// Create local media stream
localMediaStream.addTrack(localAudioTrack);
}
private VideoCapturer createVideoCapturer() {
VideoCapturer videoCapturer;
CameraEnumerator enumerator = new Camera2Enumerator(requireContext());
final String[] deviceNames = enumerator.getDeviceNames();
// Find front facing camera
for (String deviceName : deviceNames) {
if (enumerator.isFrontFacing(deviceName)) {
videoCapturer = enumerator.createCapturer(deviceName, null);
if (videoCapturer != null) {
return videoCapturer;
}
}
}
// Front facing camera not found, use the first available
if (deviceNames.length > 0) {
return enumerator.createCapturer(deviceNames[0], null);
} else {
return null;
}
}
private void startCall(boolean isVoiceCall) {
Log.d(TAG, "Starting call...");
// Create an offer and set local description
peerConnection = InitializePeerConnection();
if (!isVoiceCall){
peerConnection.addTrack(localVideoTrack);
}
peerConnection.addTrack(localAudioTrack);
peerConnection.createOffer(new SimpleSdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
Log.d(TAG, "onCreateSuccess: " + sessionDescription.description);
Websocket.connectWebSocket(new WebSocketCallback() {
@Override
public void onConnectionSuccess() {
//Websocket Connected
Log.d(TAG, "onConnectionSuccess: Websocket Connected");
String message = generateJSON(sessionDescription.description,true,"offer");
peerConnection.setLocalDescription(new SimpleSdpObserver(), sessionDescription);
Websocket.sendMessage(message);
}
@Override
public void onConnectionFailure(String message) {
Log.d(TAG, "onConnectionFailure: Message = "+message);
}
@Override
public void onConnectionClosed(String reason) {
Log.d(TAG, "onConnectionClosed: Reason = "+reason);
}
@Override
public void onConnectionsMessage(String message) {
Log.d(TAG, "onConnectionsMessage: Message = "+message);
// Here User will receive the message from the websocket
try{
JSONObject socketMessage = new JSONObject(message);
String type = socketMessage.getString("type");
Log.d(TAG, "onConnectionsMessage: Type = "+type);
String sdp_ice_VALUE = null ;
if (socketMessage.has("SDP_ICE_info")){
sdp_ice_VALUE = socketMessage.getString("SDP_ICE_info");
}
switch (type) {
case "SDP":
Log.d(TAG, "onConnectionsMessage: Type SDP");
SessionDescription sessionDescription = new SessionDescription(SessionDescription.Type.ANSWER, sdp_ice_VALUE);
peerConnection.setRemoteDescription(new SdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
Log.d(TAG, "onCreateSuccess: ");
}
@Override
public void onSetSuccess() {
Log.d(TAG, "onSetSuccess: Gathering state = " + peerConnection.iceGatheringState());
Log.d(TAG, "onSetSuccess: Connection State = " + peerConnection.iceConnectionState());
}
@Override
public void onCreateFailure(String s) {
Log.d(TAG, "onCreateFailure: " + s);
}
@Override
public void onSetFailure(String s) {
Log.d(TAG, "onSetFailure: " + s);
}
}, sessionDescription);
//Generate ICE and Send it User B
break;
case "Offline":
Log.d(TAG, "onConnectionsMessage: OFFLINE");
DefaultExecutorSupplier.getInstance().forMainThreadTasks().execute(new Runnable() {
@Override
public void run() {
Toast.makeText(getActivity(), "User Is Not Available", Toast.LENGTH_SHORT).show();
CallStatus.setText("Offline");
closeConnection();
}
});
break;
case "ICE":
// Received ICE from server as answer, This is the last step in exchanging info
Log.d(TAG, "onConnectionsMessage: Message Recevied ICE = " + type);
addIceCandidates(sdp_ice_VALUE);
break;
}
}catch (JSONException e){
Log.d(TAG, "onMessage: Error "+e.getMessage());
}
}
});
}
@Override
public void onCreateFailure(String s) {
Log.e(TAG, "onCreateFailure: " + s);
}
}, new MediaConstraints());
}
private PeerConnection InitializePeerConnection(){
ArrayList
iceServers = new ArrayList();
List STUNList= Arrays.asList(
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
"stun:stun2.l.google.com:19302",
"stun:stun3.l.google.com:19302",
"stun:stun4.l.google.com:19302");
for(String i:STUNList){
PeerConnection.IceServer.Builder iceServerBuilder = PeerConnection.IceServer.builder(i);
//iceServerBuilder.setTlsCertPolicy(PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK);
iceServerBuilder.setTlsCertPolicy(PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_SECURE);
PeerConnection.IceServer iceServer = iceServerBuilder.createIceServer();
iceServers.add(iceServer);
}
PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers);
PeerConnection.Observer observer = new PeerConnection.Observer() {
@Override
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
Log.d(TAG, "onSignalingChange: "+signalingState);
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
Log.d(TAG, "onIceConnectionChange: "+iceConnectionState);
if (PeerConnection.IceConnectionState.CONNECTED == iceConnectionState){
Log.d(TAG, "onIceConnectionChange: Connection Established");
}
}
@Override
public void onIceConnectionReceivingChange(boolean b) {
Log.d(TAG, "onIceConnectionReceivingChange: Boolean = "+b);
}
@Override
public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
Log.d(TAG, "onIceGatheringChange: "+iceGatheringState);
}
@Override
public void onIceCandidate(IceCandidate iceCandidate) {
Log.d(TAG, "onIceCandidate: 458 "+Session.getUserID());
String ice = generateJSON(iceCandidate.toString(),false,"offer");
Websocket.sendMessage(ice);
}
@Override
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
Log.d(TAG, "onIceCandidatesRemoved: "+iceCandidates);
}
@Override
public void onAddStream(MediaStream mediaStream) {
Log.d(TAG, "onAddStream: "+mediaStream);
}
@Override
public void onRemoveStream(MediaStream mediaStream) {
Log.d(TAG, "onRemoveStream: "+mediaStream);
}
@Override
public void onDataChannel(DataChannel dataChannel) {
Log.d(TAG, "onDataChannel: "+dataChannel);
}
@Override
public void onRenegotiationNeeded() {
Log.d(TAG, "onRenegotiationNeeded: ");
}
@Override
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
Log.d(TAG, "onAddTrack: "+rtpReceiver+"--- "+mediaStreams);
}
};
return peerConnectionFactory.createPeerConnection(rtcConfig,observer);
}
private void endCall() {
// End the call and release resources
// Code to end the call goes here
if (peerConnection!=null){
Log.d(TAG, "endCall: Connection State ="+peerConnection.connectionState());
Log.d(TAG, "endCall: Ice Connection State ="+peerConnection.iceConnectionState());
Log.d(TAG, "endCall: Ice Gathering State ="+peerConnection.iceGatheringState());
}
}
private class CustomPeerConnectionObserver implements PeerConnection.Observer {
@Override
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
// Handle signaling state change
Log.d(TAG, "onSignalingChange: "+signalingState);
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
// Handle ICE connection state change
Log.d(TAG, "onIceConnectionChange: "+iceConnectionState);
}
@Override
public void onIceConnectionReceivingChange(boolean b) {
// Handle ICE connection receiving change
Log.d(TAG, "onIceConnectionReceivingChange: "+b);
}
@Override
public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
// Handle ICE gathering state change
Log.d(TAG, "onIceGatheringChange: "+iceGatheringState);
}
@Override
public void onIceCandidate(IceCandidate iceCandidate) {
// Handle ICE candidate
Log.d(TAG, "onIceCandidate: "+iceCandidate);
}
@Override
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
Log.d(TAG, "onIceCandidatesRemoved: "+iceCandidates.length);
}
@Override
public void onAddStream(MediaStream mediaStream) {
// Handle new stream added
Log.d(TAG, "onAddStream: "+mediaStream);
}
@Override
public void onRemoveStream(MediaStream mediaStream) {
// Handle stream removed
Log.d(TAG, "onRemoveStream: "+mediaStream);
}
@Override
public void onDataChannel(DataChannel dataChannel) {
// Handle data channel
Log.d(TAG, "onDataChannel: "+dataChannel);
}
@Override
public void onRenegotiationNeeded() {
// Handle renegotiation needed
}
@Override
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
}
}
// Helper class for handling SDP observer events
private static class SimpleSdpObserver implements SdpObserver {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
Log.d(TAG, "onCreateSuccess: Class Session Description = "+sessionDescription.description);
}
@Override
public void onSetSuccess() {
Log.d(TAG, "onSetSuccess: ");
}
@Override
public void onCreateFailure(String s) {}
@Override
public void onSetFailure(String s) {}
}
private void addIceCandidates(String iceCandidates){
IceCandidate iceCandidateObject = new IceCandidate("sdpMid", 0,iceCandidates);
// Add the IceCandidate object to the PeerConnection
peerConnection.addIceCandidate(iceCandidateObject);
}
}
Несмотря на обмен кандидатами ICE, соединение не переходит в состояние «Подключено». Я проверил, что оба пользователя отправляют и получают кандидатов ICE.
Сталкивался ли кто-нибудь с подобной проблемой с WebRTC на Android? Что может помешать соединению ICE выйти за пределы состояния «Проверка»?
Я использую эту библиотеку webrtc https://github.com/rno/WebRTC
Я создаю приложение для голосовых/видеозвонков на Android с использованием WebRTC, где кандидаты SDP и ICE обмениваются через сервер WebSocket. Поток кандидатов SDP и ICE между двумя пользователями (A и B) выглядит правильно в журналах WebSocket, но соединение, похоже, не устанавливается полностью.[b]Журнал Websocket[/b] [code]User = A Send User B =>> offer of SDP User = A Send User B =>> offer of ICE User = A Send User B =>> offer of ICE User = A Send User B =>> offer of ICE User = A Send User B =>> offer of ICE User = A Send User B =>> offer of ICE User = A Send User B =>> offer of ICE User = A Send User B =>> offer of ICE User = A Send User B =>> offer of ICE User = B Send User A =>> answer of SDP User = B Send User A =>> answer of ICE User = B Send User A =>> answer of ICE User = B Send User A =>> answer of ICE User = B Send User A =>> answer of ICE User = B Send User A =>> answer of ICE User = B Send User A =>> answer of ICE User = B Send User A =>> answer of ICE User = B Send User A =>> answer of ICE [/code] Однако в журналах Android состояние соединения не меняется должным образом после завершения обмена ICE. Соединение остается в следующих состояниях на неопределенный срок: [code]Log.d(TAG, "Connection State: " + peerConnection.connectionState()); // Shows "New" Log.d(TAG, "ICE Connection State: " + peerConnection.iceConnectionState()); // Shows "Checking" Log.d(TAG, "ICE Gathering State: " + peerConnection.iceGatheringState()); // Shows "Gathering" [/code] [b]Фрагмент моего вызова.[/b] [code]public class Video_Voice_Call_Fragment extends Fragment {
private static final String TAG = "Call_Fragment"; private static final int PERMISSION_REQUEST_CODE = 1; public static final int VIDEO_RESOLUTION_WIDTH = 1280; public static final int VIDEO_RESOLUTION_HEIGHT = 720; public static final int FPS = 30;
final String[] deviceNames = enumerator.getDeviceNames();
// Find front facing camera for (String deviceName : deviceNames) { if (enumerator.isFrontFacing(deviceName)) { videoCapturer = enumerator.createCapturer(deviceName, null); if (videoCapturer != null) { return videoCapturer; } } }
// Front facing camera not found, use the first available if (deviceNames.length > 0) { return enumerator.createCapturer(deviceNames[0], null); } else { return null; } }
private void startCall(boolean isVoiceCall) {
Log.d(TAG, "Starting call...");
// Create an offer and set local description peerConnection = InitializePeerConnection();
if (!isVoiceCall){ peerConnection.addTrack(localVideoTrack); }
@Override public void onConnectionsMessage(String message) { Log.d(TAG, "onConnectionsMessage: Message = "+message); // Here User will receive the message from the websocket try{ JSONObject socketMessage = new JSONObject(message); String type = socketMessage.getString("type"); Log.d(TAG, "onConnectionsMessage: Type = "+type); String sdp_ice_VALUE = null ; if (socketMessage.has("SDP_ICE_info")){ sdp_ice_VALUE = socketMessage.getString("SDP_ICE_info"); }
switch (type) { case "SDP": Log.d(TAG, "onConnectionsMessage: Type SDP"); SessionDescription sessionDescription = new SessionDescription(SessionDescription.Type.ANSWER, sdp_ice_VALUE); peerConnection.setRemoteDescription(new SdpObserver() { @Override public void onCreateSuccess(SessionDescription sessionDescription) { Log.d(TAG, "onCreateSuccess: "); }
@Override public void onSetSuccess() {
Log.d(TAG, "onSetSuccess: Gathering state = " + peerConnection.iceGatheringState()); Log.d(TAG, "onSetSuccess: Connection State = " + peerConnection.iceConnectionState());
@Override public void onSetFailure(String s) { Log.d(TAG, "onSetFailure: " + s); } }, sessionDescription); //Generate ICE and Send it User B
break; case "Offline": Log.d(TAG, "onConnectionsMessage: OFFLINE"); DefaultExecutorSupplier.getInstance().forMainThreadTasks().execute(new Runnable() { @Override public void run() { Toast.makeText(getActivity(), "User Is Not Available", Toast.LENGTH_SHORT).show(); CallStatus.setText("Offline"); closeConnection(); } });
break;
case "ICE": // Received ICE from server as answer, This is the last step in exchanging info Log.d(TAG, "onConnectionsMessage: Message Recevied ICE = " + type); addIceCandidates(sdp_ice_VALUE); break; } }catch (JSONException e){ Log.d(TAG, "onMessage: Error "+e.getMessage()); } } });
}
@Override public void onCreateFailure(String s) { Log.e(TAG, "onCreateFailure: " + s); } }, new MediaConstraints()); }
private void endCall() { // End the call and release resources // Code to end the call goes here if (peerConnection!=null){ Log.d(TAG, "endCall: Connection State ="+peerConnection.connectionState()); Log.d(TAG, "endCall: Ice Connection State ="+peerConnection.iceConnectionState()); Log.d(TAG, "endCall: Ice Gathering State ="+peerConnection.iceGatheringState()); }
}
private class CustomPeerConnectionObserver implements PeerConnection.Observer { @Override public void onSignalingChange(PeerConnection.SignalingState signalingState) { // Handle signaling state change Log.d(TAG, "onSignalingChange: "+signalingState); }
@Override public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) { // Handle ICE connection state change Log.d(TAG, "onIceConnectionChange: "+iceConnectionState); }
@Override public void onIceConnectionReceivingChange(boolean b) { // Handle ICE connection receiving change Log.d(TAG, "onIceConnectionReceivingChange: "+b); }
@Override public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) { // Handle ICE gathering state change Log.d(TAG, "onIceGatheringChange: "+iceGatheringState); }
@Override public void onDataChannel(DataChannel dataChannel) { // Handle data channel Log.d(TAG, "onDataChannel: "+dataChannel); }
@Override public void onRenegotiationNeeded() { // Handle renegotiation needed }
@Override public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
} }
// Helper class for handling SDP observer events private static class SimpleSdpObserver implements SdpObserver { @Override public void onCreateSuccess(SessionDescription sessionDescription) { Log.d(TAG, "onCreateSuccess: Class Session Description = "+sessionDescription.description); }
@Override public void onSetSuccess() { Log.d(TAG, "onSetSuccess: "); }
@Override public void onCreateFailure(String s) {}
@Override public void onSetFailure(String s) {} }
private void addIceCandidates(String iceCandidates){ IceCandidate iceCandidateObject = new IceCandidate("sdpMid", 0,iceCandidates); // Add the IceCandidate object to the PeerConnection peerConnection.addIceCandidate(iceCandidateObject); }
} [/code] Несмотря на обмен кандидатами ICE, соединение не переходит в состояние «Подключено». Я проверил, что оба пользователя отправляют и получают кандидатов ICE. Сталкивался ли кто-нибудь с подобной проблемой с WebRTC на Android? Что может помешать соединению ICE выйти за пределы состояния «Проверка»? Я использую эту библиотеку webrtc https://github.com/rno/WebRTC
Я создаю приложение для голосовых/видеозвонков на Android с использованием WebRTC, где кандидаты SDP и ICE обмениваются через сервер WebSocket. Поток кандидатов SDP и ICE между двумя пользователями (A и B) выглядит правильно в журналах WebSocket, но...
Я новичок, пытаюсь создать приложение для видеозвонков webRTC в качестве проекта (мне удалось заставить его работать с веб-сокетами, но при медленном Интернете оно зависает). Я использую Angular для FE и Go для BE. У меня проблема с тем, что...
Я создаю приложение Android Assirod с открытым исходным кодом, которое использует сервер node.js в качестве моста в API Google Gemini. Приложение использует WEBRTC для потоковой передачи звука в реальном времени между клиентом Android и сервером....
Я работаю над проектом, в котором мне нужно установить соединение WebRTC между клиентом C# WPF (с использованием .NET Core) и сервером Python FastAPI. Цель состоит в том, чтобы обеспечить однонаправленный видеопоток от сервера к клиенту.
Сервер...
Я работаю над проектом, в котором мне нужно установить соединение WebRTC между клиентом C# WPF (с использованием .NET Core) и сервером Python FastAPI. Цель состоит в том, чтобы обеспечить однонаправленный видеопоток от сервера к клиенту.
Сервер...