diff --git a/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/IWebRTCClient.java b/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/IWebRTCClient.java index 9ac35367..a0a40ac1 100644 --- a/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/IWebRTCClient.java +++ b/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/IWebRTCClient.java @@ -194,6 +194,22 @@ void publish(String streamId, String token, boolean videoCallEnabled, boolean au */ void setAudioEnabled(boolean enabled); + /** + * Disable black frame sender when video is toggled off via toggleSendVideo(false). + * When true, no black frames will be sent when the camera is turned off during a call. + * + * @param disable true to disable black frame sender, false to enable (default) + */ + void setDisableBlackFrameSender(boolean disable); + + /** + * Disable silence packets when audio is muted via toggleSendAudio(false). + * When true, RTP transmission is stopped when muted (no silence packets sent). + * + * @param disable true to stop RTP when muted, false for default behavior (sends silence) + */ + void setDisableSilenceWhenMuted(boolean disable); + /** * enable/disable played track stream from the server * diff --git a/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/WebRTCClientConfig.java b/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/WebRTCClientConfig.java index 531f6abb..cd29d891 100644 --- a/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/WebRTCClientConfig.java +++ b/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/api/WebRTCClientConfig.java @@ -183,4 +183,17 @@ public class WebRTCClientConfig { * Flag for connecting bluetooth headphones. */ public boolean bluetoothEnabled = false; + + /* + * Flag indicating whether black frame sender is disabled when video is toggled off. + * When true, no black frames will be sent when toggleSendVideo(false) is called. + */ + public boolean disableBlackFrameSender = false; + + /* + * Flag indicating whether silence packets are disabled when audio is muted. + * When true, RTP transmission is stopped when toggleSendAudio(false) is called (no silence packets sent). + * When false, muted audio still sends silence packets (default WebRTC behavior). + */ + public boolean disableSilenceWhenMuted = false; } diff --git a/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/core/WebRTCClient.java b/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/core/WebRTCClient.java index 8e6c929f..247d6ffb 100644 --- a/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/core/WebRTCClient.java +++ b/webrtc-android-framework/src/main/java/io/antmedia/webrtcandroidframework/core/WebRTCClient.java @@ -1853,23 +1853,50 @@ public void sendMessageViaDataChannel(String streamId, DataChannel.Buffer buffer } public void changeVideoCapturer(VideoCapturer newVideoCapturer) { - try { - if (videoCapturer != null) { - videoCapturer.stopCapture(); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } - - videoCapturerStopped = true; + VideoCapturer oldVideoCapturer = videoCapturer; + VideoTrack oldVideoTrack = localVideoTrack; videoCapturer = newVideoCapturer; localVideoTrack = null; MediaStreamTrack newTrack = createVideoTrack(videoCapturer); - if (localVideoSender != null) { - localVideoSender.setTrack(newTrack, true); + RtpSender videoSender = findVideoSender(); + if (videoSender == null || newTrack == null) { + Log.e(TAG, "Video sender or new video track is not available while changing video capturer."); + stopVideoCapturer(newVideoCapturer); + videoCapturer = oldVideoCapturer; + localVideoTrack = oldVideoTrack; + return; + } + + try { + if (!videoSender.setTrack(newTrack, true)) { + Log.e(TAG, "RtpSender.setTrack failed while changing video capturer."); + stopVideoCapturer(newVideoCapturer); + videoCapturer = oldVideoCapturer; + localVideoTrack = oldVideoTrack; + return; + } + } catch (IllegalStateException e) { + localVideoSender = null; + stopVideoCapturer(newVideoCapturer); + videoCapturer = oldVideoCapturer; + localVideoTrack = oldVideoTrack; + Log.e(TAG, "Video sender was disposed while changing video capturer.", e); + return; } + stopVideoCapturer(oldVideoCapturer); + + } + + private void stopVideoCapturer(@androidx.annotation.Nullable VideoCapturer capturer) { + try { + if (capturer != null) { + capturer.stopCapture(); + } + } catch (InterruptedException e) { + Log.e(TAG, "Could not stop video capturer.", e); + } } /** @@ -2133,15 +2160,21 @@ public void initDataChannel(String streamId) { } public void setDegradationPreference(RtpParameters.DegradationPreference degradationPreference) { - if (localVideoSender == null) { - Log.w(TAG, "Sender is not ready."); - return; - } executor.execute(() -> { - RtpParameters newParameters = localVideoSender.getParameters(); - if (newParameters != null) { - newParameters.degradationPreference = degradationPreference; - localVideoSender.setParameters(newParameters); + RtpSender videoSender = findVideoSender(); + if (videoSender == null) { + Log.w(TAG, "Sender is not ready."); + return; + } + try { + RtpParameters newParameters = videoSender.getParameters(); + if (newParameters != null) { + newParameters.degradationPreference = degradationPreference; + videoSender.setParameters(newParameters); + } + } catch (IllegalStateException e) { + localVideoSender = null; + Log.w(TAG, "Video sender is not available: " + e.getMessage()); } }); } @@ -2262,8 +2295,12 @@ public void setAudioEnabled(final boolean enable) { config.audioCallEnabled = enable; executor.execute(() -> { + boolean shouldSendAudio = enable && sendAudioEnabled; if (localAudioTrack != null) { - localAudioTrack.setEnabled(enable); + localAudioTrack.setEnabled(shouldSendAudio); + } + if (config.disableSilenceWhenMuted) { + setAudioSenderEnabled(shouldSendAudio); } }); } @@ -2286,8 +2323,12 @@ public void setVideoEnabled(final boolean enable) { public void toggleSendAudio(boolean enableAudio) { executor.execute(() -> { sendAudioEnabled = enableAudio; + boolean shouldSendAudio = enableAudio && config.audioCallEnabled; if (localAudioTrack != null) { - localAudioTrack.setEnabled(enableAudio); + localAudioTrack.setEnabled(shouldSendAudio); + } + if (config.disableSilenceWhenMuted) { + setAudioSenderEnabled(shouldSendAudio); } }); } @@ -2307,8 +2348,10 @@ public void toggleSendVideo(boolean enableVideo) { changeVideoSource(StreamSource.FRONT_CAMERA); } else { changeVideoSource(StreamSource.CUSTOM); - blackFrameSender = new BlackFrameSender((CustomVideoCapturer) getVideoCapturer()); - blackFrameSender.start(); + if (!config.disableBlackFrameSender) { + blackFrameSender = new BlackFrameSender((CustomVideoCapturer) getVideoCapturer()); + blackFrameSender.start(); + } } }); } @@ -2444,28 +2487,32 @@ public void setVideoMaxBitrate(@androidx.annotation.Nullable final Integer maxBi return; } executor.execute(() -> { - if (localVideoSender == null) { - return; - } Log.d(TAG, "Requested max video bitrate: " + maxBitrateKbps); - if (localVideoSender == null) { + RtpSender videoSender = findVideoSender(); + if (videoSender == null) { Log.w(TAG, "Sender is not ready."); return; } - RtpParameters parameters = localVideoSender.getParameters(); - if (parameters.encodings.isEmpty()) { - Log.w(TAG, "RtpParameters are not ready."); - return; - } + try { + RtpParameters parameters = videoSender.getParameters(); + if (parameters.encodings.isEmpty()) { + Log.w(TAG, "RtpParameters are not ready."); + return; + } - for (RtpParameters.Encoding encoding : parameters.encodings) { - // Null value means no limit. - encoding.maxBitrateBps = maxBitrateKbps == null ? null : maxBitrateKbps * BPS_IN_KBPS; - encoding.minBitrateBps = maxBitrateKbps == null ? null : maxBitrateKbps * BPS_IN_KBPS / 2; - } - if (!localVideoSender.setParameters(parameters)) { - Log.e(TAG, "RtpSender.setParameters failed."); + for (RtpParameters.Encoding encoding : parameters.encodings) { + // Null value means no limit. + encoding.maxBitrateBps = maxBitrateKbps == null ? null : maxBitrateKbps * BPS_IN_KBPS; + encoding.minBitrateBps = maxBitrateKbps == null ? null : maxBitrateKbps * BPS_IN_KBPS / 2; + } + if (!videoSender.setParameters(parameters)) { + Log.e(TAG, "RtpSender.setParameters failed."); + } + } catch (IllegalStateException e) { + localVideoSender = null; + Log.w(TAG, "Video sender is not available: " + e.getMessage()); + return; } Log.d(TAG, "Configured max video bitrate to: " + maxBitrateKbps); }); @@ -2499,7 +2546,53 @@ private VideoTrack createVideoTrack(VideoCapturer capturer) { private void findVideoSender(String streamId) { PeerConnection pc = getPeerConnectionFor(streamId); + findVideoSender(pc); + } + private void setAudioSenderEnabled(boolean enabled) { + for (PeerInfo peerInfo : peers.values()) { + PeerConnection pc = peerInfo.peerConnection; + if (pc == null) { + continue; + } + try { + for (RtpSender sender : pc.getSenders()) { + MediaStreamTrack track = sender.track(); + if (track == null || !MediaStreamTrack.AUDIO_TRACK_KIND.equals(track.kind())) { + continue; + } + RtpParameters parameters = sender.getParameters(); + if (parameters == null || parameters.encodings.isEmpty()) { + Log.w(TAG, "Audio RtpParameters are not ready."); + return; + } + for (RtpParameters.Encoding encoding : parameters.encodings) { + encoding.active = enabled; + } + if (!sender.setParameters(parameters)) { + Log.e(TAG, "Audio RtpSender.setParameters failed."); + } + return; + } + } catch (IllegalStateException e) { + Log.w(TAG, "Audio sender is not available: " + e.getMessage()); + } + } + } + + @androidx.annotation.Nullable + private RtpSender findVideoSender() { + for (PeerInfo peerInfo : peers.values()) { + RtpSender sender = findVideoSender(peerInfo.peerConnection); + if (sender != null) { + return sender; + } + } + return null; + } + + @androidx.annotation.Nullable + private RtpSender findVideoSender(@androidx.annotation.Nullable PeerConnection pc) { if (pc != null) { for (RtpSender sender : pc.getSenders()) { MediaStreamTrack track = sender.track(); @@ -2508,10 +2601,12 @@ private void findVideoSender(String streamId) { if (trackType.equals(VIDEO_TRACK_TYPE)) { Log.d(TAG, "Found video sender."); localVideoSender = sender; + return sender; } } } } + return null; } private static String getSdpVideoCodecName(String codec) { @@ -2833,6 +2928,14 @@ public void setDataChannelEnabled(boolean dataChannelEnabled) { this.config.dataChannelEnabled = dataChannelEnabled; } + public void setDisableBlackFrameSender(boolean disableBlackFrameSender) { + this.config.disableBlackFrameSender = disableBlackFrameSender; + } + + public void setDisableSilenceWhenMuted(boolean disableSilenceWhenMuted) { + this.config.disableSilenceWhenMuted = disableSilenceWhenMuted; + } + public void setFactory(@androidx.annotation.Nullable PeerConnectionFactory factory) { this.factory = factory; } diff --git a/webrtc-android-framework/src/test/java/io/antmedia/webrtcandroidframework/WebRTCClientTest.java b/webrtc-android-framework/src/test/java/io/antmedia/webrtcandroidframework/WebRTCClientTest.java index b59bc10b..ea291f92 100644 --- a/webrtc-android-framework/src/test/java/io/antmedia/webrtcandroidframework/WebRTCClientTest.java +++ b/webrtc-android-framework/src/test/java/io/antmedia/webrtcandroidframework/WebRTCClientTest.java @@ -1055,6 +1055,14 @@ public void testDegradationPreference() { verify(sender, never()).setParameters(any()); webRTCClient.localVideoSender = sender; + PeerConnection pc = mock(PeerConnection.class); + MediaStreamTrack videoTrack = mock(MediaStreamTrack.class); + when(videoTrack.kind()).thenReturn("video"); + when(sender.track()).thenReturn(videoTrack); + when(pc.getSenders()).thenReturn(Collections.singletonList(sender)); + WebRTCClient.PeerInfo peerInfo = new WebRTCClient.PeerInfo("stream1", WebRTCClient.Mode.PUBLISH); + peerInfo.peerConnection = pc; + webRTCClient.getPeersForTest().put("stream1", peerInfo); RtpParameters parameters = mock(RtpParameters.class); when(sender.getParameters()).thenReturn(parameters); webRTCClient.setDegradationPreference(degradationPreference); @@ -1077,6 +1085,14 @@ public void testSetVideoMaxBitrate() throws NoSuchFieldException, IllegalAccessE verify(sender, never()).setParameters(any()); webRTCClient.localVideoSender = sender; + PeerConnection pc = mock(PeerConnection.class); + MediaStreamTrack videoTrack = mock(MediaStreamTrack.class); + when(videoTrack.kind()).thenReturn("video"); + when(sender.track()).thenReturn(videoTrack); + when(pc.getSenders()).thenReturn(Collections.singletonList(sender)); + WebRTCClient.PeerInfo peerInfo = new WebRTCClient.PeerInfo("stream1", WebRTCClient.Mode.PUBLISH); + peerInfo.peerConnection = pc; + webRTCClient.getPeersForTest().put("stream1", peerInfo); RtpParameters.Encoding encodings = mock(RtpParameters.Encoding.class); List mockEncoding = Collections.emptyList(); @@ -1366,6 +1382,68 @@ public void testToggleSendAudioVideo() { }); } + @Test + public void testDisableBlackFrameSender_blackFrameSenderNotActivatedWhenDisabled() { + webRTCClient.setDisableBlackFrameSender(true); + webRTCClient.getConfig().videoCallEnabled = true; + + CustomVideoCapturer customVideoCapturerMock = mock(CustomVideoCapturer.class); + doNothing().when(customVideoCapturerMock).writeFrame(any()); + webRTCClient.setVideoCapturer(customVideoCapturerMock); + when(webRTCClient.createVideoCapturer(IWebRTCClient.StreamSource.CUSTOM)).thenReturn(customVideoCapturerMock); + + webRTCClient.toggleSendVideo(false); + + await().atMost(2, SECONDS).untilAsserted(() -> + assertNull(webRTCClient.getBlackFrameSender())); + assertTrue(webRTCClient.getConfig().videoSource == IWebRTCClient.StreamSource.CUSTOM); + } + + @Test + public void testDisableSilenceWhenMuted_doesNotDetachAudioSender() throws NoSuchFieldException, IllegalAccessException { + String streamId = "stream1"; + WebRTCClient.PeerInfo peerInfo = new WebRTCClient.PeerInfo(streamId, WebRTCClient.Mode.PUBLISH); + PeerConnection pc = mock(PeerConnection.class); + peerInfo.peerConnection = pc; + + RtpSender audioSender = mock(RtpSender.class); + AudioTrack audioTrackMock = mock(AudioTrack.class); + RtpParameters parameters = mock(RtpParameters.class); + RtpParameters.Encoding encoding = new RtpParameters.Encoding(null, true, null); + List encodings = new ArrayList<>(); + encodings.add(encoding); + when(audioTrackMock.kind()).thenReturn(MediaStreamTrack.AUDIO_TRACK_KIND); + when(audioSender.track()).thenReturn(audioTrackMock); + when(audioSender.getParameters()).thenReturn(parameters); + when(audioSender.setParameters(parameters)).thenReturn(true); + when(pc.getSenders()).thenReturn(Collections.singletonList(audioSender)); + + Field field = RtpParameters.class.getDeclaredField("encodings"); + field.setAccessible(true); + field.set(parameters, encodings); + + webRTCClient.getPeersForTest().put(streamId, peerInfo); + webRTCClient.setLocalAudioTrack(audioTrackMock); + webRTCClient.setDisableSilenceWhenMuted(true); + + webRTCClient.toggleSendAudio(false); + + await().atMost(2, SECONDS).untilAsserted(() -> { + verify(audioTrackMock).setEnabled(false); + verify(audioSender, never()).setTrack(null, false); + verify(audioSender).setParameters(parameters); + assertFalse(encoding.active); + }); + + webRTCClient.toggleSendAudio(true); + + await().atMost(2, SECONDS).untilAsserted(() -> { + verify(audioTrackMock).setEnabled(true); + verify(audioSender, times(2)).setParameters(parameters); + assertTrue(encoding.active); + }); + } + @Test public void testTurnServer(){ String turnServerUri = "turn:example.antmedia.io"; diff --git a/webrtc-android-sample-app/src/main/java/io/antmedia/webrtc_android_sample_app/basic/ConferenceActivity.java b/webrtc-android-sample-app/src/main/java/io/antmedia/webrtc_android_sample_app/basic/ConferenceActivity.java index 4eb82de2..feb9b64d 100644 --- a/webrtc-android-sample-app/src/main/java/io/antmedia/webrtc_android_sample_app/basic/ConferenceActivity.java +++ b/webrtc-android-sample-app/src/main/java/io/antmedia/webrtc_android_sample_app/basic/ConferenceActivity.java @@ -162,6 +162,9 @@ public void createWebRTCClient(){ .setDataChannelObserver(createDatachannelObserver()) .build(); + webRTCClient.setDisableBlackFrameSender(true); + webRTCClient.setDisableSilenceWhenMuted(true); + joinButton = findViewById(R.id.join_conference_button); joinButton.setOnClickListener(v -> { joinLeaveRoom();