From f441bf8839c88c343b409f632f6dfd2e76cf435e Mon Sep 17 00:00:00 2001 From: Brazol Date: Mon, 24 Nov 2025 14:38:06 +0100 Subject: [PATCH 1/2] audio playback pause/resume --- CHANGELOG.md | 7 +- .../webrtc/flutter/MethodCallHandlerImpl.java | 103 ++++++++++++++++++ .../flutter/PeerConnectionObserver.java | 9 ++ .../webrtc/flutter/StateProvider.java | 5 + common/darwin/Classes/FlutterWebRTCPlugin.m | 47 ++++++++ ios/stream_webrtc_flutter.podspec | 2 +- .../FlutterWebRTCPlugin.m | 47 ++++++++ lib/src/helper.dart | 8 ++ lib/src/native/audio_management.dart | 36 ++++++ macos/stream_webrtc_flutter.podspec | 2 +- pubspec.yaml | 2 +- 11 files changed, 263 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25fcb0a3a8..6d7fbb8e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,13 @@ # Changelog -[2.1.0] +[2.2.0] - 2025-11-24 +* Added `Helper.pauseAudioPlayout()` / `Helper.resumeAudioPlayout()` to mute and restore remote playback with platform-specific handling for iOS/macOS and Android. + +[2.1.0] - 2025-11-17 * [iOS] Added Swift Package Manager (SPM) support to iOS. -[2.0.0] +[2.0.0] - 2025-10-31 * [Android] Fixed the camera device facing mode detection. * Synced flutter-webrtc v0.14.2 * [Doc] fix: typo in package description (#1895) diff --git a/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java b/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java index 845ccb4e85..c09cdc746e 100644 --- a/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java +++ b/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java @@ -94,6 +94,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -146,6 +147,9 @@ public class MethodCallHandlerImpl implements MethodCallHandler, StateProvider { public AudioProcessingFactoryProvider audioProcessingFactoryProvider; private ConstraintsMap initializedAndroidAudioConfiguration; + private final Map trackVolumeCache = new ConcurrentHashMap<>(); + private final Map pausedTrackVolumes = new ConcurrentHashMap<>(); + private volatile boolean isAudioPlayoutPaused = false; public static class LogSink implements Loggable { @Override @@ -1147,6 +1151,24 @@ public void onInterruptionEnd() { } break; } + case "pauseAudioPlayout": { + executor.execute(() -> { + pauseAudioPlayoutInternal(); + mainHandler.post(() -> { + result.success(null); + }); + }); + break; + } + case "resumeAudioPlayout": { + executor.execute(() -> { + resumeAudioPlayoutInternal(); + mainHandler.post(() -> { + result.success(null); + }); + }); + break; + } case "startLocalRecording": { executor.execute(() -> { audioDeviceModule.prewarmRecording(); @@ -1222,6 +1244,53 @@ private PeerConnection getPeerConnection(String id) { return (pco == null) ? null : pco.getPeerConnection(); } + private void pauseAudioPlayoutInternal() { + isAudioPlayoutPaused = true; + + for (PeerConnectionObserver observer : mPeerConnectionObservers.values()) { + for (Map.Entry entry : observer.remoteTracks.entrySet()) { + MediaStreamTrack track = entry.getValue(); + if (track instanceof AudioTrack) { + String trackId = track.id(); + if (!pausedTrackVolumes.containsKey(trackId)) { + double previousVolume = trackVolumeCache.getOrDefault(trackId, 1.0); + pausedTrackVolumes.put(trackId, previousVolume); + } + try { + ((AudioTrack) track).setVolume(0.0); + } catch (Exception e) { + Log.e(TAG, "pauseAudioPlayoutInternal: setVolume failed for track " + track.id(), e); + } + } + } + } + } + + private boolean resumeAudioPlayoutInternal() { + isAudioPlayoutPaused = false; + + if (pausedTrackVolumes.isEmpty()) { + return; + } + + Map volumesToRestore = new HashMap<>(pausedTrackVolumes); + pausedTrackVolumes.clear(); + + for (Map.Entry entry : volumesToRestore.entrySet()) { + String trackId = entry.getKey(); + double targetVolume = entry.getValue(); + MediaStreamTrack track = getTrackForId(trackId, null); + if (track instanceof AudioTrack) { + try { + ((AudioTrack) track).setVolume(targetVolume); + trackVolumeCache.put(trackId, targetVolume); + } catch (Exception e) { + Log.e(TAG, "resumeAudioPlayoutInternal: setVolume failed for track " + trackId, e); + } + } + } + } + private List createIceServers(ConstraintsArray iceServersArray) { final int size = (iceServersArray == null) ? 0 : iceServersArray.size(); List iceServers = new ArrayList<>(size); @@ -1781,6 +1850,11 @@ public void mediaStreamTrackSetVolume(final String id, final double volume, Stri Log.d(TAG, "setVolume(): " + id + "," + volume); try { ((AudioTrack) track).setVolume(volume); + trackVolumeCache.put(id, volume); + if (!pausedTrackVolumes.isEmpty() && pausedTrackVolumes.containsKey(id)) { + pausedTrackVolumes.put(id, volume); + ((AudioTrack) track).setVolume(0.0); + } } catch (Exception e) { Log.e(TAG, "setVolume(): error", e); } @@ -2406,6 +2480,35 @@ public void rtpSenderSetStreams(String peerConnectionId, String rtpSenderId, Lis } } + @Override + public void onRemoteAudioTrackAdded(AudioTrack track) { + if (track == null) { + return; + } + + String trackId = track.id(); + trackVolumeCache.putIfAbsent(trackId, 1.0); + + if (isAudioPlayoutPaused) { + double previousVolume = trackVolumeCache.getOrDefault(trackId, 1.0); + pausedTrackVolumes.put(trackId, previousVolume); + try { + track.setVolume(0.0); + } catch (Exception e) { + Log.e(TAG, "onRemoteAudioTrackAdded: setVolume failed for track " + trackId, e); + } + } + } + + @Override + public void onRemoteAudioTrackRemoved(String trackId) { + if (trackId == null) { + return; + } + + pausedTrackVolumes.remove(trackId); + trackVolumeCache.remove(trackId); + } public void reStartCamera() { if (null == getUserMediaImpl) { diff --git a/android/src/main/java/io/getstream/webrtc/flutter/PeerConnectionObserver.java b/android/src/main/java/io/getstream/webrtc/flutter/PeerConnectionObserver.java index bbced256f7..dcb394fe2c 100755 --- a/android/src/main/java/io/getstream/webrtc/flutter/PeerConnectionObserver.java +++ b/android/src/main/java/io/getstream/webrtc/flutter/PeerConnectionObserver.java @@ -430,6 +430,7 @@ public void onAddStream(MediaStream mediaStream) { String trackId = track.id(); remoteTracks.put(trackId, track); + stateProvider.onRemoteAudioTrackAdded(track); ConstraintsMap trackInfo = new ConstraintsMap(); trackInfo.putString("id", trackId); @@ -462,6 +463,7 @@ public void onRemoveStream(MediaStream mediaStream) { } for (AudioTrack track : mediaStream.audioTracks) { this.remoteTracks.remove(track.id()); + stateProvider.onRemoteAudioTrackRemoved(track.id()); } ConstraintsMap params = new ConstraintsMap(); @@ -500,6 +502,9 @@ public void onAddTrack(RtpReceiver receiver, MediaStream[] mediaStreams) { if ("audio".equals(track.kind())) { AudioSwitchManager.instance.start(); + if (track instanceof AudioTrack) { + stateProvider.onRemoteAudioTrackAdded((AudioTrack) track); + } } } @@ -538,6 +543,10 @@ public void onRemoveTrack(RtpReceiver rtpReceiver) { MediaStreamTrack track = rtpReceiver.track(); String trackId = track.id(); + remoteTracks.remove(trackId); + if ("audio".equals(track.kind())) { + stateProvider.onRemoteAudioTrackRemoved(trackId); + } ConstraintsMap trackInfo = new ConstraintsMap(); trackInfo.putString("id", trackId); trackInfo.putString("label", track.kind()); diff --git a/android/src/main/java/io/getstream/webrtc/flutter/StateProvider.java b/android/src/main/java/io/getstream/webrtc/flutter/StateProvider.java index 270f08b49f..cad70306f8 100644 --- a/android/src/main/java/io/getstream/webrtc/flutter/StateProvider.java +++ b/android/src/main/java/io/getstream/webrtc/flutter/StateProvider.java @@ -5,6 +5,7 @@ import androidx.annotation.Nullable; import java.util.Map; +import org.webrtc.AudioTrack; import org.webrtc.MediaStream; import org.webrtc.MediaStreamTrack; import org.webrtc.PeerConnectionFactory; @@ -39,4 +40,8 @@ public interface StateProvider { Context getApplicationContext(); BinaryMessenger getMessenger(); + + void onRemoteAudioTrackAdded(AudioTrack track); + + void onRemoteAudioTrackRemoved(String trackId); } diff --git a/common/darwin/Classes/FlutterWebRTCPlugin.m b/common/darwin/Classes/FlutterWebRTCPlugin.m index eca2f10a03..841e6635c3 100644 --- a/common/darwin/Classes/FlutterWebRTCPlugin.m +++ b/common/darwin/Classes/FlutterWebRTCPlugin.m @@ -1647,6 +1647,53 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { details:nil]); } #endif + } else if ([@"resumeAudioPlayout" isEqualToString:call.method]) { + RTCAudioDeviceModule* adm = _peerConnectionFactory.audioDeviceModule; + if (adm == nil) { + result([FlutterError errorWithCode:@"resumeAudioPlayout failed" + message:@"Error: audioDeviceModule is nil" + details:nil]); + return; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + NSInteger admResult = [adm initPlayout]; + if (admResult == 0) { + admResult = [adm startPlayout]; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (admResult == 0) { + result(nil); + } else { + result([FlutterError + errorWithCode:@"resumeAudioPlayout failed" + message:[NSString stringWithFormat:@"Error: adm api failed with code: %ld", + (long)admResult] + details:nil]); + } + }); + }); + } else if ([@"pauseAudioPlayout" isEqualToString:call.method]) { + RTCAudioDeviceModule* adm = _peerConnectionFactory.audioDeviceModule; + if (adm == nil) { + result([FlutterError errorWithCode:@"pauseAudioPlayout failed" + message:@"Error: audioDeviceModule is nil" + details:nil]); + return; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + NSInteger admResult = [adm stopPlayout]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (admResult == 0) { + result(nil); + } else { + result([FlutterError + errorWithCode:@"pauseAudioPlayout failed" + message:[NSString stringWithFormat:@"Error: adm api failed with code: %ld", + (long)admResult] + details:nil]); + } + }); + }); } else if ([@"startLocalRecording" isEqualToString:call.method]) { RTCAudioDeviceModule* adm = _peerConnectionFactory.audioDeviceModule; // Run on background queue diff --git a/ios/stream_webrtc_flutter.podspec b/ios/stream_webrtc_flutter.podspec index 510732cd03..81cc0534c3 100644 --- a/ios/stream_webrtc_flutter.podspec +++ b/ios/stream_webrtc_flutter.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'stream_webrtc_flutter' - s.version = '2.1.0' + s.version = '2.2.0' s.summary = 'Flutter WebRTC plugin for iOS.' s.description = <<-DESC A new flutter plugin project. diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index f2d9a5d2c2..5e5cc65dcf 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -1638,6 +1638,53 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { details:nil]); } #endif + } else if ([@"resumeAudioPlayout" isEqualToString:call.method]) { + RTCAudioDeviceModule* adm = _peerConnectionFactory.audioDeviceModule; + if (adm == nil) { + result([FlutterError errorWithCode:@"resumeAudioPlayout failed" + message:@"Error: audioDeviceModule is nil" + details:nil]); + return; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + NSInteger admResult = [adm initPlayout]; + if (admResult == 0) { + admResult = [adm startPlayout]; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (admResult == 0) { + result(nil); + } else { + result([FlutterError + errorWithCode:@"resumeAudioPlayout failed" + message:[NSString stringWithFormat:@"Error: adm api failed with code: %ld", + (long)admResult] + details:nil]); + } + }); + }); + } else if ([@"pauseAudioPlayout" isEqualToString:call.method]) { + RTCAudioDeviceModule* adm = _peerConnectionFactory.audioDeviceModule; + if (adm == nil) { + result([FlutterError errorWithCode:@"pauseAudioPlayout failed" + message:@"Error: audioDeviceModule is nil" + details:nil]); + return; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + NSInteger admResult = [adm stopPlayout]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (admResult == 0) { + result(nil); + } else { + result([FlutterError + errorWithCode:@"pauseAudioPlayout failed" + message:[NSString stringWithFormat:@"Error: adm api failed with code: %ld", + (long)admResult] + details:nil]); + } + }); + }); } else if ([@"startLocalRecording" isEqualToString:call.method]) { RTCAudioDeviceModule* adm = _peerConnectionFactory.audioDeviceModule; // Run on background queue diff --git a/lib/src/helper.dart b/lib/src/helper.dart index a6b7d17318..37ff706c82 100644 --- a/lib/src/helper.dart +++ b/lib/src/helper.dart @@ -158,6 +158,14 @@ class Helper { static Future setMicrophoneMute(bool mute, MediaStreamTrack track) => NativeAudioManagement.setMicrophoneMute(mute, track); + /// Resume remote audio playout after a pause (iOS/macOS WebRTC ADM, Android track volume restore) + static Future resumeAudioPlayout() => + NativeAudioManagement.resumeAudioPlayout(); + + /// Pause remote audio playout (iOS/macOS via ADM, Android by muting remote tracks) + static Future pauseAudioPlayout() => + NativeAudioManagement.pauseAudioPlayout(); + /// Set the audio configuration to for Android. /// Must be set before initiating a WebRTC session and cannot be changed /// mid session. diff --git a/lib/src/native/audio_management.dart b/lib/src/native/audio_management.dart index 878391f120..1723e0d35c 100644 --- a/lib/src/native/audio_management.dart +++ b/lib/src/native/audio_management.dart @@ -68,6 +68,42 @@ class NativeAudioManagement { } // ADM APIs + static Future resumeAudioPlayout() async { + if (kIsWeb) return; + if (!(WebRTC.platformIsIOS || + WebRTC.platformIsAndroid || + WebRTC.platformIsMacOS)) { + return; + } + + try { + await WebRTC.invokeMethod( + 'resumeAudioPlayout', + {}, + ); + } on PlatformException catch (e) { + throw 'Unable to resume audio playout: ${e.message}'; + } + } + + static Future pauseAudioPlayout() async { + if (kIsWeb) return; + if (!(WebRTC.platformIsIOS || + WebRTC.platformIsAndroid || + WebRTC.platformIsMacOS)) { + return; + } + + try { + await WebRTC.invokeMethod( + 'pauseAudioPlayout', + {}, + ); + } on PlatformException catch (e) { + throw 'Unable to pause audio playout: ${e.message}'; + } + } + static Future startLocalRecording() async { if (!kIsWeb) { try { diff --git a/macos/stream_webrtc_flutter.podspec b/macos/stream_webrtc_flutter.podspec index 378cd7c74a..8eda182151 100644 --- a/macos/stream_webrtc_flutter.podspec +++ b/macos/stream_webrtc_flutter.podspec @@ -3,7 +3,7 @@ # Pod::Spec.new do |s| s.name = 'stream_webrtc_flutter' - s.version = '2.1.0' + s.version = '2.2.0' s.summary = 'Flutter WebRTC plugin for macOS.' s.description = <<-DESC A new flutter plugin project. diff --git a/pubspec.yaml b/pubspec.yaml index 7eedba6077..422ef972eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: stream_webrtc_flutter description: Flutter WebRTC plugin for iOS/Android/Destkop/Web, based on GoogleWebRTC. -version: 2.1.0 +version: 2.2.0 homepage: https://github.com/GetStream/webrtc-flutter environment: sdk: ">=3.6.0 <4.0.0" From d6b58f1e3c6179c0142b23df2dbfc74222c86622 Mon Sep 17 00:00:00 2001 From: Brazol Date: Mon, 24 Nov 2025 14:48:31 +0100 Subject: [PATCH 2/2] fix --- .../java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java b/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java index c09cdc746e..7c267f1df9 100644 --- a/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java +++ b/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java @@ -1266,7 +1266,7 @@ private void pauseAudioPlayoutInternal() { } } - private boolean resumeAudioPlayoutInternal() { + private void resumeAudioPlayoutInternal() { isAudioPlayoutPaused = false; if (pausedTrackVolumes.isEmpty()) {