Skip to content

Commit 969a8fa

Browse files
authored
Merge pull request #55 from GetStream/chore/support-scree-share-audio
feat: android screen share audio
2 parents d5f1099 + 29ed6bd commit 969a8fa

File tree

5 files changed

+640
-50
lines changed

5 files changed

+640
-50
lines changed

android/src/main/java/io/getstream/webrtc/flutter/GetUserMediaImpl.java

Lines changed: 133 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
import io.getstream.webrtc.flutter.audio.AudioSwitchManager;
3737
import io.getstream.webrtc.flutter.audio.AudioUtils;
3838
import io.getstream.webrtc.flutter.audio.LocalAudioTrack;
39+
import io.getstream.webrtc.flutter.audio.ScreenAudioCapturer;
40+
41+
import java.nio.ByteBuffer;
3942
import io.getstream.webrtc.flutter.record.AudioChannel;
4043
import io.getstream.webrtc.flutter.record.AudioSamplesInterceptor;
4144
import io.getstream.webrtc.flutter.record.MediaRecorderImpl;
@@ -127,6 +130,9 @@ public class GetUserMediaImpl {
127130
private boolean isTorchOn;
128131
private Intent mediaProjectionData = null;
129132

133+
private ScreenAudioCapturer screenAudioCapturer;
134+
private volatile boolean screenAudioEnabled = false;
135+
private OrientationAwareScreenCapturer currentScreenCapturer;
130136

131137
public void screenRequestPermissions(ResultReceiver resultReceiver) {
132138
mediaProjectionData = null;
@@ -176,15 +182,40 @@ public static class ScreenRequestPermissionsFragment extends Fragment {
176182

177183
private ResultReceiver resultReceiver = null;
178184
private int requestCode = 0;
179-
private final int resultCode = 0;
185+
private int resultCode = 0;
186+
private boolean hasRequestedPermission = false;
180187

181188
private void checkSelfPermissions(boolean requestPermissions) {
189+
// Avoid requesting permission multiple times
190+
if (hasRequestedPermission) {
191+
return;
192+
}
193+
182194
if (resultCode != Activity.RESULT_OK) {
183195
Activity activity = this.getActivity();
196+
if (activity == null || activity.isFinishing()) {
197+
return;
198+
}
199+
184200
Bundle args = getArguments();
201+
if (args == null) {
202+
return;
203+
}
204+
185205
resultReceiver = args.getParcelable(RESULT_RECEIVER);
186206
requestCode = args.getInt(REQUEST_CODE);
187-
requestStart(activity, requestCode);
207+
208+
hasRequestedPermission = true;
209+
210+
// Post the permission request to allow the activity to fully stabilize.
211+
// This helps prevent the app from going to background on Samsung and other
212+
// devices when the MediaProjection permission dialog appears.
213+
new Handler(Looper.getMainLooper()).postDelayed(() -> {
214+
Activity currentActivity = getActivity();
215+
if (currentActivity != null && !currentActivity.isFinishing() && isAdded()) {
216+
requestStart(currentActivity, requestCode);
217+
}
218+
}, 100);
188219
}
189220
}
190221

@@ -495,6 +526,9 @@ public void invoke(Object... args) {
495526

496527
void getDisplayMedia(
497528
final ConstraintsMap constraints, final Result result, final MediaStream mediaStream) {
529+
// Check if audio is requested for screen share
530+
final boolean includeAudio = parseIncludeAudio(constraints);
531+
498532
if (mediaProjectionData == null) {
499533
screenRequestPermissions(
500534
new ResultReceiver(new Handler(Looper.getMainLooper())) {
@@ -507,41 +541,76 @@ protected void onReceiveResult(int requestCode, Bundle resultData) {
507541
resultError("screenRequestPermissions", "User didn't give permission to capture the screen.", result);
508542
return;
509543
}
510-
getDisplayMedia(result, mediaStream, mediaProjectionData);
544+
getDisplayMedia(result, mediaStream, mediaProjectionData, includeAudio);
511545
}
512546
});
513547
} else {
514-
getDisplayMedia(result, mediaStream, mediaProjectionData);
548+
getDisplayMedia(result, mediaStream, mediaProjectionData, includeAudio);
515549
}
516550
}
517551

518-
private void getDisplayMedia(final Result result, final MediaStream mediaStream, final Intent mediaProjectionData) {
552+
/**
553+
* Parses the includeAudio flag from constraints.
554+
* Checks for audio: true or audio: { ... } in constraints.
555+
*/
556+
private boolean parseIncludeAudio(ConstraintsMap constraints) {
557+
if (constraints == null || !constraints.hasKey("audio")) {
558+
return false;
559+
}
560+
561+
ObjectType audioType = constraints.getType("audio");
562+
if (audioType == ObjectType.Boolean) {
563+
return constraints.getBoolean("audio");
564+
} else if (audioType == ObjectType.Map) {
565+
// If audio is a map/object, we treat it as audio enabled
566+
return true;
567+
}
568+
569+
return false;
570+
}
571+
572+
private void getDisplayMedia(final Result result, final MediaStream mediaStream,
573+
final Intent mediaProjectionData, final boolean includeAudio) {
519574
/* Create ScreenCapture */
520575
VideoTrack displayTrack = null;
521-
VideoCapturer videoCapturer = null;
522576
String trackId = stateProvider.getNextTrackUUID();
523577

524-
videoCapturer = new OrientationAwareScreenCapturer(
578+
OrientationAwareScreenCapturer videoCapturer = new OrientationAwareScreenCapturer(
525579
applicationContext,
526580
mediaProjectionData,
527581
new MediaProjection.Callback() {
528582
@Override
529583
public void onStop() {
530584
super.onStop();
531585

586+
// Stop screen audio capture when screen sharing stops
587+
stopScreenAudioCapture();
588+
532589
ConstraintsMap params = new ConstraintsMap();
533590
params.putString("event", EVENT_DISPLAY_MEDIA_STOPPED);
534591
params.putString("trackId", trackId);
535592
FlutterWebRTCPlugin.sharedSingleton.sendEvent(params.toMap());
536593
}
537-
}
538-
);
594+
});
539595

540-
if (videoCapturer == null) {
541-
resultError("screenRequestPermissions", "GetDisplayMediaFailed, User revoked permission to capture the screen.", result);
542-
return;
596+
// Set up screen audio capture listener if audio is requested
597+
if (includeAudio && ScreenAudioCapturer.isSupported()) {
598+
videoCapturer.setMediaProjectionReadyListener(
599+
new OrientationAwareScreenCapturer.MediaProjectionReadyListener() {
600+
@Override
601+
public void onMediaProjectionReady(MediaProjection mediaProjection) {
602+
startScreenAudioCapture(mediaProjection);
603+
}
604+
605+
@Override
606+
public void onMediaProjectionStopped() {
607+
stopScreenAudioCapture();
608+
}
609+
});
543610
}
544611

612+
currentScreenCapturer = videoCapturer;
613+
545614
PeerConnectionFactory pcFactory = stateProvider.getPeerConnectionFactory();
546615
VideoSource videoSource = pcFactory.createVideoSource(true);
547616

@@ -566,7 +635,8 @@ public void onStop() {
566635
info.capturer = videoCapturer;
567636

568637
videoCapturer.startCapture(info.width, info.height, info.fps);
569-
Log.d(TAG, "OrientationAwareScreenCapturer.startCapture: " + info.width + "x" + info.height + "@" + info.fps);
638+
Log.d(TAG, "OrientationAwareScreenCapturer.startCapture: " + info.width + "x" + info.height + "@" + info.fps +
639+
", includeAudio: " + includeAudio);
570640

571641
mVideoCapturers.put(trackId, info);
572642
mVideoSources.put(trackId, videoSource);
@@ -609,6 +679,56 @@ public void onStop() {
609679
result.success(successResult.toMap());
610680
}
611681

682+
@RequiresApi(api = Build.VERSION_CODES.Q)
683+
private void startScreenAudioCapture(MediaProjection mediaProjection) {
684+
if (!ScreenAudioCapturer.isSupported()) {
685+
Log.w(TAG, "Screen audio capture not supported on this device");
686+
return;
687+
}
688+
689+
if (screenAudioCapturer == null) {
690+
screenAudioCapturer = new ScreenAudioCapturer(applicationContext);
691+
}
692+
693+
boolean started = screenAudioCapturer.startCapture(mediaProjection);
694+
screenAudioEnabled = started;
695+
696+
if (started) {
697+
Log.d(TAG, "Screen audio capture started successfully");
698+
} else {
699+
Log.w(TAG, "Failed to start screen audio capture");
700+
}
701+
}
702+
703+
private synchronized void stopScreenAudioCapture() {
704+
screenAudioEnabled = false;
705+
706+
ScreenAudioCapturer localCapturer = screenAudioCapturer;
707+
if (localCapturer != null) {
708+
localCapturer.stopCapture();
709+
Log.d(TAG, "Screen audio capture stopped");
710+
}
711+
}
712+
713+
/**
714+
* Returns whether screen audio capture is currently enabled.
715+
*/
716+
public boolean isScreenAudioEnabled() {
717+
return screenAudioEnabled && screenAudioCapturer != null && screenAudioCapturer.isCapturing();
718+
}
719+
720+
/**
721+
* Gets screen audio bytes for mixing with microphone audio.
722+
* Returns null if screen audio capture is not active.
723+
*/
724+
public ByteBuffer getScreenAudioBytes(int bytesRequested) {
725+
if (!isScreenAudioEnabled()) {
726+
return null;
727+
}
728+
729+
return screenAudioCapturer.getScreenAudioBytes(bytesRequested);
730+
}
731+
612732
/**
613733
* Implements {@code getUserMedia} with the knowledge that the necessary permissions have already
614734
* been granted. If the necessary permissions have not been granted yet, they will NOT be

android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import androidx.annotation.Nullable;
2424
import androidx.annotation.RequiresApi;
2525

26+
import io.getstream.webrtc.flutter.audio.AudioBufferMixer;
2627
import io.getstream.webrtc.flutter.audio.AudioDeviceKind;
2728
import io.getstream.webrtc.flutter.audio.AudioProcessingFactoryProvider;
2829
import io.getstream.webrtc.flutter.audio.AudioProcessingController;
@@ -202,6 +203,25 @@ void dispose() {
202203
mPeerConnectionObservers.clear();
203204
}
204205

206+
/**
207+
* Checks if the microphone is muted by examining all local audio tracks.
208+
* Returns true if all audio tracks are disabled or if there are no audio
209+
* tracks.
210+
*/
211+
private boolean isMicrophoneMuted() {
212+
synchronized (localTracks) {
213+
for (LocalTrack track : localTracks.values()) {
214+
if (track instanceof LocalAudioTrack) {
215+
if (track.enabled()) {
216+
return false;
217+
}
218+
}
219+
}
220+
}
221+
222+
return true;
223+
}
224+
205225
private void initialize(boolean bypassVoiceProcessing, int networkIgnoreMask, boolean forceSWCodec, List<String> forceSWCodecList,
206226
@Nullable ConstraintsMap androidAudioConfiguration, Severity logSeverity, @Nullable Integer audioSampleRate, @Nullable Integer audioOutputSampleRate) {
207227
if (mFactory != null) {
@@ -315,6 +335,24 @@ public void onWebRtcAudioRecordSamplesReady(JavaAudioDeviceModule.AudioSamples a
315335
audioDeviceModuleBuilder.setAudioAttributes(audioAttributes);
316336
}
317337

338+
// Set up audio buffer callback for screen audio mixing
339+
audioDeviceModuleBuilder.setAudioBufferCallback(
340+
(audioBuffer, audioFormat, channelCount, sampleRate, bytesRead, captureTimeNs) -> {
341+
boolean isMicrophoneMuted = isMicrophoneMuted();
342+
if (!isMicrophoneMuted && bytesRead > 0 && getUserMediaImpl != null && getUserMediaImpl.isScreenAudioEnabled()) {
343+
// Get screen audio bytes and mix with microphone audio
344+
ByteBuffer screenAudioBuffer = getUserMediaImpl.getScreenAudioBytes(bytesRead);
345+
if (screenAudioBuffer != null && screenAudioBuffer.remaining() > 0) {
346+
AudioBufferMixer.mixScreenAudioWithMicrophone(
347+
audioBuffer,
348+
screenAudioBuffer,
349+
bytesRead);
350+
}
351+
}
352+
353+
return captureTimeNs;
354+
});
355+
318356
audioDeviceModule = audioDeviceModuleBuilder.createAudioDeviceModule();
319357

320358
if(!bypassVoiceProcessing) {

0 commit comments

Comments
 (0)