From e58ba1a719ac25132dc9ebad5d6be61dc1d447cc Mon Sep 17 00:00:00 2001 From: Brazol Date: Wed, 22 Oct 2025 18:13:15 +0200 Subject: [PATCH 1/7] fixing screen share cancelation --- packages/stream_video/lib/src/call/call.dart | 39 +++++++- .../lib/src/call/session/call_session.dart | 11 ++- .../lib/src/webrtc/rtc_manager.dart | 42 +++++++++ .../rtc_media_device_notifier.dart | 30 ++++++ .../lib/src/webrtc/rtc_track/rtc_track.dart | 4 + packages/stream_video/pubspec.yaml | 3 +- .../stream_video_flutter/example/pubspec.yaml | 3 +- .../ScreenShare/ScreenShareManager.swift | 94 +++++++++++++++++++ .../Classes/StreamVideoFlutterPlugin.swift | 18 ++++ .../call_background/background_service.dart | 40 +++++++- packages/stream_video_flutter/pubspec.yaml | 4 +- .../pubspec.yaml | 3 +- .../pubspec.yaml | 3 +- 13 files changed, 283 insertions(+), 11 deletions(-) create mode 100644 packages/stream_video_flutter/ios/Classes/ScreenShare/ScreenShareManager.swift diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 39de0c198..b6740a8e0 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -8,6 +8,7 @@ import 'package:async/async.dart' show CancelableOperation; import 'package:collection/collection.dart'; import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; import 'package:meta/meta.dart'; +import 'package:rxdart/transformers.dart'; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart'; import 'package:synchronized/synchronized.dart'; @@ -87,6 +88,7 @@ const _idConnect = 6; const _idAwait = 7; const _idFastReconnectTimeout = 8; const _idReconnect = 9; +const _idNativeWebRtc = 10; const _tag = 'SV:Call'; int _callSeq = 1; @@ -381,12 +383,20 @@ class Call { _observeState(); _observeReconnectEvents(); _observeUserId(); + _observeNativeWebRtcEventStream(); _logger.v(() => '[_init] initialized'); _initialized = true; }); } + void _observeNativeWebRtcEventStream() { + _subscriptions.add( + _idNativeWebRtc, + _onNativeWebRtcEvent(), + ); + } + void _observeState() { _subscriptions.add( _idState, @@ -458,6 +468,34 @@ class Call { state.settings.audio.redundantCodingEnabled; } + StreamSubscription _onNativeWebRtcEvent() { + return RtcMediaDeviceNotifier.instance + .nativeWebRtcEventsStream() + .whereType() + .listen((event) { + _logger.d( + () => '[_onNativeWebRtcEvent] screenSharingStopped: $event', + ); + + if (CurrentPlatform.isIos) { + // On iOS only one broadcast extension can be active at a time + setScreenShareEnabled(enabled: false); + } else { + final trackId = event.data?['trackId'] as String?; + if (trackId != null) { + final track = getTrack( + state.value.localParticipant!.trackIdPrefix, + SfuTrackType.screenShare, + ); + + if (track?.mediaTrack.id == trackId) { + setScreenShareEnabled(enabled: false); + } + } + } + }); + } + Future _onCoordinatorEvent(StreamCallEvent event) async { // Return if the event is not for this call. if (event.callCid != state.value.callCid) return; @@ -559,7 +597,6 @@ class Call { return _stateManager.callMetadataChanged(event.metadata); case StreamCallSessionStartedEvent _: return _stateManager.callMetadataChanged(event.metadata); - default: break; } diff --git a/packages/stream_video/lib/src/call/session/call_session.dart b/packages/stream_video/lib/src/call/session/call_session.dart index 2b8bd07cc..166845e54 100644 --- a/packages/stream_video/lib/src/call/session/call_session.dart +++ b/packages/stream_video/lib/src/call/session/call_session.dart @@ -770,7 +770,16 @@ class CallSession extends Disposable { if (track == null) return; // Only stop remote tracks. Local tracks are stopped by the user. - if (track is! RtcRemoteTrack) return; + if (track is! RtcRemoteTrack) { + final localTrack = rtcManager?.getTrack(track.trackId); + if (localTrack != null && + localTrack.isScreenShareTrack && + localTrack.mediaTrack.enabled) { + await setScreenShareEnabled(false); + } + + return; + } await track.stop(); } diff --git a/packages/stream_video/lib/src/webrtc/rtc_manager.dart b/packages/stream_video/lib/src/webrtc/rtc_manager.dart index c9d6c60fe..812f02626 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_manager.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_manager.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; +import 'package:rxdart/transformers.dart'; import 'package:sdp_transform/sdp_transform.dart'; import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; @@ -28,6 +31,7 @@ import 'model/rtc_video_parameters.dart'; import 'peer_connection.dart'; import 'peer_type.dart'; import 'rtc_media_device/rtc_media_device.dart'; +import 'rtc_media_device/rtc_media_device_notifier.dart'; import 'rtc_parser.dart'; import 'rtc_track/rtc_track.dart'; import 'traced_peer_connection.dart'; @@ -1292,6 +1296,29 @@ extension RtcManagerTrackHelper on RtcManager { (constraints ?? const ScreenShareConstraints()) as ScreenShareConstraints, ); + + // On iOS, wait for the native WebRTC event indicating that + // screen sharing has started when using the Broadcast Extension. + if (CurrentPlatform.isIos && + screenShareTrackResult.isSuccess && + constraints is ScreenShareConstraints && + constraints.useiOSBroadcastExtension) { + return awaitNativeWebRtcEvent() + .then( + (value) => publishVideoTrack( + track: screenShareTrackResult.getDataOrNull()!, + ), + ) + .timeout( + const Duration(seconds: 15), + onTimeout: () { + return Result.error( + 'Timeout waiting for ScreenSharingStartedEvent', + ); + }, + ); + } + return screenShareTrackResult.fold( success: (it) => publishVideoTrack(track: it.data), failure: (it) => it, @@ -1302,6 +1329,21 @@ extension RtcManagerTrackHelper on RtcManager { return Result.error('Unsupported trackType $trackType'); } + Future awaitNativeWebRtcEvent() { + final completer = Completer(); + + StreamSubscription? rtcEventsSubscription; + rtcEventsSubscription = RtcMediaDeviceNotifier.instance + .nativeWebRtcEventsStream() + .whereType() + .listen((event) { + completer.complete(); + rtcEventsSubscription?.cancel(); + }); + + return completer.future; + } + Future> setAppleAudioConfiguration({ bool speakerOn = false, }) async { diff --git a/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart b/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart index 0b0778ed6..fe1930a24 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart @@ -15,6 +15,18 @@ class InterruptionBeginEvent extends InterruptionEvent {} class InterruptionEndEvent extends InterruptionEvent {} +abstract class NativeWebRtcEvent {} + +class ScreenSharingStoppedEvent extends NativeWebRtcEvent { + ScreenSharingStoppedEvent({this.data}); + final Map? data; +} + +class ScreenSharingStartedEvent extends NativeWebRtcEvent { + ScreenSharingStartedEvent({this.data}); + final Map? data; +} + class RtcMediaDeviceNotifier { RtcMediaDeviceNotifier._internal() { // Debounce call the onDeviceChange callback. @@ -71,6 +83,24 @@ class RtcMediaDeviceNotifier { ); } + Stream nativeWebRtcEventsStream() { + return rtc.eventStream + .map((data) { + final event = data.keys.first; + final Map values = data.values.first; + switch (event) { + case 'screenSharingStopped': + return ScreenSharingStoppedEvent(data: values); + case 'screenSharingStarted': + return ScreenSharingStartedEvent(data: values); + default: + return null; + } + }) + .whereNotNull() + .asBroadcastStream(); + } + Future _onDeviceChange(_) async { final devicesResult = await enumerateDevices(); final devices = devicesResult.getDataOrNull(); diff --git a/packages/stream_video/lib/src/webrtc/rtc_track/rtc_track.dart b/packages/stream_video/lib/src/webrtc/rtc_track/rtc_track.dart index 6943d4634..252797c01 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_track/rtc_track.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_track/rtc_track.dart @@ -39,6 +39,10 @@ abstract class RtcTrack { trackType == SfuTrackType.audio || trackType == SfuTrackType.screenShareAudio; + bool get isScreenShareTrack => + trackType == SfuTrackType.screenShare || + trackType == SfuTrackType.screenShareAudio; + void enable() { // Return if the track is already enabled. if (mediaTrack.enabled) return; diff --git a/packages/stream_video/pubspec.yaml b/packages/stream_video/pubspec.yaml index c002e1ea1..e05e750c2 100644 --- a/packages/stream_video/pubspec.yaml +++ b/packages/stream_video/pubspec.yaml @@ -31,7 +31,8 @@ dependencies: rxdart: ^0.28.0 sdp_transform: ^0.3.2 state_notifier: ^1.0.0 - stream_webrtc_flutter: ^1.0.12 + stream_webrtc_flutter: + path: ../../../webrtc-flutter synchronized: ^3.1.0 system_info2: ^4.0.0 tart: ^0.6.0 diff --git a/packages/stream_video_flutter/example/pubspec.yaml b/packages/stream_video_flutter/example/pubspec.yaml index db41989dc..4141e71a8 100644 --- a/packages/stream_video_flutter/example/pubspec.yaml +++ b/packages/stream_video_flutter/example/pubspec.yaml @@ -30,7 +30,8 @@ dependencies: stream_video: ^0.11.1 stream_video_flutter: ^0.11.1 stream_video_push_notification: ^0.11.1 - stream_webrtc_flutter: ^1.0.12 + stream_webrtc_flutter: + path: ../../../../webrtc-flutter dependency_overrides: stream_video: diff --git a/packages/stream_video_flutter/ios/Classes/ScreenShare/ScreenShareManager.swift b/packages/stream_video_flutter/ios/Classes/ScreenShare/ScreenShareManager.swift new file mode 100644 index 000000000..cd434c5ae --- /dev/null +++ b/packages/stream_video_flutter/ios/Classes/ScreenShare/ScreenShareManager.swift @@ -0,0 +1,94 @@ +import Foundation + +/// A lightweight wrapper around `CFNotificationCenter` +final class ScreenShareManager { + + static let shared = ScreenShareManager() + + /// Closure invoked whenever an observed notification is received. + var onNotification: ((String) -> Void)? + + private let notificationCenter: CFNotificationCenter + private var observedNames: Set = [] + private let lock = NSLock() + + private init() { + notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() + } + + deinit { + removeAllObservers() + } + + /// Begins listening for notifications with the given name. + func observeNotification(named name: String) { + lock.lock() + defer { lock.unlock() } + + guard !observedNames.contains(name) else { return } + observedNames.insert(name) + + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + notificationCenter, + observer, + ScreenShareManager.notificationCallback, + name as CFString, + nil, + .deliverImmediately + ) + } + + /// Stops listening for notifications with the specified name. + func removeObserver(named name: String) { + lock.lock() + defer { lock.unlock() } + + guard observedNames.contains(name) else { return } + observedNames.remove(name) + + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterRemoveObserver( + notificationCenter, + observer, + CFNotificationName(name as CFString), + nil + ) + } + + /// Removes all registered CFNotification observers. + func removeAllObservers() { + lock.lock() + let names = observedNames + observedNames.removeAll() + lock.unlock() + + let observer = Unmanaged.passUnretained(self).toOpaque() + for name in names { + CFNotificationCenterRemoveObserver( + notificationCenter, + observer, + CFNotificationName(name as CFString), + nil + ) + } + } + + private func handleNotification(named name: String) { + guard let handler = onNotification else { return } + DispatchQueue.main.async { handler(name) } + } + + private static let notificationCallback: CFNotificationCallback = { _, observer, name, _, _ in + guard + let observer, + let rawName = name?.rawValue as String? + else { return } + + let manager = Unmanaged + .fromOpaque(observer) + .takeUnretainedValue() + + manager.handleNotification(named: rawName) + } +} diff --git a/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift b/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift index 4aa38e420..25450f772 100644 --- a/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift +++ b/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift @@ -3,6 +3,9 @@ import UIKit import stream_webrtc_flutter public class StreamVideoFlutterPlugin: NSObject, FlutterPlugin { + static let broadcastStartedNotification = "io.getstream.video.screen_sharing.broadcastStarted" + static let broadcastStoppedNotification = "io.getstream.video.screen_sharing.broadcastStopped" + public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( name: "stream_video_flutter", binaryMessenger: registrar.messenger()) @@ -13,6 +16,21 @@ public class StreamVideoFlutterPlugin: NSObject, FlutterPlugin { registrar.register( factory, withId: "stream-pip-view") + + ScreenShareManager.shared.observeNotification(named: broadcastStartedNotification) + ScreenShareManager.shared.observeNotification(named: broadcastStoppedNotification) + ScreenShareManager.shared.onNotification = { name in + switch name { + case broadcastStartedNotification: + FlutterWebRTCPlugin.sharedSingleton()?.postEvent( + withName: "screenSharingStarted", data: nil) + case broadcastStoppedNotification: + FlutterWebRTCPlugin.sharedSingleton()?.postEvent( + withName: "screenSharingStopped", data: nil) + default: + break + } + } } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { diff --git a/packages/stream_video_flutter/lib/src/call_background/background_service.dart b/packages/stream_video_flutter/lib/src/call_background/background_service.dart index 36f8f2f77..3038d136c 100644 --- a/packages/stream_video_flutter/lib/src/call_background/background_service.dart +++ b/packages/stream_video_flutter/lib/src/call_background/background_service.dart @@ -1,7 +1,10 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:rxdart/transformers.dart'; import 'package:stream_video/stream_video.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; import '../../stream_video_flutter_background.dart'; import 'model/notification_options.dart'; @@ -35,8 +38,7 @@ class StreamBackgroundService { StreamBackgroundService._(); static final StreamBackgroundService _instance = StreamBackgroundService._(); - - static StreamSubscription>? _activeCalSubscription; + static final Subscriptions _subscriptions = Subscriptions(); // Map to store context for each managed call final Map _managedCalls = {}; @@ -132,8 +134,8 @@ class StreamBackgroundService { await onPlatformUiLayerDestroyed?.call(context.call); }); - _activeCalSubscription?.cancel(); - _activeCalSubscription = streamVideo.listenActiveCalls(( + _subscriptions.cancelAll(); + final activeCallSubscription = streamVideo.listenActiveCalls(( List currentCalls, ) async { final currentCallCids = currentCalls.map((c) => c.callCid.value).toSet(); @@ -172,6 +174,36 @@ class StreamBackgroundService { } } }); + + _subscriptions.add(1, activeCallSubscription); + + final rtcEventsSubscription = RtcMediaDeviceNotifier.instance + .nativeWebRtcEventsStream() + .whereType() + .listen((event) async { + final trackId = event.data?['trackId'] as String?; + if (trackId != null) { + final call = streamVideo.activeCalls.singleWhereOrNull( + (call) => + call + .getTrack( + call.state.value.localParticipant!.trackIdPrefix, + SfuTrackType.screenShare, + ) + ?.mediaTrack + .id == + trackId, + ); + + if (call != null) { + await _instance.stopScreenSharingNotificationService( + call.callCid.value, + ); + } + } + }); + + _subscriptions.add(2, rtcEventsSubscription); } Future _startManagingCall( diff --git a/packages/stream_video_flutter/pubspec.yaml b/packages/stream_video_flutter/pubspec.yaml index 3598760b0..c19427f36 100644 --- a/packages/stream_video_flutter/pubspec.yaml +++ b/packages/stream_video_flutter/pubspec.yaml @@ -22,8 +22,10 @@ dependencies: permission_handler: ^12.0.1 plugin_platform_interface: ^2.1.8 rate_limiter: ^1.0.0 + rxdart: ^0.28.0 stream_video: ^0.11.1 - stream_webrtc_flutter: ^1.0.12 + stream_webrtc_flutter: + path: ../../../webrtc-flutter visibility_detector: ^0.4.0+2 dev_dependencies: diff --git a/packages/stream_video_noise_cancellation/pubspec.yaml b/packages/stream_video_noise_cancellation/pubspec.yaml index 990c2e46b..0d389d014 100644 --- a/packages/stream_video_noise_cancellation/pubspec.yaml +++ b/packages/stream_video_noise_cancellation/pubspec.yaml @@ -15,7 +15,8 @@ dependencies: sdk: flutter plugin_platform_interface: ^2.0.2 stream_video: ^0.11.1 - stream_webrtc_flutter: ^1.0.12 + stream_webrtc_flutter: + path: ../../../webrtc-flutter dev_dependencies: flutter_lints: ^6.0.0 diff --git a/packages/stream_video_push_notification/pubspec.yaml b/packages/stream_video_push_notification/pubspec.yaml index 8438fe697..c7cca7a46 100644 --- a/packages/stream_video_push_notification/pubspec.yaml +++ b/packages/stream_video_push_notification/pubspec.yaml @@ -23,7 +23,8 @@ dependencies: rxdart: ^0.28.0 shared_preferences: ^2.5.3 stream_video: ^0.11.1 - stream_webrtc_flutter: ^1.0.12 + stream_webrtc_flutter: + path: ../../../webrtc-flutter uuid: ^4.5.1 dev_dependencies: From 014ba4952fd2a3a55623765ed78a4b5f2cc1a413 Mon Sep 17 00:00:00 2001 From: Brazol Date: Thu, 23 Oct 2025 10:12:59 +0200 Subject: [PATCH 2/7] tweaks --- packages/stream_video/lib/src/call/call.dart | 12 +++++++++--- .../lib/src/call/session/call_session.dart | 2 ++ .../stream_video/lib/src/webrtc/rtc_manager.dart | 6 ++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index b6740a8e0..c229ccb8a 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -1999,13 +1999,19 @@ class Call { final mediaConstraints = track.mediaConstraints; if (mediaConstraints is AudioConstraints) { _logger.v(() => '[setLocalTrack]: setMicrophoneEnabled true'); - await setMicrophoneEnabled(enabled: true); + await setMicrophoneEnabled( + enabled: true, + constraints: mediaConstraints, + ); } else if (mediaConstraints is CameraConstraints) { _logger.v(() => '[setLocalTrack]: setCameraEnabled true'); - await setCameraEnabled(enabled: true); + await setCameraEnabled(enabled: true, constraints: mediaConstraints); } else if (mediaConstraints is ScreenShareConstraints) { _logger.v(() => '[setLocalTrack] setScreenShareEnabled true'); - await setScreenShareEnabled(enabled: true); + await setScreenShareEnabled( + enabled: true, + constraints: mediaConstraints, + ); } else { streamLog.e( _tag, diff --git a/packages/stream_video/lib/src/call/session/call_session.dart b/packages/stream_video/lib/src/call/session/call_session.dart index 166845e54..e8b34fcd8 100644 --- a/packages/stream_video/lib/src/call/session/call_session.dart +++ b/packages/stream_video/lib/src/call/session/call_session.dart @@ -775,6 +775,8 @@ class CallSession extends Disposable { if (localTrack != null && localTrack.isScreenShareTrack && localTrack.mediaTrack.enabled) { + // If the unpublished track is a local screen share track and it's still enabled, + // disable screen sharing. It means the screen sharing was muted by the server. await setScreenShareEnabled(false); } diff --git a/packages/stream_video/lib/src/webrtc/rtc_manager.dart b/packages/stream_video/lib/src/webrtc/rtc_manager.dart index 812f02626..a29a27b25 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_manager.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_manager.dart @@ -1185,9 +1185,7 @@ extension RtcManagerTrackHelper on RtcManager { // Track found, mute/unmute it. if (track != null) { - if (enabled && - track is RtcLocalScreenShareTrack && - !track.compareScreenShareMode(constraints)) { + if (enabled && track is RtcLocalScreenShareTrack) { return _createAndPublishTrack( trackType: trackType, constraints: constraints, @@ -1310,7 +1308,7 @@ extension RtcManagerTrackHelper on RtcManager { ), ) .timeout( - const Duration(seconds: 15), + const Duration(seconds: 30), onTimeout: () { return Result.error( 'Timeout waiting for ScreenSharingStartedEvent', From fbf12ba65052f5a994f6e2afd6b370d33461c148 Mon Sep 17 00:00:00 2001 From: Brazol Date: Thu, 23 Oct 2025 10:13:15 +0200 Subject: [PATCH 3/7] fix --- .../lib/src/call_background/background_service.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_video_flutter/lib/src/call_background/background_service.dart b/packages/stream_video_flutter/lib/src/call_background/background_service.dart index 3038d136c..0d0b9eaec 100644 --- a/packages/stream_video_flutter/lib/src/call_background/background_service.dart +++ b/packages/stream_video_flutter/lib/src/call_background/background_service.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:rxdart/transformers.dart'; import 'package:stream_video/stream_video.dart'; -import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; import '../../stream_video_flutter_background.dart'; import 'model/notification_options.dart'; From 241af76c3ad5a3d8a879f63c9c008ff0572d6c6c Mon Sep 17 00:00:00 2001 From: Brazol Date: Fri, 24 Oct 2025 14:06:36 +0200 Subject: [PATCH 4/7] changelog --- packages/stream_video/CHANGELOG.md | 7 +++++++ packages/stream_video_flutter/CHANGELOG.md | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 85d67bb97..9788418a6 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,3 +1,10 @@ +# Unreleased + +🐞 Fixed + +- [Android/iOS] Fixed an issue where screen sharing was not stopped correctly when canceled via the system UI on Android or iOS. +- [iOS] Improved broadcast extension handling — the app now waits for the broadcast picker selection before actually starting screen sharing. + ## 0.11.1 🔄 Changed diff --git a/packages/stream_video_flutter/CHANGELOG.md b/packages/stream_video_flutter/CHANGELOG.md index 29be9ff12..c5f6cf511 100644 --- a/packages/stream_video_flutter/CHANGELOG.md +++ b/packages/stream_video_flutter/CHANGELOG.md @@ -1,3 +1,10 @@ +# Unreleased + +🐞 Fixed + +- [Android/iOS] Fixed an issue where screen sharing was not stopped correctly when canceled via the system UI on Android or iOS. +- [iOS] Improved broadcast extension handling — the app now waits for the broadcast picker selection before actually starting screen sharing. + ## 0.11.1 🔄 Changed From 9257fcc470c47c8685e2ef4fad5af98ee122d9b6 Mon Sep 17 00:00:00 2001 From: Brazol Date: Tue, 28 Oct 2025 11:07:53 +0100 Subject: [PATCH 5/7] webrtc dependency bump --- melos.yaml | 2 +- packages/stream_video/pubspec.yaml | 3 +-- packages/stream_video_flutter/example/pubspec.yaml | 3 +-- packages/stream_video_flutter/pubspec.yaml | 3 +-- packages/stream_video_noise_cancellation/pubspec.yaml | 3 +-- packages/stream_video_push_notification/pubspec.yaml | 3 +-- 6 files changed, 6 insertions(+), 11 deletions(-) diff --git a/melos.yaml b/melos.yaml index 365f67530..a9dabce1e 100644 --- a/melos.yaml +++ b/melos.yaml @@ -22,7 +22,7 @@ command: device_info_plus: ^12.1.0 share_plus: ^11.0.0 stream_chat_flutter: ^9.17.0 - stream_webrtc_flutter: ^1.0.12 + stream_webrtc_flutter: ^1.0.13 stream_video: ^0.11.1 stream_video_flutter: ^0.11.1 stream_video_noise_cancellation: ^0.11.1 diff --git a/packages/stream_video/pubspec.yaml b/packages/stream_video/pubspec.yaml index e05e750c2..2959ae860 100644 --- a/packages/stream_video/pubspec.yaml +++ b/packages/stream_video/pubspec.yaml @@ -31,8 +31,7 @@ dependencies: rxdart: ^0.28.0 sdp_transform: ^0.3.2 state_notifier: ^1.0.0 - stream_webrtc_flutter: - path: ../../../webrtc-flutter + stream_webrtc_flutter: ^1.0.13 synchronized: ^3.1.0 system_info2: ^4.0.0 tart: ^0.6.0 diff --git a/packages/stream_video_flutter/example/pubspec.yaml b/packages/stream_video_flutter/example/pubspec.yaml index 4141e71a8..9292c8656 100644 --- a/packages/stream_video_flutter/example/pubspec.yaml +++ b/packages/stream_video_flutter/example/pubspec.yaml @@ -30,8 +30,7 @@ dependencies: stream_video: ^0.11.1 stream_video_flutter: ^0.11.1 stream_video_push_notification: ^0.11.1 - stream_webrtc_flutter: - path: ../../../../webrtc-flutter + stream_webrtc_flutter: ^1.0.13 dependency_overrides: stream_video: diff --git a/packages/stream_video_flutter/pubspec.yaml b/packages/stream_video_flutter/pubspec.yaml index c19427f36..7270758d4 100644 --- a/packages/stream_video_flutter/pubspec.yaml +++ b/packages/stream_video_flutter/pubspec.yaml @@ -24,8 +24,7 @@ dependencies: rate_limiter: ^1.0.0 rxdart: ^0.28.0 stream_video: ^0.11.1 - stream_webrtc_flutter: - path: ../../../webrtc-flutter + stream_webrtc_flutter: ^1.0.13 visibility_detector: ^0.4.0+2 dev_dependencies: diff --git a/packages/stream_video_noise_cancellation/pubspec.yaml b/packages/stream_video_noise_cancellation/pubspec.yaml index 0d389d014..9331bbec0 100644 --- a/packages/stream_video_noise_cancellation/pubspec.yaml +++ b/packages/stream_video_noise_cancellation/pubspec.yaml @@ -15,8 +15,7 @@ dependencies: sdk: flutter plugin_platform_interface: ^2.0.2 stream_video: ^0.11.1 - stream_webrtc_flutter: - path: ../../../webrtc-flutter + stream_webrtc_flutter: ^1.0.13 dev_dependencies: flutter_lints: ^6.0.0 diff --git a/packages/stream_video_push_notification/pubspec.yaml b/packages/stream_video_push_notification/pubspec.yaml index c7cca7a46..6f8f0e888 100644 --- a/packages/stream_video_push_notification/pubspec.yaml +++ b/packages/stream_video_push_notification/pubspec.yaml @@ -23,8 +23,7 @@ dependencies: rxdart: ^0.28.0 shared_preferences: ^2.5.3 stream_video: ^0.11.1 - stream_webrtc_flutter: - path: ../../../webrtc-flutter + stream_webrtc_flutter: ^1.0.13 uuid: ^4.5.1 dev_dependencies: From d2383f66e1fa9ee60e01db8d6f5f765284273fae Mon Sep 17 00:00:00 2001 From: Brazol Date: Tue, 28 Oct 2025 16:11:33 +0100 Subject: [PATCH 6/7] changed approach to subscribtion for ios broadcast starting --- packages/stream_video/lib/src/call/call.dart | 103 +++++++++++++----- .../lib/src/call/call_connect_options.dart | 13 ++- .../lib/src/webrtc/rtc_manager.dart | 100 ++++++++++------- .../rtc_media_device_notifier.dart | 7 +- .../Classes/StreamVideoFlutterPlugin.swift | 20 ++-- .../call_background/background_service.dart | 13 ++- 6 files changed, 171 insertions(+), 85 deletions(-) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index c229ccb8a..c35288678 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -469,20 +469,21 @@ class Call { } StreamSubscription _onNativeWebRtcEvent() { - return RtcMediaDeviceNotifier.instance - .nativeWebRtcEventsStream() - .whereType() - .listen((event) { - _logger.d( - () => '[_onNativeWebRtcEvent] screenSharingStopped: $event', - ); + return RtcMediaDeviceNotifier.instance.nativeWebRtcEventsStream().listen(( + event, + ) { + _logger.d( + () => '[_onNativeWebRtcEvent] screenSharingStopped: $event', + ); + switch (event) { + case ScreenSharingStoppedEvent _: if (CurrentPlatform.isIos) { // On iOS only one broadcast extension can be active at a time setScreenShareEnabled(enabled: false); } else { final trackId = event.data?['trackId'] as String?; - if (trackId != null) { + if (trackId != null && state.value.localParticipant != null) { final track = getTrack( state.value.localParticipant!.trackIdPrefix, SfuTrackType.screenShare, @@ -493,7 +494,24 @@ class Call { } } } - }); + break; + case ScreenSharingStartedEvent _: + _stateManager.participantSetScreenShareEnabled( + enabled: true, + ); + + _connectOptions = _connectOptions.copyWith( + screenShare: TrackOption.enabled( + constraints: const ScreenShareConstraints( + useiOSBroadcastExtension: true, + ), + ), + ); + break; + default: + return; + } + }); } Future _onCoordinatorEvent(StreamCallEvent event) async { @@ -1943,15 +1961,21 @@ class Call { if (cameraOption is TrackProvided) { return _setLocalTrack(cameraOption.track); } else if (cameraOption is TrackEnabled) { + final constraints = cameraOption.constraints is CameraConstraints + ? cameraOption.constraints as CameraConstraints? + : null; + return setCameraEnabled( enabled: true, - constraints: CameraConstraints( - facingMode: facingMode, - deviceId: deviceId, - params: - targetResolution?.toVideoParams() ?? - RtcVideoParametersPresets.h720_16x9, - ), + constraints: + constraints ?? + CameraConstraints( + facingMode: facingMode, + deviceId: deviceId, + params: + targetResolution?.toVideoParams() ?? + RtcVideoParametersPresets.h720_16x9, + ), ); } @@ -1962,7 +1986,10 @@ class Call { if (microphoneOption is TrackProvided) { await _setLocalTrack(microphoneOption.track); } else if (microphoneOption is TrackEnabled) { - await setMicrophoneEnabled(enabled: true); + final constraints = microphoneOption.constraints is AudioConstraints + ? microphoneOption.constraints as AudioConstraints? + : null; + await setMicrophoneEnabled(enabled: true, constraints: constraints); } } @@ -1973,15 +2000,22 @@ class Call { if (screenShareOption is TrackProvided) { await _setLocalTrack(screenShareOption.track); } else if (screenShareOption is TrackEnabled) { + final constraints = + screenShareOption.constraints is ScreenShareConstraints + ? screenShareOption.constraints as ScreenShareConstraints? + : null; + await setScreenShareEnabled( enabled: true, - constraints: ScreenShareConstraints( - params: - targetResolution?.toVideoParams( - defaultBitrate: RtcVideoParametersPresets.k1080pBitrate, - ) ?? - RtcVideoParametersPresets.h1080_16x9, - ), + constraints: + constraints ?? + ScreenShareConstraints( + params: + targetResolution?.toVideoParams( + defaultBitrate: RtcVideoParametersPresets.k1080pBitrate, + ) ?? + RtcVideoParametersPresets.h1080_16x9, + ), ); } } @@ -2792,7 +2826,9 @@ class Call { ); _connectOptions = _connectOptions.copyWith( - camera: enabled ? TrackOption.enabled() : TrackOption.disabled(), + camera: enabled + ? TrackOption.enabled(constraints: constraints) + : TrackOption.disabled(), cameraFacingMode: constraints?.facingMode ?? FacingMode.user, ); } @@ -2854,7 +2890,9 @@ class Call { ); _connectOptions = _connectOptions.copyWith( - microphone: enabled ? TrackOption.enabled() : TrackOption.disabled(), + microphone: enabled + ? TrackOption.enabled(constraints: constraints) + : TrackOption.disabled(), ); } @@ -2891,16 +2929,27 @@ class Call { ) ?? Result.error('Call session is null, cannot start screen share'); + // In case of iOS Broadcast Extension, we don't update the state here + // We listen to the ScreenShareStarted event instead + if (CurrentPlatform.isIos && + constraints is ScreenShareConstraints && + constraints.useiOSBroadcastExtension) { + return result.map((_) => none); + } + if (result.isSuccess) { _stateManager.participantSetScreenShareEnabled( enabled: enabled, ); _connectOptions = _connectOptions.copyWith( - screenShare: enabled ? TrackOption.enabled() : TrackOption.disabled(), + screenShare: enabled + ? TrackOption.enabled(constraints: updatedConstraints) + : TrackOption.disabled(), ); if (enabled) { + // [web only] Automatically stop screen share when the track ends result.getDataOrNull()?.mediaTrack.onEnded = () { setScreenShareEnabled(enabled: false); }; diff --git a/packages/stream_video/lib/src/call/call_connect_options.dart b/packages/stream_video/lib/src/call/call_connect_options.dart index e9491173e..f9d227d6f 100644 --- a/packages/stream_video/lib/src/call/call_connect_options.dart +++ b/packages/stream_video/lib/src/call/call_connect_options.dart @@ -111,8 +111,8 @@ abstract class TrackOption with EquatableMixin { factory TrackOption.fromSetting({required bool enabled}) => enabled ? TrackOption.enabled() : TrackOption.disabled(); - factory TrackOption.enabled() { - return TrackEnabled._instance; + factory TrackOption.enabled({MediaConstraints? constraints}) { + return TrackEnabled._(constraints: constraints); } factory TrackOption.disabled() { @@ -140,12 +140,15 @@ class TrackDisabled extends TrackOption { } class TrackEnabled extends TrackOption { - const TrackEnabled._(); + const TrackEnabled._({this.constraints}); - static const TrackEnabled _instance = TrackEnabled._(); + final MediaConstraints? constraints; @override - String toString() => 'enabled'; + List get props => [constraints]; + + @override + String toString() => 'enabled($constraints)'; } class TrackProvided extends TrackOption { diff --git a/packages/stream_video/lib/src/webrtc/rtc_manager.dart b/packages/stream_video/lib/src/webrtc/rtc_manager.dart index a29a27b25..32bdc411a 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_manager.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_manager.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:async/async.dart' show CancelableOperation; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:rxdart/transformers.dart'; @@ -18,6 +19,7 @@ import '../sfu/data/models/sfu_publish_options.dart'; import '../sfu/data/models/sfu_track_type.dart'; import '../sfu/data/models/sfu_video_sender.dart'; import '../utils/extensions.dart'; +import '../utils/future.dart'; import '../utils/none.dart'; import '../utils/result.dart'; import 'codecs_helper.dart' as codecs; @@ -104,6 +106,11 @@ class RtcManager extends Disposable { OnLocalTrackPublished? onLocalTrackPublished; OnRemoteTrackReceived? onRemoteTrackReceived; + CancelableOperation? + _screenSharingStartedOperation; + StreamSubscription? + _screenSharingStartedSubscription; + /// Returns a generic sdp. static Future getGenericSdp( rtc.TransceiverDirection direction, @@ -429,6 +436,8 @@ class RtcManager extends Disposable { @override Future dispose() async { _logger.d(() => '[dispose] no args'); + await _screenSharingStartedOperation?.cancel(); + _screenSharingStartedOperation = null; for (final trackSid in [...tracks.keys]) { await unpublishTrack(trackId: trackSid); } @@ -1168,12 +1177,38 @@ extension RtcManagerTrackHelper on RtcManager { Future> setScreenShareEnabled({ bool enabled = true, ScreenShareConstraints? constraints, - }) { - return _setTrackEnabled( - trackType: SfuTrackType.screenShare, - enabled: enabled, - constraints: constraints, - ); + }) async { + if (CurrentPlatform.isIos && + enabled && + constraints is ScreenShareConstraints && + constraints.useiOSBroadcastExtension) { + final screenShareTrackResult = await createScreenShareTrack( + constraints: constraints, + ); + + if (screenShareTrackResult.isFailure) { + _logger.e( + () => + '[ScreenSharingStartedEvent] failed to create screen share track: ${screenShareTrackResult.getErrorOrNull()}', + ); + return screenShareTrackResult; + } + + _startListeningToScreenShareStarted( + screenShareTrackResult.getDataOrNull()!, + ); + + return Future.value( + Result.success(screenShareTrackResult.getDataOrNull()!), + ); + } else { + await _screenSharingStartedSubscription?.cancel(); + return _setTrackEnabled( + trackType: SfuTrackType.screenShare, + enabled: enabled, + constraints: constraints, + ); + } } Future> _setTrackEnabled({ @@ -1186,6 +1221,8 @@ extension RtcManagerTrackHelper on RtcManager { // Track found, mute/unmute it. if (track != null) { if (enabled && track is RtcLocalScreenShareTrack) { + // Always create a new screen share track when enabling screen sharing. + // This ensures correct handling of constraints on iOS and supports selecting different screens on web and desktop platforms. return _createAndPublishTrack( trackType: trackType, constraints: constraints, @@ -1295,28 +1332,6 @@ extension RtcManagerTrackHelper on RtcManager { as ScreenShareConstraints, ); - // On iOS, wait for the native WebRTC event indicating that - // screen sharing has started when using the Broadcast Extension. - if (CurrentPlatform.isIos && - screenShareTrackResult.isSuccess && - constraints is ScreenShareConstraints && - constraints.useiOSBroadcastExtension) { - return awaitNativeWebRtcEvent() - .then( - (value) => publishVideoTrack( - track: screenShareTrackResult.getDataOrNull()!, - ), - ) - .timeout( - const Duration(seconds: 30), - onTimeout: () { - return Result.error( - 'Timeout waiting for ScreenSharingStartedEvent', - ); - }, - ); - } - return screenShareTrackResult.fold( success: (it) => publishVideoTrack(track: it.data), failure: (it) => it, @@ -1327,19 +1342,28 @@ extension RtcManagerTrackHelper on RtcManager { return Result.error('Unsupported trackType $trackType'); } - Future awaitNativeWebRtcEvent() { - final completer = Completer(); - - StreamSubscription? rtcEventsSubscription; - rtcEventsSubscription = RtcMediaDeviceNotifier.instance + Future awaitNativeWebRtcEvent() { + return RtcMediaDeviceNotifier.instance .nativeWebRtcEventsStream() .whereType() - .listen((event) { - completer.complete(); - rtcEventsSubscription?.cancel(); - }); + .take(1) + .first; + } + + void _startListeningToScreenShareStarted( + RtcLocalTrack track, + ) { + _screenSharingStartedSubscription?.cancel(); + _screenSharingStartedSubscription = RtcMediaDeviceNotifier.instance + .nativeWebRtcEventsStream() + .whereType() + .listen((event) async { + _logger.i(() => '[ScreenSharingStartedEvent] received: $event'); - return completer.future; + await publishVideoTrack( + track: track, + ); + }); } Future> setAppleAudioConfiguration({ diff --git a/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart b/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart index fe1930a24..12efe6b61 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_media_device/rtc_media_device_notifier.dart @@ -86,8 +86,13 @@ class RtcMediaDeviceNotifier { Stream nativeWebRtcEventsStream() { return rtc.eventStream .map((data) { + if (data.isEmpty) return null; + final event = data.keys.first; - final Map values = data.values.first; + final values = data.values.first; + + if (values is! Map?) return null; + switch (event) { case 'screenSharingStopped': return ScreenSharingStoppedEvent(data: values); diff --git a/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift b/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift index 25450f772..c3ce816d1 100644 --- a/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift +++ b/packages/stream_video_flutter/ios/Classes/StreamVideoFlutterPlugin.swift @@ -20,15 +20,17 @@ public class StreamVideoFlutterPlugin: NSObject, FlutterPlugin { ScreenShareManager.shared.observeNotification(named: broadcastStartedNotification) ScreenShareManager.shared.observeNotification(named: broadcastStoppedNotification) ScreenShareManager.shared.onNotification = { name in - switch name { - case broadcastStartedNotification: - FlutterWebRTCPlugin.sharedSingleton()?.postEvent( - withName: "screenSharingStarted", data: nil) - case broadcastStoppedNotification: - FlutterWebRTCPlugin.sharedSingleton()?.postEvent( - withName: "screenSharingStopped", data: nil) - default: - break + DispatchQueue.main.async { + switch name { + case broadcastStartedNotification: + FlutterWebRTCPlugin.sharedSingleton()?.postEvent( + withName: "screenSharingStarted", data: nil) + case broadcastStoppedNotification: + FlutterWebRTCPlugin.sharedSingleton()?.postEvent( + withName: "screenSharingStopped", data: nil) + default: + break + } } } } diff --git a/packages/stream_video_flutter/lib/src/call_background/background_service.dart b/packages/stream_video_flutter/lib/src/call_background/background_service.dart index 0d0b9eaec..1cb8f12de 100644 --- a/packages/stream_video_flutter/lib/src/call_background/background_service.dart +++ b/packages/stream_video_flutter/lib/src/call_background/background_service.dart @@ -182,17 +182,20 @@ class StreamBackgroundService { .listen((event) async { final trackId = event.data?['trackId'] as String?; if (trackId != null) { - final call = streamVideo.activeCalls.singleWhereOrNull( - (call) => - call + final call = streamVideo.activeCalls.singleWhereOrNull((call) { + if (call.state.value.localParticipant == null) { + return false; + } + + return call .getTrack( call.state.value.localParticipant!.trackIdPrefix, SfuTrackType.screenShare, ) ?.mediaTrack .id == - trackId, - ); + trackId; + }); if (call != null) { await _instance.stopScreenSharingNotificationService( From 6fb1db687d894ce38f7b62094d1f5e9a2ce6ff2c Mon Sep 17 00:00:00 2001 From: Brazol Date: Wed, 29 Oct 2025 09:52:06 +0100 Subject: [PATCH 7/7] fix --- packages/stream_video/lib/src/webrtc/rtc_manager.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/stream_video/lib/src/webrtc/rtc_manager.dart b/packages/stream_video/lib/src/webrtc/rtc_manager.dart index 32bdc411a..7d81ce5bf 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_manager.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_manager.dart @@ -106,8 +106,6 @@ class RtcManager extends Disposable { OnLocalTrackPublished? onLocalTrackPublished; OnRemoteTrackReceived? onRemoteTrackReceived; - CancelableOperation? - _screenSharingStartedOperation; StreamSubscription? _screenSharingStartedSubscription; @@ -436,8 +434,8 @@ class RtcManager extends Disposable { @override Future dispose() async { _logger.d(() => '[dispose] no args'); - await _screenSharingStartedOperation?.cancel(); - _screenSharingStartedOperation = null; + await _screenSharingStartedSubscription?.cancel(); + _screenSharingStartedSubscription = null; for (final trackSid in [...tracks.keys]) { await unpublishTrack(trackId: trackSid); }