From c354d76e8775b858cfeda7c195ba4759b37d9309 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Mon, 14 Jul 2025 17:37:26 +0300 Subject: [PATCH 1/6] [Enhancement]Incoming video pause --- .../Sources/Components/AppEnvironment.swift | 14 ++ .../CallingView/SimpleCallingView.swift | 12 ++ DemoApp/Sources/Views/Login/DebugMenu.swift | 16 ++ .../Views/Reactions/ReactionOverlayView.swift | 3 +- .../Reactions/ReactionsViewModifier.swift | 3 +- Sources/StreamVideo/Call.swift | 10 + .../Controllers/CallController.swift | 4 + .../StreamVideo/Models/CallParticipant.swift | 113 ++++++++-- .../StreamVideo/Models/ClientCapability.swift | 42 ++++ ...u_Models_Participant+CallParticipant.swift | 3 +- .../v2/PeerConnection/Models/TrackType.swift | 27 ++- ...TCPeerConnectionCoordinatorProviding.swift | 17 +- .../RTCPeerConnectionCoordinator.swift | 24 ++- .../WebRTC/v2/SFU/SFUEventAdapter.swift | 53 ++++- .../Stages/WebRTCCoordinator+Joined.swift | 3 +- .../Stages/WebRTCCoordinator+Joining.swift | 114 ++++++----- .../WebRTCUpdateSubscriptionsAdapter.swift | 11 +- .../WebRTC/v2/WebRTCCoordinator.swift | 4 + .../WebRTC/v2/WebRTCJoinRequestFactory.swift | 5 +- .../WebRTC/v2/WebRTCStateAdapter.swift | 12 +- .../CallView/VideoParticipantsView.swift | 11 +- StreamVideo.xcodeproj/project.pbxproj | 8 +- StreamVideoTests/Call/Call_Tests.swift | 18 ++ .../Controllers/CallController_Tests.swift | 9 + .../Mock/CallParticipant_Mock.swift | 6 +- .../Mock/MockCallController.swift | 10 + .../MockRTCPeerConnectionCoordinator.swift | 3 +- ...kRTCPeerConnectionCoordinatorFactory.swift | 6 +- ...ockRTCPeerConnectionCoordinatorStack.swift | 6 +- .../Models/CallParticipants_Tests.swift | 34 ++- .../WebRTC/SFU/SFUEventAdapter_Tests.swift | 193 ++++++++++++++++++ .../RTCPeerConnectionCoordinator_Tests.swift | 3 +- ...inatorStateMachine_JoiningStageTests.swift | 5 + ...bRTCUpdateSubscriptionsAdapter_Tests.swift | 3 +- .../WebRTC/v2/WebRTCCoorindator_Tests.swift | 8 + .../v2/WebRTCJoinRequestFactory_Tests.swift | 2 +- 36 files changed, 704 insertions(+), 111 deletions(-) create mode 100644 Sources/StreamVideo/Models/ClientCapability.swift diff --git a/DemoApp/Sources/Components/AppEnvironment.swift b/DemoApp/Sources/Components/AppEnvironment.swift index fd8889ae4..b09f84c71 100644 --- a/DemoApp/Sources/Components/AppEnvironment.swift +++ b/DemoApp/Sources/Components/AppEnvironment.swift @@ -620,6 +620,20 @@ extension AppEnvironment { }() } +extension AppEnvironment { + + static var clientCapabilities: Set = [] +} + +extension ClientCapability: Debuggable { + var title: String { + switch self { + case .subscriberVideoPause: + "Subscriber video pause" + } + } +} + extension String: Debuggable { var title: String { self diff --git a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift index e181831fa..018faf7f1 100644 --- a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift +++ b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift @@ -197,6 +197,15 @@ struct SimpleCallingView: View { try policies.forEach { try call.addProximityPolicy($0) } } + private func setClientCapabilities(for callId: String) async { + let clientCapabilities = AppEnvironment.clientCapabilities + guard !clientCapabilities.isEmpty else { + return + } + let call = streamVideo.call(callType: callType, callId: callId) + await call.updateClientCapabilities(clientCapabilities) + } + private func parseURLIfRequired(_ text: String) { let adapter = DeeplinkAdapter() guard @@ -234,6 +243,7 @@ struct SimpleCallingView: View { await setPreferredVideoCodec(for: text) try? await setAudioSessionPolicyOverride(for: text) try? setProximityPolicies(for: text) + await setClientCapabilities(for: text) viewModel.enterLobby( callType: callType, callId: text, @@ -243,11 +253,13 @@ struct SimpleCallingView: View { await setPreferredVideoCodec(for: text) try? await setAudioSessionPolicyOverride(for: text) try? setProximityPolicies(for: text) + await setClientCapabilities(for: text) viewModel.joinCall(callType: callType, callId: text) case let .start(callId): await setPreferredVideoCodec(for: callId) try? await setAudioSessionPolicyOverride(for: callId) try? setProximityPolicies(for: callId) + await setClientCapabilities(for: callId) viewModel.startCall( callType: callType, callId: callId, diff --git a/DemoApp/Sources/Views/Login/DebugMenu.swift b/DemoApp/Sources/Views/Login/DebugMenu.swift index 57143f946..6b186e67a 100644 --- a/DemoApp/Sources/Views/Login/DebugMenu.swift +++ b/DemoApp/Sources/Views/Login/DebugMenu.swift @@ -129,6 +129,10 @@ struct DebugMenu: View { didSet { AppEnvironment.proximityPolicies = proximityPolicies } } + @State private var clientCapabilities = AppEnvironment.clientCapabilities { + didSet { AppEnvironment.clientCapabilities = clientCapabilities } + } + var body: some View { Menu { makeMenu( @@ -188,6 +192,18 @@ struct DebugMenu: View { label: "ClosedCaptions Integration" ) { self.closedCaptionsIntegration = $0 } + makeMultipleSelectMenu( + for: ClientCapability.allCases, + currentValues: clientCapabilities, + label: "Client Capabilities" + ) { item, isSelected in + if isSelected { + clientCapabilities = clientCapabilities.filter { item != $0 } + } else { + clientCapabilities.insert(item) + } + } + makeMenu( for: [.default, .ownCapabilities], currentValue: audioSessionPolicy, diff --git a/DemoApp/Sources/Views/Reactions/ReactionOverlayView.swift b/DemoApp/Sources/Views/Reactions/ReactionOverlayView.swift index 6f000399f..15df36e9f 100644 --- a/DemoApp/Sources/Views/Reactions/ReactionOverlayView.swift +++ b/DemoApp/Sources/Views/Reactions/ReactionOverlayView.swift @@ -57,7 +57,8 @@ struct ReactionOverlayView_Previews: PreviewProvider { joinedAt: Date(), audioLevel: 0, audioLevels: [], - pin: nil + pin: nil, + pausedTracks: [] ) ) } diff --git a/DemoApp/Sources/Views/Reactions/ReactionsViewModifier.swift b/DemoApp/Sources/Views/Reactions/ReactionsViewModifier.swift index 6dc8c3fda..81b5da025 100644 --- a/DemoApp/Sources/Views/Reactions/ReactionsViewModifier.swift +++ b/DemoApp/Sources/Views/Reactions/ReactionsViewModifier.swift @@ -55,7 +55,8 @@ struct ReactionsViewModifier_Previews: PreviewProvider { joinedAt: Date(), audioLevel: 0, audioLevels: [], - pin: nil + pin: nil, + pausedTracks: [] ) ) ) diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index 5768bdc2e..8f2bc2eff 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -1389,6 +1389,16 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { proximity.remove(policy) } + // MARK: - ClientCapabilities + + /// Updates the set of client capabilities for the call. + /// + /// - Parameter clientCapabilities: A set of client capabilities that influence + /// subscription logic (e.g., support for paused tracks). + public func updateClientCapabilities(_ clientCapabilities: Set) async { + await callController.updateClientCapabilities(clientCapabilities) + } + // MARK: - Internal internal func update(reconnectionStatus: ReconnectionStatus) { diff --git a/Sources/StreamVideo/Controllers/CallController.swift b/Sources/StreamVideo/Controllers/CallController.swift index 587fb9c0e..7c77ec538 100644 --- a/Sources/StreamVideo/Controllers/CallController.swift +++ b/Sources/StreamVideo/Controllers/CallController.swift @@ -505,6 +505,10 @@ class CallController: @unchecked Sendable { try webRTCCoordinator.callKitActivated(audioSession) } + func updateClientCapabilities(_ capabilities: Set) async { + await webRTCCoordinator.updateClientCapabilities(capabilities) + } + // MARK: - private private func handleParticipantsUpdated() { diff --git a/Sources/StreamVideo/Models/CallParticipant.swift b/Sources/StreamVideo/Models/CallParticipant.swift index c851f0415..9f65d8a4a 100644 --- a/Sources/StreamVideo/Models/CallParticipant.swift +++ b/Sources/StreamVideo/Models/CallParticipant.swift @@ -43,8 +43,18 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { public var audioLevel: Float /// List of the last 10 audio levels. public var audioLevels: [Float] + /// Pinning metadata used to keep this participant visible across layouts. + /// + /// If set, the participant is considered pinned either locally or remotely. + /// SDK integrators can use this to reflect UI state (e.g., always visible). public var pin: PinInfo? + /// The set of media track types currently paused for this participant. + /// + /// This is used to control bandwidth or presentation. SDK integrators can + /// rely on it to know when a participant's track has been paused remotely. + public var pausedTracks: Set + /// The user's id. This is not necessarily unique, since a user can join from multiple devices. public var userId: String { user.id @@ -81,7 +91,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: Date, audioLevel: Float, audioLevels: [Float], - pin: PinInfo? + pin: PinInfo?, + pausedTracks: Set ) { user = User( id: userId, @@ -106,6 +117,7 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { self.audioLevel = audioLevel self.audioLevels = audioLevels self.pin = pin + self.pausedTracks = pausedTracks } public static func == (lhs: CallParticipant, rhs: CallParticipant) -> Bool { @@ -127,7 +139,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { lhs.audioLevels == rhs.audioLevels && lhs.pin == rhs.pin && lhs.track === rhs.track && - lhs.screenshareTrack === rhs.screenshareTrack + lhs.screenshareTrack === rhs.screenshareTrack && + lhs.pausedTracks == rhs.pausedTracks } public var isPinned: Bool { @@ -141,7 +154,7 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { /// Determines whether the track of the participant should be displayed. public var shouldDisplayTrack: Bool { - hasVideo && showTrack && track != nil + hasVideo && showTrack && track != nil && pausedTracks.contains(.video) == false } public func withUpdated(trackSize: CGSize) -> CallParticipant { @@ -166,7 +179,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -192,7 +206,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -218,7 +233,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -244,7 +260,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -270,7 +287,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -296,7 +314,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -322,7 +341,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -348,7 +368,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -383,7 +404,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: levels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -409,7 +431,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -435,7 +458,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } @@ -461,7 +485,66 @@ public struct CallParticipant: Identifiable, Sendable, Hashable { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks + ) + } + + public func withPausedTrack(_ trackType: TrackType) -> CallParticipant { + var updatedPausedTracks = pausedTracks + updatedPausedTracks.insert(trackType) + return CallParticipant( + id: id, + userId: userId, + roles: roles, + name: name, + profileImageURL: profileImageURL, + trackLookupPrefix: trackLookupPrefix, + hasVideo: hasVideo, + hasAudio: hasAudio, + isScreenSharing: isScreensharing, + showTrack: showTrack, + track: track, + trackSize: trackSize, + screenshareTrack: screenshareTrack, + isSpeaking: isSpeaking, + isDominantSpeaker: isDominantSpeaker, + sessionId: sessionId, + connectionQuality: connectionQuality, + joinedAt: joinedAt, + audioLevel: audioLevel, + audioLevels: audioLevels, + pin: pin, + pausedTracks: updatedPausedTracks + ) + } + + public func withUnpausedTrack(_ trackType: TrackType) -> CallParticipant { + var updatedPausedTracks = pausedTracks + updatedPausedTracks.remove(trackType) + return CallParticipant( + id: id, + userId: userId, + roles: roles, + name: name, + profileImageURL: profileImageURL, + trackLookupPrefix: trackLookupPrefix, + hasVideo: hasVideo, + hasAudio: hasAudio, + isScreenSharing: isScreensharing, + showTrack: showTrack, + track: track, + trackSize: trackSize, + screenshareTrack: screenshareTrack, + isSpeaking: isSpeaking, + isDominantSpeaker: isDominantSpeaker, + sessionId: sessionId, + connectionQuality: connectionQuality, + joinedAt: joinedAt, + audioLevel: audioLevel, + audioLevels: audioLevels, + pin: pin, + pausedTracks: updatedPausedTracks ) } } diff --git a/Sources/StreamVideo/Models/ClientCapability.swift b/Sources/StreamVideo/Models/ClientCapability.swift new file mode 100644 index 000000000..a6c5ac8ad --- /dev/null +++ b/Sources/StreamVideo/Models/ClientCapability.swift @@ -0,0 +1,42 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// Describes the capabilities that the client supports. +/// +/// This type is used by SDK integrators to declare specific functionality +/// their client is capable of handling. These capabilities may affect how +/// the backend or SFU behaves during a call. +public enum ClientCapability: Hashable, Sendable, CaseIterable { + + /// Indicates that the client supports pausing and resuming video for + /// individual subscribers. When enabled, the backend can pause a + /// participant’s video for a specific subscriber to save bandwidth. + case subscriberVideoPause + + /// Initializes a `ClientCapability` from the backend protobuf value. + /// + /// - Parameter source: The raw protobuf capability value. + /// - Returns: A `ClientCapability` or `nil` if the value is unrecognized + /// or unspecified. + init?(_ source: Stream_Video_Sfu_Models_ClientCapability) { + switch source { + case .subscriberVideoPause: + self = .subscriberVideoPause + case .unspecified: + return nil + case .UNRECOGNIZED: + return nil + } + } + + /// Returns the backend-compatible representation of this capability. + var rawValue: Stream_Video_Sfu_Models_ClientCapability { + switch self { + case .subscriberVideoPause: + return .subscriberVideoPause + } + } +} diff --git a/Sources/StreamVideo/Models/Extensions/Stream_Video_Sfu_Models_Participant+CallParticipant.swift b/Sources/StreamVideo/Models/Extensions/Stream_Video_Sfu_Models_Participant+CallParticipant.swift index 1f0631364..f3bdd37cc 100644 --- a/Sources/StreamVideo/Models/Extensions/Stream_Video_Sfu_Models_Participant+CallParticipant.swift +++ b/Sources/StreamVideo/Models/Extensions/Stream_Video_Sfu_Models_Participant+CallParticipant.swift @@ -27,7 +27,8 @@ extension Stream_Video_Sfu_Models_Participant { joinedAt: joinedAt.date, audioLevel: audioLevel, audioLevels: [audioLevel], - pin: pin + pin: pin, + pausedTracks: [] ) } } diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Models/TrackType.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Models/TrackType.swift index 092283334..301d1b19f 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Models/TrackType.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Models/TrackType.swift @@ -5,32 +5,45 @@ import Foundation /// Represents the type of media track in a WebRTC communication. -struct TrackType: RawRepresentable, Codable, Hashable, ExpressibleByStringLiteral { +public struct TrackType: RawRepresentable, Codable, Hashable, ExpressibleByStringLiteral, Sendable { /// The raw string value of the track type. - let rawValue: String + public let rawValue: String /// Initializes a TrackType with the given raw value. /// /// - Parameter rawValue: The string representation of the track type. - init(rawValue: String) { + public init(rawValue: String) { self.rawValue = rawValue } /// Initializes a TrackType with a string literal. /// /// - Parameter value: The string literal representation of the track type. - init(stringLiteral value: String) { + public init(stringLiteral value: String) { self.init(rawValue: value) } + + init(_ source: Stream_Video_Sfu_Models_TrackType) { + switch source { + case .audio: + self = .audio + case .video: + self = .video + case .screenShare: + self = .screenshare + default: + self = .unknown + } + } } extension TrackType { /// Represents an audio track. - static let audio: Self = "audio" + public static let audio: Self = "audio" /// Represents a video track. - static let video: Self = "video" + public static let video: Self = "video" /// Represents a screen sharing track. - static let screenshare: Self = "screenshare" + public static let screenshare: Self = "screenshare" /// Represents an unknown track type. static let unknown: Self = "unknown" } diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/RTCPeerConnectionCoordinatorProviding.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/RTCPeerConnectionCoordinatorProviding.swift index 967feac59..4ce7fc55d 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/RTCPeerConnectionCoordinatorProviding.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Protocols/RTCPeerConnectionCoordinatorProviding.swift @@ -25,6 +25,10 @@ protocol RTCPeerConnectionCoordinatorProviding: Sendable { /// - sfuAdapter: The adapter for interacting with the Selective Forwarding Unit. /// - videoCaptureSessionProvider: Provider for video capturing functionality. /// - screenShareSessionProvider: Provider for screen sharing functionality. + /// - clientCapabilities: A set of client capabilities that affect how the + /// coordinator behaves (e.g., enabling paused tracks support). + /// + /// This parameter affects features such as support for paused tracks. /// - Returns: An initialized `RTCPeerConnectionCoordinator` instance. func buildCoordinator( sessionId: String, @@ -38,7 +42,8 @@ protocol RTCPeerConnectionCoordinatorProviding: Sendable { publishOptions: PublishOptions, sfuAdapter: SFUAdapter, videoCaptureSessionProvider: VideoCaptureSessionProvider, - screenShareSessionProvider: ScreenShareSessionProvider + screenShareSessionProvider: ScreenShareSessionProvider, + clientCapabilities: Set ) -> RTCPeerConnectionCoordinator } @@ -62,6 +67,10 @@ final class StreamRTCPeerConnectionCoordinatorFactory: RTCPeerConnectionCoordina /// - sfuAdapter: The adapter for interacting with the Selective Forwarding Unit. /// - videoCaptureSessionProvider: Provider for video capturing functionality. /// - screenShareSessionProvider: Provider for screen sharing functionality. + /// - clientCapabilities: A set of client capabilities that affect how the + /// coordinator behaves (e.g., enabling paused tracks support). + /// + /// This parameter affects features such as support for paused tracks. /// - Returns: A newly created `RTCPeerConnectionCoordinator` instance. func buildCoordinator( sessionId: String, @@ -75,7 +84,8 @@ final class StreamRTCPeerConnectionCoordinatorFactory: RTCPeerConnectionCoordina publishOptions: PublishOptions, sfuAdapter: SFUAdapter, videoCaptureSessionProvider: VideoCaptureSessionProvider, - screenShareSessionProvider: ScreenShareSessionProvider + screenShareSessionProvider: ScreenShareSessionProvider, + clientCapabilities: Set ) -> RTCPeerConnectionCoordinator { RTCPeerConnectionCoordinator( sessionId: sessionId, @@ -89,7 +99,8 @@ final class StreamRTCPeerConnectionCoordinatorFactory: RTCPeerConnectionCoordina publishOptions: publishOptions, sfuAdapter: sfuAdapter, videoCaptureSessionProvider: videoCaptureSessionProvider, - screenShareSessionProvider: screenShareSessionProvider + screenShareSessionProvider: screenShareSessionProvider, + clientCapabilities: clientCapabilities ) } } diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift index 8e90e7ba2..40149b5e8 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator.swift @@ -34,6 +34,7 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { private let peerType: PeerConnectionType private let peerConnection: StreamRTCPeerConnectionProtocol private let subsystem: LogSubsystem + private let clientCapabilities: Set private let disposableBag: DisposableBag = .init() private let dispatchQueue = DispatchQueue(label: "io.getstream.peerconnection.serial.offer.queue") @@ -140,7 +141,8 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { publishOptions: PublishOptions, sfuAdapter: SFUAdapter, videoCaptureSessionProvider: VideoCaptureSessionProvider, - screenShareSessionProvider: ScreenShareSessionProvider + screenShareSessionProvider: ScreenShareSessionProvider, + clientCapabilities: Set ) { self.init( sessionId: sessionId, @@ -169,7 +171,8 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { peerConnection: peerConnection, sfuAdapter: sfuAdapter ), - iceConnectionStateAdapter: .init() + iceConnectionStateAdapter: .init(), + clientCapabilities: clientCapabilities ) } @@ -184,7 +187,8 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { sfuAdapter: SFUAdapter, mediaAdapter: MediaAdapter, iceAdapter: ICEAdapter, - iceConnectionStateAdapter: ICEConnectionStateAdapter + iceConnectionStateAdapter: ICEConnectionStateAdapter, + clientCapabilities: Set ) { self.sessionId = sessionId self.peerType = peerType @@ -200,6 +204,7 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { self.mediaAdapter = mediaAdapter self.iceAdapter = iceAdapter self.iceConnectionStateAdapter = iceConnectionStateAdapter + self.clientCapabilities = clientCapabilities // Warm up instances iceConnectionStateAdapter.peerConnectionCoordinator = self @@ -547,8 +552,10 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { setPublisherProcessingQueue.addTaskOperation { [weak self] in guard let self else { return } - let trackInfo = WebRTCJoinRequestFactory() - .buildAnnouncedTracks(self, collectionType: .allAvailable) + let trackInfo = WebRTCJoinRequestFactory( + capabilities: clientCapabilities.map(\.rawValue) + ) + .buildAnnouncedTracks(self, collectionType: .allAvailable) /// We only want to trigger a renegotiation if the user is already publishing any media. /// In any other case we skip. @@ -731,8 +738,11 @@ class RTCPeerConnectionCoordinator: @unchecked Sendable { try await ensureSetUpHasBeenCompleted() - let tracksInfo = WebRTCJoinRequestFactory() - .buildAnnouncedTracks(self, collectionType: .allAvailable) + /// - Note: Capabilities aren't required at this point and thus it's ok to leave it empty. + let tracksInfo = WebRTCJoinRequestFactory( + capabilities: clientCapabilities.map(\.rawValue) + ) + .buildAnnouncedTracks(self, collectionType: .allAvailable) // This is only used for debugging and internal validation. validateTracksAndTransceivers(.video, tracksInfo: tracksInfo) diff --git a/Sources/StreamVideo/WebRTC/v2/SFU/SFUEventAdapter.swift b/Sources/StreamVideo/WebRTC/v2/SFU/SFUEventAdapter.swift index 766c85c1d..99638b66d 100644 --- a/Sources/StreamVideo/WebRTC/v2/SFU/SFUEventAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/SFU/SFUEventAdapter.swift @@ -140,6 +140,14 @@ final class SFUEventAdapter: @unchecked Sendable { } .sinkTask(queue: processingQueue) { [weak self] in await self?.handleChangePublishOptions($0) } .store(in: disposableBag) + + sfuAdapter + .publisher(eventType: Stream_Video_Sfu_Event_InboundStateNotification.self) + .log(.debug, subsystems: .sfu) { + "Processing SFU event of type:\(type(of: $0)) for userID:\($0.inboundVideoStates.map { "(userID:\($0.userID) trackType:\(TrackType($0.trackType)) isPaused:\($0.paused))" }.joined(separator: ", "))" + } + .sinkTask(queue: processingQueue) { [weak self] in await self?.handleInboundVideoState($0) } + .store(in: disposableBag) } // MARK: - Event handlers @@ -388,7 +396,9 @@ final class SFUEventAdapter: @unchecked Sendable { switch event.type { case .audio: - updatedParticipants[sessionID] = participant.withUpdated(audio: false) + updatedParticipants[sessionID] = participant + .withUpdated(audio: false) + .withUnpausedTrack(.audio) log.debug( """ AudioTrack was unpublished @@ -399,7 +409,9 @@ final class SFUEventAdapter: @unchecked Sendable { ) case .video: - updatedParticipants[sessionID] = participant.withUpdated(video: false) + updatedParticipants[sessionID] = participant + .withUpdated(video: false) + .withUnpausedTrack(.video) log.debug( """ VideoTrack was unpublished @@ -413,6 +425,7 @@ final class SFUEventAdapter: @unchecked Sendable { updatedParticipants[sessionID] = participant .withUpdated(screensharing: false) .withUpdated(screensharingTrack: nil) + .withUnpausedTrack(.screenshare) log.debug( """ ScreenShareTrack was unpublished @@ -498,4 +511,40 @@ final class SFUEventAdapter: @unchecked Sendable { await stateAdapter .set(publishOptions: .init(event.publishOptions)) } + + /// Handles an InboundStateNotification event and updates paused track state. + /// + /// - Parameter event: The InboundStateNotification event to handle. + /// + /// This event is sent by the SFU to indicate whether a track (e.g., video, + /// screenshare) for a given participant is paused or resumed. The method + /// updates the corresponding participant's state accordingly. + private func handleInboundVideoState( + _ event: Stream_Video_Sfu_Event_InboundStateNotification + ) async { + await stateAdapter.enqueue { participants in + var updatedParticipants = participants + + for inboundVideoState in event.inboundVideoStates { + let trackType = TrackType(inboundVideoState.trackType) + guard + let participant = updatedParticipants[inboundVideoState.sessionID], + trackType != .unknown + else { + continue + } + + var updatedParticipant = participant + if inboundVideoState.paused { + updatedParticipant = participant.withPausedTrack(trackType) + } else { + updatedParticipant = participant.withUnpausedTrack(trackType) + } + + updatedParticipants[inboundVideoState.sessionID] = updatedParticipant + } + + return updatedParticipants + } + } } diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift index 3063478b4..c3d756e12 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift @@ -572,7 +572,8 @@ extension WebRTCCoordinator.StateMachine.Stage { .$incomingVideoQualitySettings .eraseToAnyPublisher(), sfuAdapter: sfuAdapter, - sessionID: await stateAdapter.sessionID + sessionID: await stateAdapter.sessionID, + clientCapabilities: await stateAdapter.clientCapabilities ) } } diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift index 5264cfdab..4bb7a28e0 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joining.swift @@ -95,25 +95,27 @@ extension WebRTCCoordinator.StateMachine.Stage { try Task.checkCancellation() await sfuAdapter.sendJoinRequest( - WebRTCJoinRequestFactory() - .buildRequest( - with: isFastReconnecting ? .fastReconnect : .default, + WebRTCJoinRequestFactory( + capabilities: coordinator.stateAdapter.clientCapabilities.map(\.rawValue) + ) + .buildRequest( + with: isFastReconnecting ? .fastReconnect : .default, + coordinator: coordinator, + publisherSdp: try await buildSessionDescription( + peerConnectionType: .publisher, + coordinator: coordinator, + sfuAdapter: sfuAdapter, + isFastReconnecting: isFastReconnecting + ), + subscriberSdp: try await buildSessionDescription( + peerConnectionType: .subscriber, coordinator: coordinator, - publisherSdp: try await buildSessionDescription( - peerConnectionType: .publisher, - coordinator: coordinator, - sfuAdapter: sfuAdapter, - isFastReconnecting: isFastReconnecting - ), - subscriberSdp: try await buildSessionDescription( - peerConnectionType: .subscriber, - coordinator: coordinator, - sfuAdapter: sfuAdapter, - isFastReconnecting: isFastReconnecting - ), - reconnectAttempt: context.reconnectAttempts, - publisher: await coordinator.stateAdapter.publisher - ) + sfuAdapter: sfuAdapter, + isFastReconnecting: isFastReconnecting + ), + reconnectAttempt: context.reconnectAttempts, + publisher: await coordinator.stateAdapter.publisher + ) ) try Task.checkCancellation() @@ -158,25 +160,27 @@ extension WebRTCCoordinator.StateMachine.Stage { try Task.checkCancellation() await sfuAdapter.sendJoinRequest( - WebRTCJoinRequestFactory() - .buildRequest( - with: .migration(fromHostname: context.migratingFromSFU), + WebRTCJoinRequestFactory( + capabilities: coordinator.stateAdapter.clientCapabilities.map(\.rawValue) + ) + .buildRequest( + with: .migration(fromHostname: context.migratingFromSFU), + coordinator: coordinator, + publisherSdp: try await buildSessionDescription( + peerConnectionType: .publisher, + coordinator: coordinator, + sfuAdapter: sfuAdapter, + isFastReconnecting: false + ), + subscriberSdp: try await buildSessionDescription( + peerConnectionType: .subscriber, coordinator: coordinator, - publisherSdp: try await buildSessionDescription( - peerConnectionType: .publisher, - coordinator: coordinator, - sfuAdapter: sfuAdapter, - isFastReconnecting: false - ), - subscriberSdp: try await buildSessionDescription( - peerConnectionType: .subscriber, - coordinator: coordinator, - sfuAdapter: sfuAdapter, - isFastReconnecting: false - ), - reconnectAttempt: context.reconnectAttempts, - publisher: context.previousSessionPublisher - ) + sfuAdapter: sfuAdapter, + isFastReconnecting: false + ), + reconnectAttempt: context.reconnectAttempts, + publisher: context.previousSessionPublisher + ) ) context.reconnectAttempts += 1 @@ -216,25 +220,27 @@ extension WebRTCCoordinator.StateMachine.Stage { try Task.checkCancellation() await sfuAdapter.sendJoinRequest( - WebRTCJoinRequestFactory() - .buildRequest( - with: .rejoin(fromSessionID: isRejoiningFromSessionID), + WebRTCJoinRequestFactory( + capabilities: coordinator.stateAdapter.clientCapabilities.map(\.rawValue) + ) + .buildRequest( + with: .rejoin(fromSessionID: isRejoiningFromSessionID), + coordinator: coordinator, + publisherSdp: try await buildSessionDescription( + peerConnectionType: .publisher, coordinator: coordinator, - publisherSdp: try await buildSessionDescription( - peerConnectionType: .publisher, - coordinator: coordinator, - sfuAdapter: sfuAdapter, - isFastReconnecting: false - ), - subscriberSdp: try await buildSessionDescription( - peerConnectionType: .subscriber, - coordinator: coordinator, - sfuAdapter: sfuAdapter, - isFastReconnecting: false - ), - reconnectAttempt: context.reconnectAttempts, - publisher: context.previousSessionPublisher - ) + sfuAdapter: sfuAdapter, + isFastReconnecting: false + ), + subscriberSdp: try await buildSessionDescription( + peerConnectionType: .subscriber, + coordinator: coordinator, + sfuAdapter: sfuAdapter, + isFastReconnecting: false + ), + reconnectAttempt: context.reconnectAttempts, + publisher: context.previousSessionPublisher + ) ) context.reconnectAttempts += 1 diff --git a/Sources/StreamVideo/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter.swift b/Sources/StreamVideo/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter.swift index 738362d03..0b9ff3f47 100644 --- a/Sources/StreamVideo/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter.swift @@ -11,6 +11,9 @@ import Foundation /// computes the necessary subscription details, and instructs the SFUAdapter /// to update subscriptions accordingly. It ensures updates are sent only /// when there are meaningful changes. +/// +/// Subscription behavior is influenced by the provided `clientCapabilities`, +/// allowing customization such as support for paused tracks. final class WebRTCUpdateSubscriptionsAdapter: @unchecked Sendable { /// The session identifier for the current WebRTC session. private let sessionID: String @@ -19,7 +22,7 @@ final class WebRTCUpdateSubscriptionsAdapter: @unchecked Sendable { /// A serial queue used to process update tasks in order. private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1) /// A factory that builds subscription details for WebRTC tracks. - private let tracksFactory: WebRTCJoinRequestFactory = .init() + private let tracksFactory: WebRTCJoinRequestFactory /// A container for cancellable Combine subscriptions. private let disposableBag = DisposableBag() /// The active Combine subscription observing participants and settings. @@ -37,6 +40,8 @@ final class WebRTCUpdateSubscriptionsAdapter: @unchecked Sendable { /// video quality settings. /// - sfuAdapter: The SFU adapter to send updates to. /// - sessionID: The identifier for the session. + /// - clientCapabilities: A set of client capabilities affecting + /// subscription behavior (e.g., paused tracks support). init( participantsPublisher: AnyPublisher< WebRTCStateAdapter.ParticipantsStorage, Never @@ -45,10 +50,12 @@ final class WebRTCUpdateSubscriptionsAdapter: @unchecked Sendable { IncomingVideoQualitySettings, Never >, sfuAdapter: SFUAdapter, - sessionID: String + sessionID: String, + clientCapabilities: Set ) { self.sessionID = sessionID self.sfuAdapter = sfuAdapter + tracksFactory = .init(capabilities: clientCapabilities.map(\.rawValue)) observable = Publishers.CombineLatest( participantsPublisher, incomingVideoQualitySettingsPublisher diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift index 2317e616c..f9a32d69e 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift @@ -436,6 +436,10 @@ final class WebRTCCoordinator: @unchecked Sendable { try stateAdapter.audioSession.callKitActivated(audioSession) } + func updateClientCapabilities(_ capabilities: Set) async { + await stateAdapter.set(clientCapabilities: capabilities) + } + // MARK: - Private /// Creates the state machine for managing WebRTC stages. diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCJoinRequestFactory.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCJoinRequestFactory.swift index ff7b9270e..92db96186 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCJoinRequestFactory.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCJoinRequestFactory.swift @@ -25,6 +25,8 @@ struct WebRTCJoinRequestFactory { } } + var capabilities: [Stream_Video_Sfu_Models_ClientCapability] + /// Builds a join request for WebRTC. /// - Parameters: /// - connectionType: The type of connection for the join request. @@ -59,7 +61,8 @@ struct WebRTCJoinRequestFactory { coordinator: coordinator, publisherSdp: publisherSdp ) - + result.capabilities = capabilities + if let reconnectDetails = await buildReconnectDetails( for: connectionType, coordinator: coordinator, diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift index 7321dc786..6ffb50863 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift @@ -76,6 +76,8 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { @Published private(set) var incomingVideoQualitySettings: IncomingVideoQualitySettings = .none @Published private(set) var isTracingEnabled: Bool = false + private(set) var clientCapabilities: Set = [] + // Various private and internal properties. private(set) var initialCallSettings: CallSettings? @@ -216,6 +218,10 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { statsAdapter?.isTracingEnabled = value } + func set(clientCapabilities value: Set) { + self.clientCapabilities = value + } + // MARK: - Session Management /// Refreshes the session by setting a new session ID. @@ -260,7 +266,8 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { publishOptions: publishOptions, sfuAdapter: sfuAdapter, videoCaptureSessionProvider: videoCaptureSessionProvider, - screenShareSessionProvider: screenShareSessionProvider + screenShareSessionProvider: screenShareSessionProvider, + clientCapabilities: clientCapabilities ) let subscriber = rtcPeerConnectionCoordinatorFactory.buildCoordinator( @@ -278,7 +285,8 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { publishOptions: publishOptions, sfuAdapter: sfuAdapter, videoCaptureSessionProvider: videoCaptureSessionProvider, - screenShareSessionProvider: screenShareSessionProvider + screenShareSessionProvider: screenShareSessionProvider, + clientCapabilities: clientCapabilities ) publisher diff --git a/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift b/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift index fa96ab6e1..ecb49def9 100644 --- a/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift @@ -439,7 +439,16 @@ public struct ParticipantInfoView: View { .font(fonts.caption1) .minimumScaleFactor(0.7) .accessibility(identifier: "participantName") - + + if participant.pausedTracks.contains(.video) { + Image(systemName: "wifi.slash") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: maxHeight) + .foregroundColor(.white) + .padding(.trailing, 4) + } + SoundIndicator(participant: participant) .frame(maxHeight: maxHeight) } diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index c8bf0fd93..23b2a094f 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -363,11 +363,12 @@ 408721F12E12741F006A68CB /* DispatchWorkItem+TimerControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408721F02E12741F006A68CB /* DispatchWorkItem+TimerControl.swift */; }; 408721F72E127551006A68CB /* TimerPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408721F62E127551006A68CB /* TimerPublisher.swift */; }; 408721FA2E127A18006A68CB /* TimerPublisher_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408721F92E127A18006A68CB /* TimerPublisher_Tests.swift */; }; + 408722372E13C91F006A68CB /* AVCaptureDevice.Format+MediaSubType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408722362E13C91F006A68CB /* AVCaptureDevice.Format+MediaSubType.swift */; }; 408722392E13CD9D006A68CB /* DemoMoreThermalStateButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408722382E13CD9D006A68CB /* DemoMoreThermalStateButtonView.swift */; }; 4087223A2E13CD9D006A68CB /* DemoMoreThermalStateButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408722382E13CD9D006A68CB /* DemoMoreThermalStateButtonView.swift */; }; - 408722372E13C91F006A68CB /* AVCaptureDevice.Format+MediaSubType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408722362E13C91F006A68CB /* AVCaptureDevice.Format+MediaSubType.swift */; }; 4089378B2C062B17000EEB69 /* StreamUUIDFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4089378A2C062B17000EEB69 /* StreamUUIDFactory.swift */; }; 408937912C134305000EEB69 /* View+AlertWithTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408937902C134305000EEB69 /* View+AlertWithTextField.swift */; }; + 40895E602E25538E00D3049D /* ClientCapability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40895E5F2E25538E00D3049D /* ClientCapability.swift */; }; 408CE0F72BD95EB60052EC3A /* VideoConfig+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F62BD95EB60052EC3A /* VideoConfig+Dummy.swift */; }; 408CE0F82BD95F170052EC3A /* VideoConfig+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F62BD95EB60052EC3A /* VideoConfig+Dummy.swift */; }; 408CE0F92BD95F1B0052EC3A /* VideoConfig+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F62BD95EB60052EC3A /* VideoConfig+Dummy.swift */; }; @@ -1937,10 +1938,11 @@ 408721F02E12741F006A68CB /* DispatchWorkItem+TimerControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchWorkItem+TimerControl.swift"; sourceTree = ""; }; 408721F62E127551006A68CB /* TimerPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerPublisher.swift; sourceTree = ""; }; 408721F92E127A18006A68CB /* TimerPublisher_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerPublisher_Tests.swift; sourceTree = ""; }; - 408722382E13CD9D006A68CB /* DemoMoreThermalStateButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoMoreThermalStateButtonView.swift; sourceTree = ""; }; 408722362E13C91F006A68CB /* AVCaptureDevice.Format+MediaSubType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVCaptureDevice.Format+MediaSubType.swift"; sourceTree = ""; }; + 408722382E13CD9D006A68CB /* DemoMoreThermalStateButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoMoreThermalStateButtonView.swift; sourceTree = ""; }; 4089378A2C062B17000EEB69 /* StreamUUIDFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamUUIDFactory.swift; sourceTree = ""; }; 408937902C134305000EEB69 /* View+AlertWithTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AlertWithTextField.swift"; sourceTree = ""; }; + 40895E5F2E25538E00D3049D /* ClientCapability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCapability.swift; sourceTree = ""; }; 408CE0F62BD95EB60052EC3A /* VideoConfig+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoConfig+Dummy.swift"; sourceTree = ""; }; 408CF9C22CAE886500F56833 /* WebRTCIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCIntegrationTests.swift; sourceTree = ""; }; 408CF9C52CAEC24A00F56833 /* ScreenPropertiesAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenPropertiesAdapter.swift; sourceTree = ""; }; @@ -5957,6 +5959,7 @@ 8454A3182AAB374B00A012C6 /* CallStatsReport.swift */, 40034C1F2CFDABE600A318B1 /* PublishOptions.swift */, 4039F0CB2D0241120078159E /* AudioCodec.swift */, + 40895E5F2E25538E00D3049D /* ClientCapability.swift */, ); path = Models; sourceTree = ""; @@ -7785,6 +7788,7 @@ 84DC389529ADFCFD00946713 /* QueryMembersRequest.swift in Sources */, 84DC38AB29ADFCFD00946713 /* RecordSettingsRequest.swift in Sources */, 84EA5D3F28C09AAC004D3531 /* CallController.swift in Sources */, + 40895E602E25538E00D3049D /* ClientCapability.swift in Sources */, 84DC38CA29ADFCFD00946713 /* UpdateCallRequest.swift in Sources */, 40E363752D0A2C6B0028C52A /* CGSize+Adapt.swift in Sources */, 4031D7FA2B84B077002EC6E4 /* StreamActiveCallProvider.swift in Sources */, diff --git a/StreamVideoTests/Call/Call_Tests.swift b/StreamVideoTests/Call/Call_Tests.swift index cd7dbbb60..4d5af4e35 100644 --- a/StreamVideoTests/Call/Call_Tests.swift +++ b/StreamVideoTests/Call/Call_Tests.swift @@ -537,6 +537,24 @@ final class Call_Tests: StreamVideoTestCase { XCTAssert(call?.state.broadcasting == false) } + // MARK: - updateClientCapabilities + + func test_updateClientCapabilities_correctlyUpdatesStateAdapter() async throws { + let mockCallController = MockCallController() + let call = MockCall(.dummy(callController: mockCallController)) + call.stub(for: \.state, with: .init()) + + await call.updateClientCapabilities([.subscriberVideoPause]) + + XCTAssertEqual( + mockCallController.recordedInputPayload( + Set.self, + for: .updateClientCapabilities + )?.first, + [.subscriberVideoPause] + ) + } + // MARK: - Private helpers private func assertUpdateState( diff --git a/StreamVideoTests/Controllers/CallController_Tests.swift b/StreamVideoTests/Controllers/CallController_Tests.swift index 72b42a865..460cef15a 100644 --- a/StreamVideoTests/Controllers/CallController_Tests.swift +++ b/StreamVideoTests/Controllers/CallController_Tests.swift @@ -659,6 +659,15 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { XCTAssertEqual(call.state.anonymousParticipantCount, 0) } + func test_updateClientCapabilities_correctlyUpdatesStateAdapter() async throws { + await subject.updateClientCapabilities([.subscriberVideoPause]) + + await assertEqualAsync( + await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.clientCapabilities, + [.subscriberVideoPause] + ) + } + // MARK: - Private helpers private func assertTransitionToStage( diff --git a/StreamVideoTests/Mock/CallParticipant_Mock.swift b/StreamVideoTests/Mock/CallParticipant_Mock.swift index c86de53b6..cb32eabf0 100644 --- a/StreamVideoTests/Mock/CallParticipant_Mock.swift +++ b/StreamVideoTests/Mock/CallParticipant_Mock.swift @@ -29,7 +29,8 @@ extension CallParticipant { joinedAt: Date = .init(timeIntervalSince1970: 0), audioLevel: Float = 0, audioLevels: [Float] = [], - pin: PinInfo? = nil + pin: PinInfo? = nil, + pausedTracks: Set = [] ) -> CallParticipant { .init( id: id, @@ -52,7 +53,8 @@ extension CallParticipant { joinedAt: joinedAt, audioLevel: audioLevel, audioLevels: audioLevels, - pin: pin + pin: pin, + pausedTracks: pausedTracks ) } } diff --git a/StreamVideoTests/Mock/MockCallController.swift b/StreamVideoTests/Mock/MockCallController.swift index 28ab49f60..dc74e0f6f 100644 --- a/StreamVideoTests/Mock/MockCallController.swift +++ b/StreamVideoTests/Mock/MockCallController.swift @@ -12,6 +12,7 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { case setDisconnectionTimeout case observeWebRTCStateUpdated case changeVideoState + case updateClientCapabilities } enum MockFunctionInputKey: Payloadable { @@ -29,6 +30,8 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { case changeVideoState(Bool) + case updateClientCapabilities(Set) + var payload: Any { switch self { case let .setDisconnectionTimeout(timeout): @@ -39,6 +42,8 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { return () case let .changeVideoState(value): return value + case let .updateClientCapabilities(value): + return value } } } @@ -115,4 +120,9 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { stubbedFunctionInput[.changeVideoState]? .append(.changeVideoState(isEnabled)) } + + override func updateClientCapabilities(_ capabilities: Set) async { + stubbedFunctionInput[.updateClientCapabilities]? + .append(.updateClientCapabilities(capabilities)) + } } diff --git a/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinator.swift b/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinator.swift index 4b4ce4520..f4f38f8ce 100644 --- a/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinator.swift +++ b/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinator.swift @@ -191,7 +191,8 @@ final class MockRTCPeerConnectionCoordinator: peerConnection: peerConnection, sfuAdapter: sfuAdapter ), - iceConnectionStateAdapter: iceConnectionStateAdapter ?? .init() + iceConnectionStateAdapter: iceConnectionStateAdapter ?? .init(), + clientCapabilities: [] ) stub(for: \.isHealthy, with: true) diff --git a/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorFactory.swift b/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorFactory.swift index 928960593..b14dd4002 100644 --- a/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorFactory.swift +++ b/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorFactory.swift @@ -21,7 +21,8 @@ final class MockRTCPeerConnectionCoordinatorFactory: RTCPeerConnectionCoordinato publishOptions: PublishOptions, sfuAdapter: SFUAdapter, videoCaptureSessionProvider: VideoCaptureSessionProvider, - screenShareSessionProvider: ScreenShareSessionProvider + screenShareSessionProvider: ScreenShareSessionProvider, + clientCapabilities: Set ) -> RTCPeerConnectionCoordinator { stubbedBuildCoordinatorResult[peerType] ?? MockRTCPeerConnectionCoordinator( sessionId: sessionId, @@ -35,7 +36,8 @@ final class MockRTCPeerConnectionCoordinatorFactory: RTCPeerConnectionCoordinato publishOptions: publishOptions, sfuAdapter: sfuAdapter, videoCaptureSessionProvider: videoCaptureSessionProvider, - screenShareSessionProvider: screenShareSessionProvider + screenShareSessionProvider: screenShareSessionProvider, + clientCapabilities: clientCapabilities ) } } diff --git a/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorStack.swift b/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorStack.swift index 37b74d5fb..91297a34d 100644 --- a/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorStack.swift +++ b/StreamVideoTests/Mock/MockRTCPeerConnectionCoordinatorStack.swift @@ -39,7 +39,8 @@ struct MockRTCPeerConnectionCoordinatorStack: @unchecked Sendable { spySubject: PassthroughSubject = .init(), mockLocalAudioMediaAdapter: MockLocalMediaAdapter = .init(), mockLocalVideoMediaAdapter: MockLocalMediaAdapter = .init(), - mockLocalScreenSharingMediaAdapter: MockLocalMediaAdapter = .init() + mockLocalScreenSharingMediaAdapter: MockLocalMediaAdapter = .init(), + clientCapabilities: Set = [] ) { self.sessionId = sessionId self.peerConnection = peerConnection @@ -108,7 +109,8 @@ struct MockRTCPeerConnectionCoordinatorStack: @unchecked Sendable { sfuAdapter: mockSFUStack.adapter, mediaAdapter: mediaAdapter, iceAdapter: iceAdapter, - iceConnectionStateAdapter: iceConnectionStateAdapter + iceConnectionStateAdapter: iceConnectionStateAdapter, + clientCapabilities: clientCapabilities ) } } diff --git a/StreamVideoTests/Models/CallParticipants_Tests.swift b/StreamVideoTests/Models/CallParticipants_Tests.swift index 86e3cc2cf..454fb9350 100644 --- a/StreamVideoTests/Models/CallParticipants_Tests.swift +++ b/StreamVideoTests/Models/CallParticipants_Tests.swift @@ -27,7 +27,8 @@ final class CallParticipants_Tests: XCTestCase, @unchecked Sendable { joinedAt: Date(), audioLevel: 0, audioLevels: [], - pin: nil + pin: nil, + pausedTracks: [] ) // When @@ -99,4 +100,35 @@ final class CallParticipants_Tests: XCTestCase, @unchecked Sendable { XCTAssertTrue(subject.shouldDisplayTrack) } + + func test_shouldDisplayTrack_hasVideoTrueShowTrackTrueTrackNotNilPausedTracksContainsVideo_returnsFalse() { + let subject = CallParticipant.dummy( + hasVideo: true, + showTrack: true, + track: PeerConnectionFactory.mock().mockVideoTrack(forScreenShare: false), + pausedTracks: [.video] + ) + + XCTAssertFalse(subject.shouldDisplayTrack) + } + + func test_shouldDisplayTrack_hasVideoTrueShowTrackTrueTrackNotNilPausedTracksDoesNotContainVideo_returnstrue() { + let subject = CallParticipant.dummy( + hasVideo: true, + showTrack: true, + track: PeerConnectionFactory.mock().mockVideoTrack(forScreenShare: false), + pausedTracks: [] + ) + + XCTAssertTrue(subject.shouldDisplayTrack) + } + + // MARK: - Equatable + + func test_isEqual_participantWithDifferentPausedTracksAreNotEqual() { + let participantA = CallParticipant.dummy(pausedTracks: [.video]) + let participantB = participantA.withPausedTrack(.audio) + + XCTAssertNotEqual(participantA, participantB) + } } diff --git a/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift b/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift index 7c1ea71c7..14fa74c4c 100644 --- a/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift @@ -423,6 +423,24 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { } } + func test_handleTrackUnpublished_givenAudioEvent_whenPublishedWithPausedTracksAudio_thenUpdatesParticipantAudioStatus( + ) async throws { + let participant = CallParticipant.dummy(hasAudio: true, pausedTracks: [.audio]) + var event = Stream_Video_Sfu_Event_TrackUnpublished() + event.sessionID = participant.sessionId + event.type = .audio + + try await assert( + event, + wrappedEvent: .sfuEvent(.trackUnpublished(event)), + initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0.count == 1 + && $0[participant.sessionId]?.hasAudio == false + && $0[participant.sessionId]?.pausedTracks.isEmpty == true + } + } + func test_handleTrackUnpublished_givenVideoEvent_whenPublished_thenUpdatesParticipantVideoStatus() async throws { let participant = CallParticipant.dummy(hasVideo: true) var event = Stream_Video_Sfu_Event_TrackUnpublished() @@ -438,6 +456,24 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { } } + func test_handleTrackUnpublished_givenVideoEvent_whenPublishedWithPausedTracksVideo_thenUpdatesParticipantVideoStatus( + ) async throws { + let participant = CallParticipant.dummy(hasVideo: true, pausedTracks: [.video]) + var event = Stream_Video_Sfu_Event_TrackUnpublished() + event.sessionID = participant.sessionId + event.type = .video + + try await assert( + event, + wrappedEvent: .sfuEvent(.trackUnpublished(event)), + initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0.count == 1 + && $0[participant.sessionId]?.hasVideo == false + && $0[participant.sessionId]?.pausedTracks.isEmpty == true + } + } + func test_handleTrackUnpublished_givenScreenShareEvent_whenPublished_thenUpdatesParticipantScreenShareStatus() async throws { let participant = CallParticipant.dummy(isScreenSharing: true) var event = Stream_Video_Sfu_Event_TrackUnpublished() @@ -453,6 +489,24 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { } } + func test_handleTrackUnpublished_givenScreenShareEvent_whenPublishedWithPausedTracksScreenshar_thenUpdatesParticipantScreenShareStatus( + ) async throws { + let participant = CallParticipant.dummy(isScreenSharing: true, pausedTracks: [.screenshare]) + var event = Stream_Video_Sfu_Event_TrackUnpublished() + event.sessionID = participant.sessionId + event.type = .screenShare + + try await assert( + event, + wrappedEvent: .sfuEvent(.trackUnpublished(event)), + initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0.count == 1 + && $0[participant.sessionId]?.isScreensharing == false + && $0[participant.sessionId]?.pausedTracks.isEmpty == true + } + } + // MARK: pinsChanged func test_handlePinsChanged_givenEvent_whenPublished_thenUpdatesPinnedParticipants() async throws { @@ -556,6 +610,145 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { ) { _ in await self.stateAdapter.publishOptions == expected } } + // MARK: inboundStateNotification + + func test_handleInboundVideoState_givenEvent_updatesParticipantsWithPausedTracks() async throws { + let participantA = CallParticipant.dummy() + let participantB = CallParticipant.dummy() + var event = Stream_Video_Sfu_Event_InboundStateNotification() + var participantAInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantAInboundState.paused = true + participantAInboundState.trackType = .video + participantAInboundState.sessionID = participantA.sessionId + event.inboundVideoStates = [ + participantAInboundState + ] + try await assert( + event, + wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0[participantA.sessionId]?.pausedTracks == [.video] && $0[participantB.sessionId]?.pausedTracks.isEmpty == true + } + } + + func test_handleInboundVideoState_givenEventWithMultipleUsers_updatesParticipantsWithPausedTracks() async throws { + let participantA = CallParticipant.dummy() + let participantB = CallParticipant.dummy() + let participantC = CallParticipant.dummy() + var event = Stream_Video_Sfu_Event_InboundStateNotification() + + var participantAInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantAInboundState.paused = true + participantAInboundState.trackType = .video + participantAInboundState.sessionID = participantA.sessionId + + var participantBInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantBInboundState.paused = true + participantBInboundState.trackType = .audio + participantBInboundState.sessionID = participantB.sessionId + + event.inboundVideoStates = [ + participantAInboundState, + participantBInboundState + ] + try await assert( + event, + wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + initialState: [participantA, participantB, participantC] + .reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0[participantA.sessionId]?.pausedTracks == [.video] && $0[participantB.sessionId]? + .pausedTracks == [.audio] && $0[participantC.sessionId]?.pausedTracks.isEmpty == true + } + } + + func test_handleInboundVideoState_givenEvent_updatesParticipantsWithUnpausedTracks() async throws { + let participantA = CallParticipant.dummy(pausedTracks: [.video]) + let participantB = CallParticipant.dummy() + var event = Stream_Video_Sfu_Event_InboundStateNotification() + var participantAInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantAInboundState.paused = false + participantAInboundState.trackType = .video + participantAInboundState.sessionID = participantA.sessionId + event.inboundVideoStates = [ + participantAInboundState + ] + try await assert( + event, + wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0[participantA.sessionId]?.pausedTracks.isEmpty == true + && $0[participantB.sessionId]?.pausedTracks.isEmpty == true + } + } + + func test_handleInboundVideoState_givenEventWithMultipleUsers_updatesParticipantsWithUnpausedTracks() async throws { + let participantA = CallParticipant.dummy(pausedTracks: [.video]) + let participantB = CallParticipant.dummy(pausedTracks: [.audio]) + let participantC = CallParticipant.dummy() + var event = Stream_Video_Sfu_Event_InboundStateNotification() + + var participantAInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantAInboundState.paused = false + participantAInboundState.trackType = .video + participantAInboundState.sessionID = participantA.sessionId + + var participantBInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantBInboundState.paused = false + participantBInboundState.trackType = .audio + participantBInboundState.sessionID = participantB.sessionId + + event.inboundVideoStates = [ + participantAInboundState, + participantBInboundState + ] + try await assert( + event, + wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + initialState: [participantA, participantB, participantC] + .reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0[participantA.sessionId]?.pausedTracks.isEmpty == true + && $0[participantB.sessionId]?.pausedTracks.isEmpty == true + && $0[participantC.sessionId]?.pausedTracks.isEmpty == true + } + } + + func test_handleInboundVideoState_givenEventWithMultipleUsersWithMulptiplePausedTracks_updatesParticipantsWithCorrectTracks( + ) async throws { + let participantA = CallParticipant.dummy(pausedTracks: [.video, .screenshare]) + let participantB = CallParticipant.dummy(pausedTracks: [.audio, .video]) + let participantC = CallParticipant.dummy() + var event = Stream_Video_Sfu_Event_InboundStateNotification() + + var participantAInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantAInboundState.paused = false + participantAInboundState.trackType = .video + participantAInboundState.sessionID = participantA.sessionId + + var participantBInboundState = Stream_Video_Sfu_Event_InboundVideoState() + participantBInboundState.paused = false + participantBInboundState.trackType = .audio + participantBInboundState.sessionID = participantB.sessionId + + event.inboundVideoStates = [ + participantAInboundState, + participantBInboundState + ] + try await assert( + event, + wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + initialState: [participantA, participantB, participantC] + .reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } + ) { + $0[participantA.sessionId]?.pausedTracks == [.screenshare] + && $0[participantB.sessionId]?.pausedTracks == [.video] + && $0[participantC.sessionId]?.pausedTracks.isEmpty == true + } + } + // MARK: - Private helpers private func assert( diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift index d0a815dd8..127b67cc3 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift @@ -63,7 +63,8 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable peerConnection: mockPeerConnection, sfuAdapter: mockSFUStack.adapter ), - iceConnectionStateAdapter: iceConnectionStateAdapter + iceConnectionStateAdapter: iceConnectionStateAdapter, + clientCapabilities: [] ) override func tearDown() { diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift index 6f341a4ee..172f58fce 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift @@ -114,6 +114,10 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .stateAdapter .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) + await mockCoordinatorStack + .coordinator + .updateClientCapabilities([.subscriberVideoPause]) + try await assertTransition( from: .connected, expectedTarget: .disconnected, @@ -139,6 +143,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec XCTAssertEqual(request.joinRequest.reconnectDetails.strategy, .unspecified) XCTAssertTrue(request.joinRequest.reconnectDetails.previousSessionID.isEmpty) XCTAssertTrue(request.joinRequest.reconnectDetails.fromSfuID.isEmpty) + XCTAssertEqual(request.joinRequest.capabilities, [.subscriberVideoPause]) } } diff --git a/StreamVideoTests/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter_Tests.swift index fb7974a44..f916dd56a 100644 --- a/StreamVideoTests/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/UpdateSubscriptions/WebRTCUpdateSubscriptionsAdapter_Tests.swift @@ -17,7 +17,8 @@ final class WebRTCUpdateSubscriptionsAdapter_Tests: XCTestCase, @unchecked Senda participantsPublisher: participantsSubject.eraseToAnyPublisher(), incomingVideoQualitySettingsPublisher: incomingVideoQualitySettingsSubject.eraseToAnyPublisher(), sfuAdapter: mockSFUStack.adapter, - sessionID: sessionID + sessionID: sessionID, + clientCapabilities: [] ) override func setUp() { diff --git a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift index 9054bf0c4..6f8ffe3fc 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift @@ -548,6 +548,14 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(videoPublishOptions.bitrate, 1000) } + // MARK: - updateClientCapabilities + + func test_updateClientCapabilities_correctlyUpdatesStateAdapter() async throws { + await subject.updateClientCapabilities([.subscriberVideoPause]) + + await assertEqualAsync(await subject.stateAdapter.clientCapabilities, [.subscriberVideoPause]) + } + // MARK: - Private helpers private func assertEqualAsync( diff --git a/StreamVideoTests/WebRTC/v2/WebRTCJoinRequestFactory_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCJoinRequestFactory_Tests.swift index bff8a2c79..a269446b2 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCJoinRequestFactory_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCJoinRequestFactory_Tests.swift @@ -13,7 +13,7 @@ final class WebRTCJoinRequestFactory_Tests: XCTestCase, @unchecked Sendable { private lazy var mockCoordinatorStack: MockWebRTCCoordinatorStack! = .init( videoConfig: Self.videoConfig ) - private var subject: WebRTCJoinRequestFactory! = .init() + private var subject: WebRTCJoinRequestFactory! = .init(capabilities: []) // MARK: - Lifecycle From 083845272a8b5ad678cafa935bc28adf02b4addc Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Tue, 15 Jul 2025 11:06:01 +0300 Subject: [PATCH 2/6] Fix compilation errors --- .../DocumentationTests/GloballyUsedVariables.swift | 6 ++++-- TestTools/TestData/ViewFactory.swift | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/GloballyUsedVariables.swift b/DocumentationTests/DocumentationTests/DocumentationTests/GloballyUsedVariables.swift index ed9bcb4dc..e4cf0386f 100644 --- a/DocumentationTests/DocumentationTests/DocumentationTests/GloballyUsedVariables.swift +++ b/DocumentationTests/DocumentationTests/DocumentationTests/GloballyUsedVariables.swift @@ -37,7 +37,8 @@ var participant = CallParticipant( joinedAt: .init(), audioLevel: 0, audioLevels: [], - pin: nil + pin: nil, + pausedTracks: [] ) var contentMode = UIView.ContentMode.scaleAspectFit var id = "" @@ -421,7 +422,8 @@ var otherParticipant = CallParticipant( joinedAt: .init(), audioLevel: 0, audioLevels: [], - pin: nil + pin: nil, + pausedTracks: [] ) final class UserManager { diff --git a/TestTools/TestData/ViewFactory.swift b/TestTools/TestData/ViewFactory.swift index 93d25b93a..b35eeb10c 100644 --- a/TestTools/TestData/ViewFactory.swift +++ b/TestTools/TestData/ViewFactory.swift @@ -85,7 +85,8 @@ struct ParticipantFactory { joinedAt: Date(), audioLevel: 0, audioLevels: [], - pin: nil + pin: nil, + pausedTracks: [] ) factory.append(participant) } From 69db968b7841734fc05dbbf3849873354f213e29 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Tue, 15 Jul 2025 11:47:49 +0300 Subject: [PATCH 3/6] Update changelog and documentation test --- CHANGELOG.md | 3 +- .../project.pbxproj | 4 +++ .../25-incoming-video-state.swift | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b34ba51..a51d7dc5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +### ✅ Added +- `ClientCapabilities` have been added to support remote subscriber track pause. [#888](https://github.com/GetStream/stream-video-swift/pull/888) # [1.28.1](https://github.com/GetStream/stream-video-swift/releases/tag/1.28.1) _July 11, 2025_ diff --git a/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj b/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj index 9e300d7fd..b86815e39 100644 --- a/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj +++ b/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 4029E95E2CB94EAE00E1D571 /* 22-manual-quality-selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */; }; 404CAED82B8E3874007087BC /* 06-apply-video-filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404CAED72B8E3874007087BC /* 06-apply-video-filters.swift */; }; 4068C1252B67C056006B0BEE /* 03-callkit-integration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4068C1242B67C056006B0BEE /* 03-callkit-integration.swift */; }; + 40895E622E264BB000D3049D /* 25-incoming-video-state.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40895E612E264BB000D3049D /* 25-incoming-video-state.swift */; }; 408CE0F52BD91B490052EC3A /* 19-transcriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F42BD91B490052EC3A /* 19-transcriptions.swift */; }; 409774B02CC19F5500E0D3EE /* 23-network-disruption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */; }; 409C39692B67CC5C0090044C /* 04-screensharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409C39682B67CC5C0090044C /* 04-screensharing.swift */; }; @@ -102,6 +103,7 @@ 4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "22-manual-quality-selection.swift"; sourceTree = ""; }; 404CAED72B8E3874007087BC /* 06-apply-video-filters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "06-apply-video-filters.swift"; sourceTree = ""; }; 4068C1242B67C056006B0BEE /* 03-callkit-integration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-callkit-integration.swift"; sourceTree = ""; }; + 40895E612E264BB000D3049D /* 25-incoming-video-state.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "25-incoming-video-state.swift"; sourceTree = ""; }; 408CE0F42BD91B490052EC3A /* 19-transcriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "19-transcriptions.swift"; sourceTree = ""; }; 409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "23-network-disruption.swift"; sourceTree = ""; }; 409C39682B67CC5C0090044C /* 04-screensharing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-screensharing.swift"; sourceTree = ""; }; @@ -286,6 +288,7 @@ 4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */, 409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */, 401C1EF32D494CED00304609 /* 24-closed-captions.swift */, + 40895E612E264BB000D3049D /* 25-incoming-video-state.swift */, ); path = "05-ui-cookbook"; sourceTree = ""; @@ -487,6 +490,7 @@ 40FFDC442B63E95D004DA7A2 /* 14-swiftui-vs-uikit.swift in Sources */, 40FFDC872B63FEAE004DA7A2 /* 05-incoming-call.swift in Sources */, 4029E95E2CB94EAE00E1D571 /* 22-manual-quality-selection.swift in Sources */, + 40895E622E264BB000D3049D /* 25-incoming-video-state.swift in Sources */, 40FFDC942B6401CC004DA7A2 /* 07-video-fallback.swift in Sources */, 400D91C72B63D96800EBA47D /* 03-quickstart.swift in Sources */, 40FFDC9E2B64063D004DA7A2 /* 12-connection-unstable.swift in Sources */, diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift new file mode 100644 index 000000000..67e08715d --- /dev/null +++ b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift @@ -0,0 +1,28 @@ +import StreamVideo +import StreamVideoSwiftUI +import SwiftUI +import Combine + +@MainActor +fileprivate func content() { + asyncContainer { + let call = streamVideo.call(callType: "default", callId: "my-call-id") + await call.updateClientCapabilities([.subscriberVideoPause]) + } + + container { + let cancellable = call + .state + .$participants + .sink { participants in + let pausedVideoParticipants = participants.filter { + $0.pausedTracks.contains(.video) + } + + print("Participants with paused video tracks: \(pausedVideoParticipants)") + } + + // Cancel when no longer needed: + cancellable.cancel() + } +} From 6d66e1f6c6c5b7ff41c4bdb70fb9551f32906bf4 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Tue, 15 Jul 2025 13:04:47 +0300 Subject: [PATCH 4/6] Set feature enabled by default --- .../Sources/Components/AppEnvironment.swift | 2 +- .../CallingView/SimpleCallingView.swift | 3 +- DemoApp/Sources/Views/Login/DebugMenu.swift | 39 +++++++++++++++---- .../WebRTC/v2/WebRTCStateAdapter.swift | 4 +- .../WebRTC/v2/WebRTCStateAdapter_Tests.swift | 18 +++++++++ 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/DemoApp/Sources/Components/AppEnvironment.swift b/DemoApp/Sources/Components/AppEnvironment.swift index b09f84c71..465f358cd 100644 --- a/DemoApp/Sources/Components/AppEnvironment.swift +++ b/DemoApp/Sources/Components/AppEnvironment.swift @@ -622,7 +622,7 @@ extension AppEnvironment { extension AppEnvironment { - static var clientCapabilities: Set = [] + static var clientCapabilities: Set? } extension ClientCapability: Debuggable { diff --git a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift index 018faf7f1..977486d4b 100644 --- a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift +++ b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift @@ -198,8 +198,7 @@ struct SimpleCallingView: View { } private func setClientCapabilities(for callId: String) async { - let clientCapabilities = AppEnvironment.clientCapabilities - guard !clientCapabilities.isEmpty else { + guard let clientCapabilities = AppEnvironment.clientCapabilities else { return } let call = streamVideo.call(callType: callType, callId: callId) diff --git a/DemoApp/Sources/Views/Login/DebugMenu.swift b/DemoApp/Sources/Views/Login/DebugMenu.swift index 6b186e67a..cbc087cf0 100644 --- a/DemoApp/Sources/Views/Login/DebugMenu.swift +++ b/DemoApp/Sources/Views/Login/DebugMenu.swift @@ -129,8 +129,9 @@ struct DebugMenu: View { didSet { AppEnvironment.proximityPolicies = proximityPolicies } } - @State private var clientCapabilities = AppEnvironment.clientCapabilities { - didSet { AppEnvironment.clientCapabilities = clientCapabilities } + @State private var availableClientCapabilities = ClientCapability.allCases + @State private var preferredClientCapabilities = AppEnvironment.clientCapabilities { + didSet { AppEnvironment.clientCapabilities = preferredClientCapabilities } } var body: some View { @@ -193,14 +194,36 @@ struct DebugMenu: View { ) { self.closedCaptionsIntegration = $0 } makeMultipleSelectMenu( - for: ClientCapability.allCases, - currentValues: clientCapabilities, - label: "Client Capabilities" + for: availableClientCapabilities, + currentValues: preferredClientCapabilities ?? [], + additionalItems: { + if preferredClientCapabilities != nil { + Divider() + + Button { + self.preferredClientCapabilities = nil + } label: { + Text("Remove overrides") + } + } else { + EmptyView() + } + }, + label: "Override Client Capabilities" ) { item, isSelected in if isSelected { - clientCapabilities = clientCapabilities.filter { item != $0 } + if let preferredClientCapabilities { + if preferredClientCapabilities.count == 1 { + self.preferredClientCapabilities = nil + } else { + self.preferredClientCapabilities = preferredClientCapabilities.filter { item != $0 } + } + } } else { - clientCapabilities.insert(item) + if preferredClientCapabilities == nil { + preferredClientCapabilities = Set() + } + preferredClientCapabilities?.insert(item) } } @@ -500,6 +523,7 @@ struct DebugMenu: View { private func makeMultipleSelectMenu( for items: [Item], currentValues: Set, + @ViewBuilder additionalItems: () -> some View = { EmptyView() }, label: String, updater: @escaping (Item, Bool) -> Void ) -> some View { @@ -517,6 +541,7 @@ struct DebugMenu: View { } } } + additionalItems() } label: { Text(label) } diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift index 6ffb50863..dc9b91b62 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift @@ -76,7 +76,9 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { @Published private(set) var incomingVideoQualitySettings: IncomingVideoQualitySettings = .none @Published private(set) var isTracingEnabled: Bool = false - private(set) var clientCapabilities: Set = [] + private(set) var clientCapabilities: Set = [ + .subscriberVideoPause + ] // Various private and internal properties. private(set) var initialCallSettings: CallSettings? diff --git a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift index 4512dad53..5b5bb55c3 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift @@ -277,6 +277,24 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { ) } + // MARK: - clientCapabilities + + func test_clientCapabilities_expectedDefaultValue() async throws { + await assertEqualAsync( + await subject.clientCapabilities, + [.subscriberVideoPause] + ) + } + + // MARK: - setClientCapabilities + + func test_setClientCapabilities_shouldUpdateClientCapabilities() async throws { + + await subject.set(clientCapabilities: []) + + await assertEqualAsync(await subject.clientCapabilities, []) + } + // MARK: - refreshSession func test_refreshSession_shouldUpdateSessionID() async throws { From 5dfdc124195a5999fa28bb0c3e87460bfce0338f Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Tue, 15 Jul 2025 15:02:43 +0300 Subject: [PATCH 5/6] Update docs --- .../25-incoming-video-state.swift | 142 +++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift index 67e08715d..831279d24 100644 --- a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift +++ b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift @@ -7,7 +7,7 @@ import Combine fileprivate func content() { asyncContainer { let call = streamVideo.call(callType: "default", callId: "my-call-id") - await call.updateClientCapabilities([.subscriberVideoPause]) + await call.updateClientCapabilities([]) } container { @@ -25,4 +25,144 @@ fileprivate func content() { // Cancel when no longer needed: cancellable.cancel() } + + viewContainer { + if participant.pausedTracks.contains(.video) { + Image(systemName: "video.slash.fill") + .foregroundColor(.yellow) + .padding(4) + } + } + + container { + struct ParticipantInfoView: View { + @Injected(\.images) var images + @Injected(\.fonts) var fonts + @Injected(\.colors) var colors + + var participant: CallParticipant + var isPinned: Bool + var maxHeight: CGFloat + + public init( + participant: CallParticipant, + isPinned: Bool, + maxHeight: Float = 14 + ) { + self.participant = participant + self.isPinned = isPinned + self.maxHeight = CGFloat(maxHeight) + } + + public var body: some View { + HStack(spacing: 4) { + if isPinned { + Image(systemName: "pin.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: maxHeight) + .foregroundColor(.white) + .padding(.trailing, 4) + } + Text(participant.name.isEmpty ? participant.id : participant.name) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .lineLimit(1) + .font(fonts.caption1) + .minimumScaleFactor(0.7) + .accessibility(identifier: "participantName") + + if participant.pausedTracks.contains(.video) { + Image(systemName: "wifi.slash") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: maxHeight) + .foregroundColor(.white) + .padding(.trailing, 4) + } + + SoundIndicator(participant: participant) + .frame(maxHeight: maxHeight) + } + .padding(.all, 2) + .padding(.horizontal, 4) + .frame(height: 28) + .cornerRadius( + 8, + corners: [.topRight], + backgroundColor: colors.participantInfoBackgroundColor + ) + } + } + } + + container { + struct VideoCallParticipantModifier: ViewModifier { + + var participant: CallParticipant + var call: Call? + var availableFrame: CGRect + var ratio: CGFloat + var showAllInfo: Bool + var decorations: Set + + public init( + participant: CallParticipant, + call: Call?, + availableFrame: CGRect, + ratio: CGFloat, + showAllInfo: Bool, + decorations: [VideoCallParticipantDecoration] = VideoCallParticipantDecoration.allCases + ) { + self.participant = participant + self.call = call + self.availableFrame = availableFrame + self.ratio = ratio + self.showAllInfo = showAllInfo + self.decorations = .init(decorations) + } + + public func body(content: Content) -> some View { + content + .adjustVideoFrame(to: availableFrame.size.width, ratio: ratio) + .overlay( + ZStack { + BottomView(content: { + HStack { + ParticipantInfoView( + participant: participant, + isPinned: participant.isPinned + ) + + Spacer() + + if showAllInfo { + ConnectionQualityIndicator( + connectionQuality: participant.connectionQuality + ) + } + } + }) + } + ) + .applyDecorationModifierIfRequired( + VideoCallParticipantOptionsModifier(participant: participant, call: call), + decoration: .options, + availableDecorations: decorations + ) + .applyDecorationModifierIfRequired( + VideoCallParticipantSpeakingModifier(participant: participant, participantCount: participantCount), + decoration: .speaking, + availableDecorations: decorations + ) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipped() + } + + @MainActor + private var participantCount: Int { + call?.state.participants.count ?? 0 + } + } + } } From 4883ad44a3fa11a4009d0ace61e01f8527ba2412 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Thu, 17 Jul 2025 13:39:19 +0300 Subject: [PATCH 6/6] Update API --- .../CallingView/SimpleCallingView.swift | 2 +- .../25-incoming-video-state.swift | 2 +- Sources/StreamVideo/Call.swift | 30 +++++++++++++++---- .../Controllers/CallController.swift | 10 +++++-- .../WebRTC/v2/WebRTCCoordinator.swift | 8 +++-- .../WebRTC/v2/WebRTCStateAdapter.swift | 10 +++++-- StreamVideoTests/Call/Call_Tests.swift | 26 +++++++++++++--- .../Controllers/CallController_Tests.swift | 17 +++++++++-- .../Mock/MockCallController.swift | 26 ++++++++++++---- ...inatorStateMachine_JoiningStageTests.swift | 4 --- .../WebRTC/v2/WebRTCCoorindator_Tests.swift | 22 +++++++++++--- .../WebRTC/v2/WebRTCStateAdapter_Tests.swift | 17 +++++++++-- 12 files changed, 137 insertions(+), 37 deletions(-) diff --git a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift index 977486d4b..f99259427 100644 --- a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift +++ b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift @@ -202,7 +202,7 @@ struct SimpleCallingView: View { return } let call = streamVideo.call(callType: callType, callId: callId) - await call.updateClientCapabilities(clientCapabilities) + await call.enableClientCapabilities(clientCapabilities) } private func parseURLIfRequired(_ text: String) { diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift index 831279d24..4eee15da3 100644 --- a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift +++ b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/25-incoming-video-state.swift @@ -7,7 +7,7 @@ import Combine fileprivate func content() { asyncContainer { let call = streamVideo.call(callType: "default", callId: "my-call-id") - await call.updateClientCapabilities([]) + await call.disableClientCapabilities([.subscriberVideoPause]) } container { diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index 8f2bc2eff..75923daee 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -1389,14 +1389,32 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { proximity.remove(policy) } - // MARK: - ClientCapabilities + // MARK: - Client Capabilities - /// Updates the set of client capabilities for the call. + /// Enables a set of client capabilities for the call. /// - /// - Parameter clientCapabilities: A set of client capabilities that influence - /// subscription logic (e.g., support for paused tracks). - public func updateClientCapabilities(_ clientCapabilities: Set) async { - await callController.updateClientCapabilities(clientCapabilities) + /// Use this to activate one or more client capabilities for the current call + /// session. Enabling capabilities may affect how features such as paused tracks, + /// bandwidth management, or other optional behaviors work for this call. + /// + /// - Parameter capabilities: The set of capabilities to enable. + public func enableClientCapabilities( + _ capabilities: Set + ) async { + await callController.enableClientCapabilities(capabilities) + } + + /// Disables a set of client capabilities for the call. + /// + /// Use this to deactivate one or more client capabilities for the current call + /// session. Disabling capabilities can limit or remove optional features + /// associated with those capabilities. + /// + /// - Parameter capabilities: The set of capabilities to disable. + public func disableClientCapabilities( + _ capabilities: Set + ) async { + await callController.disableClientCapabilities(capabilities) } // MARK: - Internal diff --git a/Sources/StreamVideo/Controllers/CallController.swift b/Sources/StreamVideo/Controllers/CallController.swift index 7c77ec538..64c8431c8 100644 --- a/Sources/StreamVideo/Controllers/CallController.swift +++ b/Sources/StreamVideo/Controllers/CallController.swift @@ -505,8 +505,14 @@ class CallController: @unchecked Sendable { try webRTCCoordinator.callKitActivated(audioSession) } - func updateClientCapabilities(_ capabilities: Set) async { - await webRTCCoordinator.updateClientCapabilities(capabilities) + // MARK: - Client Capabilities + + func enableClientCapabilities(_ capabilities: Set) async { + await webRTCCoordinator.enableClientCapabilities(capabilities) + } + + func disableClientCapabilities(_ capabilities: Set) async { + await webRTCCoordinator.disableClientCapabilities(capabilities) } // MARK: - private diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift index f9a32d69e..b8a93f0f8 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift @@ -436,8 +436,12 @@ final class WebRTCCoordinator: @unchecked Sendable { try stateAdapter.audioSession.callKitActivated(audioSession) } - func updateClientCapabilities(_ capabilities: Set) async { - await stateAdapter.set(clientCapabilities: capabilities) + func enableClientCapabilities(_ capabilities: Set) async { + await stateAdapter.enableClientCapabilities(capabilities) + } + + func disableClientCapabilities(_ capabilities: Set) async { + await stateAdapter.disableClientCapabilities(capabilities) } // MARK: - Private diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift index dc9b91b62..b744768b5 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCStateAdapter.swift @@ -220,8 +220,14 @@ actor WebRTCStateAdapter: ObservableObject, StreamAudioSessionAdapterDelegate { statsAdapter?.isTracingEnabled = value } - func set(clientCapabilities value: Set) { - self.clientCapabilities = value + // MARK: - Client Capabilities + + func enableClientCapabilities(_ capabilities: Set) { + self.clientCapabilities = self.clientCapabilities.union(capabilities) + } + + func disableClientCapabilities(_ capabilities: Set) { + self.clientCapabilities = self.clientCapabilities.subtracting(capabilities) } // MARK: - Session Management diff --git a/StreamVideoTests/Call/Call_Tests.swift b/StreamVideoTests/Call/Call_Tests.swift index 4d5af4e35..791573a38 100644 --- a/StreamVideoTests/Call/Call_Tests.swift +++ b/StreamVideoTests/Call/Call_Tests.swift @@ -537,19 +537,37 @@ final class Call_Tests: StreamVideoTestCase { XCTAssert(call?.state.broadcasting == false) } - // MARK: - updateClientCapabilities + // MARK: - enableClientCapabilities - func test_updateClientCapabilities_correctlyUpdatesStateAdapter() async throws { + func test_enableClientCapabilities_correctlyUpdatesStateAdapter() async throws { let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) call.stub(for: \.state, with: .init()) - await call.updateClientCapabilities([.subscriberVideoPause]) + await call.enableClientCapabilities([.subscriberVideoPause]) XCTAssertEqual( mockCallController.recordedInputPayload( Set.self, - for: .updateClientCapabilities + for: .enableClientCapabilities + )?.first, + [.subscriberVideoPause] + ) + } + + // MARK: - disableClientCapabilities + + func test_disableClientCapabilities_correctlyUpdatesStateAdapter() async throws { + let mockCallController = MockCallController() + let call = MockCall(.dummy(callController: mockCallController)) + call.stub(for: \.state, with: .init()) + + await call.disableClientCapabilities([.subscriberVideoPause]) + + XCTAssertEqual( + mockCallController.recordedInputPayload( + Set.self, + for: .disableClientCapabilities )?.first, [.subscriberVideoPause] ) diff --git a/StreamVideoTests/Controllers/CallController_Tests.swift b/StreamVideoTests/Controllers/CallController_Tests.swift index 460cef15a..ab98993b1 100644 --- a/StreamVideoTests/Controllers/CallController_Tests.swift +++ b/StreamVideoTests/Controllers/CallController_Tests.swift @@ -659,8 +659,10 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { XCTAssertEqual(call.state.anonymousParticipantCount, 0) } - func test_updateClientCapabilities_correctlyUpdatesStateAdapter() async throws { - await subject.updateClientCapabilities([.subscriberVideoPause]) + // MARK: - enableClientCapabilities + + func test_enableClientCapabilities_correctlyUpdatesStateAdapter() async throws { + await subject.enableClientCapabilities([.subscriberVideoPause]) await assertEqualAsync( await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.clientCapabilities, @@ -668,6 +670,17 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { ) } + // MARK: - disableClientCapabilities + + func test_disableClientCapabilities_correctlyUpdatesStateAdapter() async throws { + await subject.disableClientCapabilities([.subscriberVideoPause]) + + await assertEqualAsync( + await mockWebRTCCoordinatorFactory.mockCoordinatorStack.coordinator.stateAdapter.clientCapabilities, + [] + ) + } + // MARK: - Private helpers private func assertTransitionToStage( diff --git a/StreamVideoTests/Mock/MockCallController.swift b/StreamVideoTests/Mock/MockCallController.swift index dc74e0f6f..71d029146 100644 --- a/StreamVideoTests/Mock/MockCallController.swift +++ b/StreamVideoTests/Mock/MockCallController.swift @@ -12,7 +12,8 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { case setDisconnectionTimeout case observeWebRTCStateUpdated case changeVideoState - case updateClientCapabilities + case enableClientCapabilities + case disableClientCapabilities } enum MockFunctionInputKey: Payloadable { @@ -30,7 +31,9 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { case changeVideoState(Bool) - case updateClientCapabilities(Set) + case enableClientCapabilities(Set) + + case disableClientCapabilities(Set) var payload: Any { switch self { @@ -42,7 +45,9 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { return () case let .changeVideoState(value): return value - case let .updateClientCapabilities(value): + case let .enableClientCapabilities(value): + return value + case let .disableClientCapabilities(value): return value } } @@ -121,8 +126,17 @@ final class MockCallController: CallController, Mockable, @unchecked Sendable { .append(.changeVideoState(isEnabled)) } - override func updateClientCapabilities(_ capabilities: Set) async { - stubbedFunctionInput[.updateClientCapabilities]? - .append(.updateClientCapabilities(capabilities)) + override func enableClientCapabilities( + _ capabilities: Set + ) async { + stubbedFunctionInput[.enableClientCapabilities]? + .append(.enableClientCapabilities(capabilities)) + } + + override func disableClientCapabilities( + _ capabilities: Set + ) async { + stubbedFunctionInput[.disableClientCapabilities]? + .append(.disableClientCapabilities(capabilities)) } } diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift index 172f58fce..5ccf17eae 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift @@ -114,10 +114,6 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .stateAdapter .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) - await mockCoordinatorStack - .coordinator - .updateClientCapabilities([.subscriberVideoPause]) - try await assertTransition( from: .connected, expectedTarget: .disconnected, diff --git a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift index 6f8ffe3fc..e84339f04 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift @@ -548,12 +548,26 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(videoPublishOptions.bitrate, 1000) } - // MARK: - updateClientCapabilities + // MARK: - enableClientCapabilities - func test_updateClientCapabilities_correctlyUpdatesStateAdapter() async throws { - await subject.updateClientCapabilities([.subscriberVideoPause]) + func test_enableClientCapabilities_correctlyUpdatesStateAdapter() async throws { + await subject.enableClientCapabilities([.subscriberVideoPause]) - await assertEqualAsync(await subject.stateAdapter.clientCapabilities, [.subscriberVideoPause]) + await assertEqualAsync( + await subject.stateAdapter.clientCapabilities, + [.subscriberVideoPause] + ) + } + + // MARK: - disableClientCapabilities + + func test_disableClientCapabilities_correctlyUpdatesStateAdapter() async throws { + await subject.disableClientCapabilities([.subscriberVideoPause]) + + await assertEqualAsync( + await subject.stateAdapter.clientCapabilities, + [] + ) } // MARK: - Private helpers diff --git a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift index 5b5bb55c3..8ead1c34d 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift @@ -286,11 +286,22 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { ) } - // MARK: - setClientCapabilities + // MARK: - enableClientCapabilities - func test_setClientCapabilities_shouldUpdateClientCapabilities() async throws { + func enableClientCapabilities_shouldUpdateClientCapabilities() async throws { - await subject.set(clientCapabilities: []) + await subject.enableClientCapabilities([.subscriberVideoPause]) + + await assertEqualAsync(await subject.clientCapabilities, [.subscriberVideoPause]) + } + + // MARK: - disableClientCapabilities + + func test_enableClientCapabilities_shouldUpdateClientCapabilities() async throws { + + await subject.enableClientCapabilities([.subscriberVideoPause]) + + await subject.disableClientCapabilities([.subscriberVideoPause]) await assertEqualAsync(await subject.clientCapabilities, []) }