Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### ✅ Added
- When the user is missing a permission, the SDK will prompt them to accept any missing permission. [#915](https://github.com/GetStream/stream-video-swift/pull/915)
- `CallParticipant` now exposes the `source` property, which can be used to distinguish between WebRTC users and ingest sources like RTMP or SIP. [#93](https://github.com/GetStream/stream-video-swift/pull/933)

### 🔄 Changed
- Improved the LastParticipantLeavePolicy to be more robust. [#925](https://github.com/GetStream/stream-video-swift/pull/925)
Expand Down
18 changes: 14 additions & 4 deletions Sources/StreamVideo/Models/CallParticipant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,22 @@ public struct CallParticipant: Identifiable, Sendable, Hashable {
/// 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<TrackType>

/// The user's id. This is not necessarily unique, since a user can join from multiple devices.
/// Describes where the participant's media originates from.
/// Distinguish WebRTC users from ingest sources like RTMP or SIP. Defaults
/// to `.webRTCUnspecified`.
public var source: ParticipantSource

/// The user's id. This is not necessarily unique, since a user can join
/// from multiple devices.
public var userId: String {
user.id
}
Expand Down Expand Up @@ -92,7 +96,8 @@ public struct CallParticipant: Identifiable, Sendable, Hashable {
audioLevel: Float,
audioLevels: [Float],
pin: PinInfo?,
pausedTracks: Set<TrackType>
pausedTracks: Set<TrackType>,
source: ParticipantSource = .webRTCUnspecified
) {
user = User(
id: userId,
Expand All @@ -118,6 +123,7 @@ public struct CallParticipant: Identifiable, Sendable, Hashable {
self.audioLevels = audioLevels
self.pin = pin
self.pausedTracks = pausedTracks
self.source = source
}

public static func == (lhs: CallParticipant, rhs: CallParticipant) -> Bool {
Expand All @@ -143,10 +149,12 @@ public struct CallParticipant: Identifiable, Sendable, Hashable {
lhs.pausedTracks == rhs.pausedTracks
}

/// Indicates whether any pin is applied to this participant.
public var isPinned: Bool {
pin != nil
}

/// Indicates whether the pin was set by another user.
public var isPinnedRemotely: Bool {
guard let pin else { return false }
return pin.isLocal == false
Expand Down Expand Up @@ -490,6 +498,7 @@ public struct CallParticipant: Identifiable, Sendable, Hashable {
)
}

/// Returns a copy with the given track type marked as paused.
public func withPausedTrack(_ trackType: TrackType) -> CallParticipant {
var updatedPausedTracks = pausedTracks
updatedPausedTracks.insert(trackType)
Expand Down Expand Up @@ -519,6 +528,7 @@ public struct CallParticipant: Identifiable, Sendable, Hashable {
)
}

/// Returns a copy with the given track type unpaused.
public func withUnpausedTrack(_ trackType: TrackType) -> CallParticipant {
var updatedPausedTracks = pausedTracks
updatedPausedTracks.remove(trackType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ extension Stream_Video_Sfu_Models_Participant {
audioLevel: audioLevel,
audioLevels: [audioLevel],
pin: pin,
pausedTracks: []
pausedTracks: [],
source: .init(source)
)
}
}
87 changes: 87 additions & 0 deletions Sources/StreamVideo/Models/ParticipantSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

/// Describes the origin of a participant's media.
/// Values mirror the backend model. Use this to handle WebRTC users, SIP
/// gateways, and ingest streams differently.
public enum ParticipantSource: CustomStringConvertible, Hashable, Sendable {

/// WebRTC participant. Default for SDK users.
case webRTCUnspecified
/// RTMP ingest source.
case rtmp
/// WHIP published source.
case whip
/// SIP gateway participant.
case sip
/// RTSP stream source.
case rtsp
/// SRT stream source.
case srt
/// Backend value not recognized by this SDK.
case unrecognized(Int)

/// Creates a `ParticipantSource` from the backend enum.
/// Unknown values are preserved as `.unrecognized(_:)`.
init(_ source: Stream_Video_Sfu_Models_ParticipantSource) {
switch source {
case .webrtcUnspecified:
self = .webRTCUnspecified
case .rtmp:
self = .rtmp
case .whip:
self = .whip
case .sip:
self = .sip
case .rtsp:
self = .rtsp
case .srt:
self = .srt
case let .UNRECOGNIZED(value):
self = .unrecognized(value)
}
}

/// Integer that matches the backend enum value.
var rawValue: Int {
switch self {
case .webRTCUnspecified:
return 0
case .rtmp:
return 1
case .whip:
return 2
case .sip:
return 3
case .rtsp:
return 4
case .srt:
return 5
case let .unrecognized(value):
return value
}
}

/// Human-readable case name for logs and debugging.
public var description: String {
switch self {
case .webRTCUnspecified:
return ".webRTCUnspecified"
case .rtmp:
return ".rtmp"
case .whip:
return ".whip"
case .sip:
return ".sip"
case .rtsp:
return ".rtsp"
case .srt:
return ".srt"
case let .unrecognized(value):
return ".unrecognized(\(value))"
}
}
}
26 changes: 21 additions & 5 deletions Sources/StreamVideo/Utils/Sorting/Participants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import Foundation

/// A comparator which sorts participants by whether they are the dominant speaker.
/// A comparator which sorts participants by whether they are the dominant
/// speaker.
nonisolated(unsafe) public let dominantSpeaker: StreamSortComparator<CallParticipant> = { a, b in
if a.isDominantSpeaker && !b.isDominantSpeaker { return .orderedAscending }
if !a.isDominantSpeaker && b.isDominantSpeaker { return .orderedDescending }
Expand Down Expand Up @@ -56,7 +57,8 @@ nonisolated(unsafe) public let pinned: StreamSortComparator<CallParticipant> = {
return .orderedSame
}

/// A comparator creator which sets up a comparator prioritizing participants who have a specific role.
/// A comparator creator which sets up a comparator prioritizing participants
/// who have a specific role.
nonisolated public func roles(_ roles: [String] = ["admin", "host", "speaker"]) -> StreamSortComparator<CallParticipant> {
{ a, b in
if hasAnyRole(a, roles) && !hasAnyRole(b, roles) { return .orderedAscending }
Expand All @@ -75,11 +77,25 @@ nonisolated private func hasAnyRole(_ participant: CallParticipant, _ roles: [St
participant.roles.contains(where: roles.contains)
}

/// Comparator for sorting `CallParticipant` objects based on their `id` property
/// Comparator for sorting `CallParticipant` objects based on their `id`
/// property
nonisolated(unsafe) public var id: StreamSortComparator<CallParticipant> = { comparison($0, $1, keyPath: \.id) }

/// Comparator for sorting `CallParticipant` objects based on their `userId` property
/// Comparator for sorting `CallParticipant` objects based on their `userId`
/// property
nonisolated(unsafe) public var userId: StreamSortComparator<CallParticipant> = { comparison($0, $1, keyPath: \.userId) }

/// Comparator for sorting `CallParticipant` objects based on the date and time (`joinedAt`) they joined the call
/// Comparator for sorting `CallParticipant` objects based on the date and time
/// (`joinedAt`) they joined the call
nonisolated(unsafe) public var joinedAt: StreamSortComparator<CallParticipant> = { comparison($0, $1, keyPath: \.joinedAt) }

/// Comparator that prioritizes participants whose `source` matches the given
/// value.
/// Use this to surface ingest or SIP participants before others.
nonisolated public func participantSource(_ source: ParticipantSource) -> StreamSortComparator<CallParticipant> {
{ a, b in
if a.source == source && b.source != source { return .orderedAscending }
if a.source != source && b.source == source { return .orderedDescending }
return .orderedSame
}
}
1 change: 1 addition & 0 deletions Sources/StreamVideo/Utils/Sorting/Presets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ nonisolated(unsafe) public let livestreamOrAudioRoomSortPreset = [
[
dominantSpeaker,
speaking,
participantSource(.rtmp),
publishingVideo,
publishingAudio
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ struct WebRTCJoinRequestFactory {
publisherSdp: publisherSdp
)
result.capabilities = capabilities
result.source = .webrtcUnspecified

if let reconnectDetails = await buildReconnectDetails(
for: connectionType,
Expand Down
4 changes: 4 additions & 0 deletions StreamVideo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@
40C4DF572C1C61BD0035DBC2 /* URL+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A64B22A9DF86200534ED1 /* URL+Convenience.swift */; };
40C4E8322E60BBCC00FC29BC /* CallKitMissingPermissionPolicy_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C4E8312E60BBCC00FC29BC /* CallKitMissingPermissionPolicy_Tests.swift */; };
40C4E8352E60BC6300FC29BC /* CallKitMissingPermissionPolicy_EndCallTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C4E8342E60BC6300FC29BC /* CallKitMissingPermissionPolicy_EndCallTests.swift */; };
40C4E85F2E69B5C100FC29BC /* ParticipantSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C4E85E2E69B5C100FC29BC /* ParticipantSource.swift */; };
40C689182C64DDC70054528A /* Publisher+TaskSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C689172C64DDC70054528A /* Publisher+TaskSink.swift */; };
40C708D62D8D729500D3501F /* Gleap in Frameworks */ = {isa = PBXBuildFile; productRef = 40C708D52D8D729500D3501F /* Gleap */; };
40C71B5A2E53565300733BF6 /* Store+Mocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C71B592E53565300733BF6 /* Store+Mocks.swift */; };
Expand Down Expand Up @@ -2318,6 +2319,7 @@
40C4DF4F2C1C415F0035DBC2 /* LastParticipantAutoLeavePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastParticipantAutoLeavePolicyTests.swift; sourceTree = "<group>"; };
40C4E8312E60BBCC00FC29BC /* CallKitMissingPermissionPolicy_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallKitMissingPermissionPolicy_Tests.swift; sourceTree = "<group>"; };
40C4E8342E60BC6300FC29BC /* CallKitMissingPermissionPolicy_EndCallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallKitMissingPermissionPolicy_EndCallTests.swift; sourceTree = "<group>"; };
40C4E85E2E69B5C100FC29BC /* ParticipantSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantSource.swift; sourceTree = "<group>"; };
40C689172C64DDC70054528A /* Publisher+TaskSink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+TaskSink.swift"; sourceTree = "<group>"; };
40C689192C64F74F0054528A /* SFUSignalService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFUSignalService.swift; sourceTree = "<group>"; };
40C6891D2C6661990054528A /* SFUEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFUEventAdapter.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6483,6 +6485,7 @@
8456E6D9287EC46D004E180E /* Models */ = {
isa = PBXGroup;
children = (
40C4E85E2E69B5C100FC29BC /* ParticipantSource.swift */,
4019A2792E42475300CE70A4 /* JoinSource.swift */,
403CA9B32CC7BAF0001A88C2 /* VideoCodec.swift */,
4029E94D2CB8160E00E1D571 /* IncomingVideoQualitySettings.swift */,
Expand Down Expand Up @@ -8722,6 +8725,7 @@
82686160290A7556005BFFED /* SystemEnvironment.swift in Sources */,
40E3634E2D09FDE50028C52A /* CameraCaptureHandler.swift in Sources */,
40944CBC2E4CBEED00088AF0 /* StreamCallAudioRecorder+ActiveCallMiddleware.swift in Sources */,
40C4E85F2E69B5C100FC29BC /* ParticipantSource.swift in Sources */,
406583922B877A1600B4F979 /* BackgroundImageFilterProcessor.swift in Sources */,
8490DD23298D5330007E53D2 /* Data+Gzip.swift in Sources */,
84DC38B829ADFCFD00946713 /* UpdateUserPermissionsResponse.swift in Sources */,
Expand Down
6 changes: 4 additions & 2 deletions StreamVideoTests/Mock/CallParticipant_Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ extension CallParticipant {
audioLevel: Float = 0,
audioLevels: [Float] = [],
pin: PinInfo? = nil,
pausedTracks: Set<TrackType> = []
pausedTracks: Set<TrackType> = [],
source: ParticipantSource = .webRTCUnspecified
) -> CallParticipant {
.init(
id: id,
Expand All @@ -54,7 +55,8 @@ extension CallParticipant {
audioLevel: audioLevel,
audioLevels: audioLevels,
pin: pin,
pausedTracks: pausedTracks
pausedTracks: pausedTracks,
source: source
)
}
}
41 changes: 41 additions & 0 deletions StreamVideoTests/Utils/Sorting_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,47 @@ final class Sorting_Tests: XCTestCase, @unchecked Sendable {
)
}

// MARK: - participantSource

func test_participantSource_rtmpHasPriority() {
let subject = participantSource(.rtmp)

assertSort(
[
.dummy(source: .rtmp),
.dummy(source: .webRTCUnspecified)
],
comparator: subject,
expectedTransformer: { [$0[0], $0[1]] } // "rtmp" has priority over "webRTCUnspecified".
)
}

func test_participantSource_srtHasPriority() {
let subject = participantSource(.srt)

assertSort(
[
.dummy(source: .rtmp),
.dummy(source: .srt)
],
comparator: subject,
expectedTransformer: { [$0[1], $0[0]] } // "srt" has priority over "rtmp".
)
}

func test_participantSource_sameSource() {
let subject = participantSource(.rtmp)

assertSort(
[
.dummy(source: .rtmp),
.dummy(source: .rtmp)
],
comparator: subject,
expectedTransformer: { [$0[0], $0[1]] }
)
}

// MARK: - Private Helpers

private func assertSort(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ final class WebRTCJoinRequestFactory_Tests: XCTestCase, @unchecked Sendable {
XCTAssertEqual(result.subscriberSdp, subscriberSdp)
XCTAssertFalse(result.fastReconnect)
XCTAssertEqual(result.token, token)
XCTAssertEqual(result.source, .webrtcUnspecified)
XCTAssertEqual(result.reconnectDetails.announcedTracks, [])
XCTAssertEqual(result.reconnectDetails.strategy, .unspecified)
XCTAssertEqual(result.reconnectDetails.reconnectAttempt, 0)
Expand Down Expand Up @@ -98,6 +99,7 @@ final class WebRTCJoinRequestFactory_Tests: XCTestCase, @unchecked Sendable {
XCTAssertEqual(result.subscriberSdp, subscriberSdp)
XCTAssertTrue(result.fastReconnect)
XCTAssertEqual(result.token, token)
XCTAssertEqual(result.source, .webrtcUnspecified)
XCTAssertEqual(result.reconnectDetails.announcedTracks, [])
XCTAssertEqual(result.reconnectDetails.strategy, .fast)
XCTAssertEqual(result.reconnectDetails.reconnectAttempt, 12)
Expand Down Expand Up @@ -137,6 +139,7 @@ final class WebRTCJoinRequestFactory_Tests: XCTestCase, @unchecked Sendable {
XCTAssertEqual(result.subscriberSdp, subscriberSdp)
XCTAssertFalse(result.fastReconnect)
XCTAssertEqual(result.token, token)
XCTAssertEqual(result.source, .webrtcUnspecified)
XCTAssertEqual(result.reconnectDetails.announcedTracks, [])
XCTAssertEqual(result.reconnectDetails.strategy, .migrate)
XCTAssertEqual(result.reconnectDetails.reconnectAttempt, 12)
Expand Down Expand Up @@ -176,6 +179,7 @@ final class WebRTCJoinRequestFactory_Tests: XCTestCase, @unchecked Sendable {
XCTAssertEqual(result.subscriberSdp, subscriberSdp)
XCTAssertFalse(result.fastReconnect)
XCTAssertEqual(result.token, token)
XCTAssertEqual(result.source, .webrtcUnspecified)
XCTAssertEqual(result.reconnectDetails.announcedTracks, [])
XCTAssertEqual(result.reconnectDetails.strategy, .rejoin)
XCTAssertEqual(result.reconnectDetails.reconnectAttempt, 12)
Expand Down