From 5a0bc115649012acca37e1414d69e3d40a63fa70 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Tue, 12 Nov 2024 13:59:16 +0200 Subject: [PATCH 1/4] [Enhancement]Demo app livestream support --- .../Sources/Components/AppEnvironment.swift | 17 +++ DemoApp/Sources/DemoApp.swift | 7 +- .../CallModifier/DemoCallModifier.swift | 32 ++++- .../Views/CallTopView/DemoCallTopView.swift | 47 ++++++++ .../CallingView/DemoCallingViewModifier.swift | 19 ++- .../CallingView/SimpleCallingView.swift | 112 +++++++++++++----- .../CallView/DemoCallContainerView.swift | 6 +- DemoApp/Sources/Views/Login/DebugMenu.swift | 56 +++++++++ Sources/StreamVideo/CallState.swift | 82 +++++++------ Sources/StreamVideo/Utils/Sorting.swift | 22 ++-- .../StreamVideoSwiftUI/CallViewModel.swift | 3 + .../Livestreaming/LivestreamPlayer.swift | 12 +- 12 files changed, 323 insertions(+), 92 deletions(-) diff --git a/DemoApp/Sources/Components/AppEnvironment.swift b/DemoApp/Sources/Components/AppEnvironment.swift index 627b49485..3b0329590 100644 --- a/DemoApp/Sources/Components/AppEnvironment.swift +++ b/DemoApp/Sources/Components/AppEnvironment.swift @@ -513,3 +513,20 @@ extension AppEnvironment { static var preferredVideoCodec: PreferredVideoCodec = .h264 } + +extension AppEnvironment { + + static var availableCallTypes: [String] = [ + .development, + .default, + .audioRoom, + .livestream + ] + static var preferredCallType: String? +} + +extension String: Debuggable { + var title: String { + self + } +} diff --git a/DemoApp/Sources/DemoApp.swift b/DemoApp/Sources/DemoApp.swift index a1c730b4d..159d29ca4 100644 --- a/DemoApp/Sources/DemoApp.swift +++ b/DemoApp/Sources/DemoApp.swift @@ -33,8 +33,11 @@ struct DemoApp: App { ZStack { if userState == .loggedIn { NavigationView { - DemoCallContainerView(callId: router.appState.deeplinkInfo.callId) - .navigationBarHidden(true) + DemoCallContainerView( + callId: router.appState.deeplinkInfo.callId, + callType: router.appState.deeplinkInfo.callType + ) + .navigationBarHidden(true) } .navigationViewStyle(.stack) } else { diff --git a/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift b/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift index e7cf7543a..50624a5af 100644 --- a/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift +++ b/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift @@ -26,11 +26,31 @@ struct DemoCallModifier: ViewModifier { } func body(content: Content) -> some View { - VideoViewOverlay( - rootView: content, - viewFactory: viewFactory, - viewModel: viewModel - ) - .modifier(ThermalStateViewModifier()) + contentView(content) + } + + @ViewBuilder + private func contentView(_ rootView: Content) -> some View { + if + let call = viewModel.call, + call.callType == .livestream, + (call.currentUserHasCapability(.sendAudio) == false) || (call.currentUserHasCapability(.sendVideo) == false) { + ZStack { + rootView + LivestreamPlayer( + type: call.callType, + id: call.callId, + handleParticipationWithLifecycle: false, + onFullScreenStateChange: { [weak viewModel] in viewModel?.hideUIElements = $0 } + ) + } + } else { + VideoViewOverlay( + rootView: rootView, + viewFactory: viewFactory, + viewModel: viewModel + ) + .modifier(ThermalStateViewModifier()) + } } } diff --git a/DemoApp/Sources/Views/CallTopView/DemoCallTopView.swift b/DemoApp/Sources/Views/CallTopView/DemoCallTopView.swift index 8d48a7bc0..45931e03d 100644 --- a/DemoApp/Sources/Views/CallTopView/DemoCallTopView.swift +++ b/DemoApp/Sources/Views/CallTopView/DemoCallTopView.swift @@ -42,6 +42,7 @@ struct DemoCallTopView: View { HStack { Spacer() + livestreamControlsView HangUpIconView(viewModel: viewModel) } .frame(maxWidth: .infinity) @@ -64,6 +65,52 @@ struct DemoCallTopView: View { viewModel.call?.state.screenSharingSession != nil && viewModel.call?.state.isCurrentUserScreensharing == false } + + @ViewBuilder + private var livestreamControlsView: some View { + if let call = viewModel.call, call.callType == .livestream, call.currentUserHasCapability(.startBroadcastCall) { + Menu { + Button { + Task { + do { + if call.state.backstage { + try await call.goLive() + } else { + try await call.stopLive() + } + } catch { + log.error(error) + } + } + } label: { + if call.state.backstage { + Label { + Text("Start Live") + } icon: { + Image(systemName: "play.fill") + .foregroundColor(colors.accentGreen) + } + } else { + Label { + Text("Stop Live") + } icon: { + Image(systemName: "stop.fill") + .foregroundColor(colors.accentRed) + } + } + } + + } label: { + CallIconView( + icon: Image(systemName: "gear"), + size: 44, + iconStyle: .transparent + ) + } + } else { + EmptyView() + } + } } struct SharingIndicator: View { diff --git a/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift b/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift index 092a4122d..f2802fea5 100644 --- a/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift +++ b/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift @@ -20,6 +20,12 @@ struct DemoCallingViewModifier: ViewModifier { private var isAnonymous: Bool { appState.currentUser == .anonymous } + private var callType: String { + !AppState.shared.deeplinkInfo.callType.isEmpty + ? AppState.shared.deeplinkInfo.callType + : AppEnvironment.preferredCallType ?? .default + } + init( text: Binding, viewModel: CallViewModel @@ -41,13 +47,14 @@ struct DemoCallingViewModifier: ViewModifier { // We may get in this situation when launching the app from a // deeplink. + if deeplinkInfo.callId.isEmpty { - joinCallIfNeeded(with: self.text.wrappedValue) + joinCallIfNeeded(with: self.text.wrappedValue, callType: callType) } else { self.text.wrappedValue = deeplinkInfo.callId joinCallIfNeeded( with: self.text.wrappedValue, - callType: deeplinkInfo.callType + callType: callType ) } } @@ -68,7 +75,7 @@ struct DemoCallingViewModifier: ViewModifier { guard !isAnonymous else { return } callKitAdapter.registerForIncomingCalls() callKitAdapter.iconTemplateImageData = UIImage(named: "logo")?.pngData() - joinCallIfNeeded(with: text.wrappedValue) + joinCallIfNeeded(with: text.wrappedValue, callType: callType) } .onReceive(appState.$activeCall) { call in viewModel.setActiveCall(call) @@ -82,7 +89,7 @@ struct DemoCallingViewModifier: ViewModifier { .toastView(toast: $viewModel.toast) } - private func joinCallIfNeeded(with callId: String, callType: String = .default) { + private func joinCallIfNeeded(with callId: String, callType: String) { guard !callId.isEmpty, viewModel.callingState == .idle else { return } @@ -95,11 +102,15 @@ struct DemoCallingViewModifier: ViewModifier { preferredVideoCodec: AppEnvironment.preferredVideoCodec.videoCodec ) _ = await Task { @MainActor in + viewModel.update( + participantsSortComparators: callType == .livestream ? livestreamComparators : defaultComparators + ) viewModel.joinCall(callType: callType, callId: callId) }.result } catch { log.error(error) } + AppState.shared.deeplinkInfo = .empty } } } diff --git a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift index f7b51e5d1..a5eabddbe 100644 --- a/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift +++ b/DemoApp/Sources/Views/CallView/CallingView/SimpleCallingView.swift @@ -9,10 +9,13 @@ import SwiftUI struct SimpleCallingView: View { + private enum CallAction { case lobby, join, start(callId: String) } + @Injected(\.streamVideo) var streamVideo @Injected(\.appearance) var appearance @State var text = "" + @State private var callType: String @State private var changeEnvironmentPromptForURL: URL? @State private var showChangeEnvironmentPrompt: Bool = false @@ -22,6 +25,16 @@ struct SimpleCallingView: View { init(viewModel: CallViewModel, callId: String) { self.viewModel = viewModel text = callId + callType = { + guard + !AppState.shared.deeplinkInfo.callId.isEmpty, + !AppState.shared.deeplinkInfo.callType.isEmpty + else { + return AppEnvironment.preferredCallType ?? .default + } + + return AppState.shared.deeplinkInfo.callType + }() } var body: some View { @@ -46,7 +59,7 @@ struct SimpleCallingView: View { .padding() HStack { - Text("Call ID number") + Text("\(callTypeTitle) ID number") .font(.caption) .foregroundColor(.init(appearance.colors.textLowEmphasis)) Spacer() @@ -54,7 +67,7 @@ struct SimpleCallingView: View { HStack { HStack { - TextField("Call ID", text: $text) + TextField("\(callTypeTitle) ID", text: $text) .foregroundColor(appearance.colors.text) .padding(.all, 12) .disabled(isAnonymous) @@ -81,53 +94,38 @@ struct SimpleCallingView: View { Button { resignFirstResponder() Task { - await setPreferredVideoCodec(for: text) - viewModel.enterLobby( - callType: .default, - callId: text, - members: [] + await performCallAction( + callType != .livestream ? .lobby : .join ) } } label: { CallButtonView( - title: "Join Call", + title: "Join \(callTypeTitle)", maxWidth: 120, isDisabled: appState.loading || text.isEmpty ) .disabled(appState.loading || text.isEmpty) + .minimumScaleFactor(0.7) + .lineLimit(1) } .disabled(appState.loading || text.isEmpty) } if canStartCall { HStack { - Text("Don't have a Call ID?") + Text("Don't have a \(callTypeTitle) ID?") .font(.caption) - .foregroundColor( - .init( - appearance.colors.textLowEmphasis - ) - ) + .foregroundColor(.init(appearance.colors.textLowEmphasis)) Spacer() } .padding(.top) Button { resignFirstResponder() - Task { - let callId = String.unique - await setPreferredVideoCodec(for: callId) - viewModel.startCall( - callType: .default, - callId: callId, - members: [], - ring: false, - maxDuration: AppEnvironment.callExpiration.duration - ) - } + Task { await performCallAction(.start(callId: .unique)) } } label: { CallButtonView( - title: "Start New Call", + title: "Start New \(callTypeTitle)", isDisabled: appState.loading ) .disabled(appState.loading) @@ -144,6 +142,7 @@ struct SimpleCallingView: View { viewModel: viewModel ) ) + .onChange(of: text) { parseURLIfRequired($0) } } private var isAnonymous: Bool { appState.currentUser == .anonymous } @@ -158,6 +157,12 @@ struct SimpleCallingView: View { } if deeplinkInfo.baseURL == AppEnvironment.baseURL { + if !Set(AppEnvironment.availableCallTypes).contains(deeplinkInfo.callType) { + AppEnvironment.availableCallTypes.append(deeplinkInfo.callType) + } + AppEnvironment.preferredCallType = deeplinkInfo.callType + + callType = deeplinkInfo.callType text = deeplinkInfo.callId } else if let url = deeplinkInfo.url { changeEnvironmentPromptForURL = url @@ -170,11 +175,64 @@ struct SimpleCallingView: View { } private func setPreferredVideoCodec(for callId: String) async { - let call = streamVideo.call(callType: .default, callId: callId) + let call = streamVideo.call(callType: callType, callId: callId) await call.updatePublishOptions( preferredVideoCodec: AppEnvironment.preferredVideoCodec.videoCodec ) } + + private func parseURLIfRequired(_ text: String) { + let adapter = DeeplinkAdapter() + guard + let url = URL(string: text), + adapter.canHandle(url: url) + else { + return + } + + let deeplinkInfo = adapter.handle(url: url).deeplinkInfo + guard !deeplinkInfo.callId.isEmpty else { return } + + handleDeeplink(deeplinkInfo) + } + + private var callTypeTitle: String { + switch callType { + case .livestream: + return "Livestream" + case .audioRoom: + return "AudioRoom" + default: + return "Call" + } + } + + private func performCallAction(_ action: CallAction) async { + viewModel.update( + participantsSortComparators: callType == .livestream ? livestreamComparators : defaultComparators + ) + switch action { + case .lobby: + await setPreferredVideoCodec(for: text) + viewModel.enterLobby( + callType: callType, + callId: text, + members: [] + ) + case .join: + await setPreferredVideoCodec(for: text) + viewModel.joinCall(callType: callType, callId: text) + case let .start(callId): + await setPreferredVideoCodec(for: callId) + viewModel.startCall( + callType: callType, + callId: callId, + members: [], + ring: false, + maxDuration: AppEnvironment.callExpiration.duration + ) + } + } } extension URL: Identifiable { diff --git a/DemoApp/Sources/Views/CallView/DemoCallContainerView.swift b/DemoApp/Sources/Views/CallView/DemoCallContainerView.swift index 9dd3fcaa5..00c31813a 100644 --- a/DemoApp/Sources/Views/CallView/DemoCallContainerView.swift +++ b/DemoApp/Sources/Views/CallView/DemoCallContainerView.swift @@ -10,19 +10,21 @@ import SwiftUI internal struct DemoCallContainerView: View { private var callId: String + private var callType: String @Injected(\.streamVideo) var streamVideo @Injected(\.appearance) var appearance @StateObject var viewModel: CallViewModel @StateObject var chatViewModel: DemoChatViewModel @ObservedObject var appState = AppState.shared - internal init(callId: String) { + internal init(callId: String, callType: String = .default) { let callViewModel = CallViewModel() callViewModel.participantAutoLeavePolicy = AppEnvironment.autoLeavePolicy.policy callViewModel.isPictureInPictureEnabled = AppEnvironment.pictureInPictureIntegration == .enabled _viewModel = StateObject(wrappedValue: callViewModel) _chatViewModel = StateObject(wrappedValue: .init(callViewModel)) self.callId = callId + self.callType = callType } internal var body: some View { @@ -52,7 +54,7 @@ internal struct DemoCallContainerView: View { let contact = callIntent.contacts?.first guard let name = contact?.personHandle?.value else { return } - viewModel.startCall(callType: .default, callId: .unique, members: [.init(user: .init(id: name))], ring: true) + viewModel.startCall(callType: callType, callId: .unique, members: [.init(user: .init(id: name))], ring: true) } } } diff --git a/DemoApp/Sources/Views/Login/DebugMenu.swift b/DemoApp/Sources/Views/Login/DebugMenu.swift index 291360bef..4062b1043 100644 --- a/DemoApp/Sources/Views/Login/DebugMenu.swift +++ b/DemoApp/Sources/Views/Login/DebugMenu.swift @@ -101,6 +101,16 @@ struct DebugMenu: View { didSet { AppEnvironment.preferredVideoCodec = preferredVideoCodec } } + @State private var customPreferredCallType: String = "" + @State private var presentsCustomPreferredCallType = false + @State private var preferredCallType = AppEnvironment.preferredCallType { + didSet { AppEnvironment.preferredCallType = preferredCallType?.isEmpty == true ? nil : preferredCallType } + } + + @State private var availableCallTypes: [String] = AppEnvironment.availableCallTypes { + didSet { AppEnvironment.availableCallTypes = availableCallTypes } + } + var body: some View { Menu { makeMenu( @@ -167,6 +177,28 @@ struct DebugMenu: View { label: "Disconnection Timeout" ) { self.disconnectionTimeout = $0 } + makeMenu( + for: availableCallTypes, + currentValue: preferredCallType ?? "", + additionalItems: { + Divider() + customPreferredCallTypeView + if preferredCallType != nil { + Divider() + Button { + self.preferredCallType = nil + } label: { + Label { + Text("Clear") + } icon: { + Image(systemName: "xmark") + } + } + } + }, + label: "Preferred CallType" + ) { self.preferredCallType = $0 } + makeMenu( for: [.h264, .vp8, .vp9, .av1], currentValue: preferredVideoCodec, @@ -245,6 +277,17 @@ struct DebugMenu: View { transformer: { TimeInterval($0) ?? 0 }, action: { self.disconnectionTimeout = .custom(customDisconnectionTimeoutValue) } ) + .alertWithTextField( + title: "Enter call type", + placeholder: "Call Type", + presentationBinding: $presentsCustomPreferredCallType, + valueBinding: $customPreferredCallType, + transformer: { $0 }, + action: { + self.availableCallTypes.append(customPreferredCallType) + self.preferredCallType = customPreferredCallType + } + ) } @ViewBuilder @@ -347,6 +390,19 @@ struct DebugMenu: View { } } + @ViewBuilder + private var customPreferredCallTypeView: some View { + Button { + presentsCustomPreferredCallType = true + } label: { + Label { + Text("Add") + } icon: { + Image(systemName: "plus") + } + } + } + @ViewBuilder private func makeMenu( for items: [Item], diff --git a/Sources/StreamVideo/CallState.swift b/Sources/StreamVideo/CallState.swift index 79fdf7a6d..01da6f3c1 100644 --- a/Sources/StreamVideo/CallState.swift +++ b/Sources/StreamVideo/CallState.swift @@ -23,7 +23,7 @@ public struct PermissionRequest: @unchecked Sendable, Identifiable { self.requestedAt = requestedAt self.onReject = onReject } - + public func reject() { onReject(self) } @@ -33,7 +33,7 @@ public struct PermissionRequest: @unchecked Sendable, Identifiable { public class CallState: ObservableObject { @Injected(\.streamVideo) var streamVideo - + /// The id of the current session. /// When a call is started, a unique session identifier is assigned to the user in the call. @Published public internal(set) var sessionId: String = "" @@ -41,7 +41,7 @@ public class CallState: ObservableObject { @Published public internal(set) var participantsMap = [String: CallParticipant]() { didSet { didUpdate(Array(participantsMap.values)) } } - + @Published public internal(set) var localParticipant: CallParticipant? @Published public internal(set) var dominantSpeaker: CallParticipant? @Published public internal(set) var remoteParticipants: [CallParticipant] = [] @@ -61,7 +61,7 @@ public class CallState: ObservableObject { } } } - + @Published public internal(set) var recordingState: RecordingState = .noRecording @Published public internal(set) var blockedUserIds: Set = [] @Published public internal(set) var settings: CallSettingsResponse? @@ -77,7 +77,7 @@ public class CallState: ObservableObject { Updating ownCapabilities: From: \(oldValue.map(\.rawValue)) - + To: \(ownCapabilities.map(\.rawValue)) """, @@ -85,14 +85,14 @@ public class CallState: ObservableObject { ) } } - + @Published public internal(set) var capabilitiesByRole: [String: [String]] = [:] @Published public internal(set) var backstage: Bool = false @Published public internal(set) var broadcasting: Bool = false @Published public internal(set) var createdAt: Date = .distantPast { didSet { if !isInitialized { isInitialized = true }} } - + @Published public internal(set) var updatedAt: Date = .distantPast @Published public internal(set) var startsAt: Date? @Published public internal(set) var startedAt: Date? { @@ -100,7 +100,7 @@ public class CallState: ObservableObject { setupDurationTimer() } } - + @Published public internal(set) var endedAt: Date? @Published public internal(set) var endedBy: User? @Published public internal(set) var custom: [String: RawJSON] = [:] @@ -115,7 +115,7 @@ public class CallState: ObservableObject { didUpdate(session) } } - + @Published public internal(set) var reconnectionStatus = ReconnectionStatus.connected @Published public internal(set) var anonymousParticipantCount: UInt32 = 0 @Published public internal(set) var participantCount: UInt32 = 0 @@ -131,7 +131,7 @@ public class CallState: ObservableObject { /// session. This enum supports different policies like none, manual, or /// disabled, each potentially applying to specific session IDs. @Published public internal(set) var incomingVideoQualitySettings: IncomingVideoQualitySettings = .none - + /// This property holds the error that indicates the user has been disconnected /// due to a network-related issue. When the user’s connection is disrupted for longer than the specified /// timeout, this error will be set with a relevant error type, such as @@ -140,12 +140,18 @@ public class CallState: ObservableObject { /// - SeeAlso: ``ClientError.NetworkNotAvailable`` for the type of error set when a /// disconnection due to network issues occurs. @Published public internal(set) var disconnectionError: Error? - - var sortComparators = defaultComparators + + var sortComparators = defaultComparators { + didSet { + Task { @MainActor in + didUpdate(participants) + } + } + } private var localCallSettingsUpdate = false private var durationTimer: Foundation.Timer? - + internal func updateState(from event: VideoEvent) { switch event { case let .typeBlockedUserEvent(event): @@ -264,7 +270,7 @@ public class CallState: ObservableObject { break } } - + internal func addPermissionRequest(user: User, permissions: [String], requestedAt: Date) { let requests = permissions.map { PermissionRequest( @@ -276,23 +282,23 @@ public class CallState: ObservableObject { } permissionRequests.append(contentsOf: requests) } - + internal func removePermissionRequest(request: PermissionRequest) { permissionRequests = permissionRequests.filter { $0.id != request.id } } - + internal func blockUser(id: String) { if !blockedUserIds.contains(id) { blockedUserIds.insert(id) } } - + internal func unblockUser(id: String) { blockedUserIds.remove(id) } - + internal func mergeMembers(_ response: [MemberResponse]) { var current = members var changed = false @@ -314,48 +320,48 @@ public class CallState: ObservableObject { members = current } } - + internal func update(from response: GetOrCreateCallResponse) { update(from: response.call) mergeMembers(response.members) ownCapabilities = response.ownCapabilities } - + internal func update(from response: JoinCallResponse) { update(from: response.call) mergeMembers(response.members) ownCapabilities = response.ownCapabilities statsCollectionInterval = response.statsOptions.reportingIntervalMs / 1000 } - + internal func update(from response: GetCallResponse) { update(from: response.call) mergeMembers(response.members) ownCapabilities = response.ownCapabilities } - + internal func update(from response: CallStateResponseFields) { update(from: response.call) mergeMembers(response.members) ownCapabilities = response.ownCapabilities } - + internal func update(from response: UpdateCallResponse) { update(from: response.call) mergeMembers(response.members) ownCapabilities = response.ownCapabilities } - + internal func update(from event: CallCreatedEvent) { update(from: event.call) mergeMembers(event.members) } - + internal func update(from event: CallRingEvent) { update(from: event.call) mergeMembers(event.members) } - + internal func update(from response: CallResponse) { custom = response.custom createdAt = response.createdAt @@ -371,13 +377,13 @@ public class CallState: ObservableObject { session = response.session settings = response.settings egress = response.egress - + let rtmp = RTMP( address: response.ingress.rtmp.address, streamKey: streamVideo.token.rawValue ) ingress = Ingress(rtmp: rtmp) - + if !localCallSettingsUpdate { callSettings = response.settings.toCallSettings } @@ -400,26 +406,26 @@ public class CallState: ObservableObject { } ownCapabilities = event.ownCapabilities } - + private func didUpdate(_ newParticipants: [CallParticipant]) { // Combine existing and newly added participants. let currentParticipantIds = Set(participants.map(\.id)) let newlyAddedParticipants = Set(newParticipants.map(\.id)) .subtracting(currentParticipantIds) .compactMap { participantsMap[$0] } - + // Sort the updated participants. let updatedCurrentParticipants: [CallParticipant] = ( participants .compactMap { participantsMap[$0.id] } + newlyAddedParticipants ) .sorted(by: sortComparators) - + // Variables to hold segregated participants. var remoteParticipants: [CallParticipant] = [] var activeSpeakers: [CallParticipant] = [] var screenSharingSession: ScreenSharingSession? - + // Segregate participants based on conditions. for participant in updatedCurrentParticipants { // Check if participant is local or remote. @@ -428,30 +434,30 @@ public class CallState: ObservableObject { } else { remoteParticipants.append(participant) } - + // Check if participant is speaking. if participant.isSpeaking { activeSpeakers.append(participant) } - + // Check if participant is a dominant speaker. if participant.isDominantSpeaker { dominantSpeaker = participant } - + // Check if participant is sharing their screen. if let screenshareTrack = participant.screenshareTrack, participant.isScreensharing { screenSharingSession = .init(track: screenshareTrack, participant: participant) } } - + // Update the respective class properties. participants = updatedCurrentParticipants self.screenSharingSession = screenSharingSession self.remoteParticipants = remoteParticipants self.activeSpeakers = activeSpeakers } - + private func didUpdate(_ egress: EgressResponse?) { broadcasting = egress?.broadcasting ?? false } @@ -466,7 +472,7 @@ public class CallState: ObservableObject { /// If we don't receive a value from the SFU we start the timer on the current date. startedAt = Date() } - + if session.liveEndedAt != nil { resetTimer() } diff --git a/Sources/StreamVideo/Utils/Sorting.swift b/Sources/StreamVideo/Utils/Sorting.swift index c022f6191..797644087 100644 --- a/Sources/StreamVideo/Utils/Sorting.swift +++ b/Sources/StreamVideo/Utils/Sorting.swift @@ -34,13 +34,13 @@ public let defaultComparators: [StreamSortComparator] = [ /// - `ifInvisible(publishingAudio)`: Sorts participants based on their audio status, but only if they are not visible. /// - `roles()`: Sorts participants based on their assigned roles. public let livestreamComparators: [StreamSortComparator] = [ - ifInvisible(dominantSpeaker), - ifInvisible(isSpeaking), - ifInvisible(publishingVideo), - ifInvisible(publishingAudio), - roles(), - joinedAt, - userId + combineComparators([ + dominantSpeaker, + isSpeaking, + publishingVideo, + publishingAudio + ]), + roles() ] // MARK: - Sort Sequence @@ -49,7 +49,7 @@ public let livestreamComparators: [StreamSortComparator] = [ public enum StreamSortOrder { case ascending, descending } extension Sequence where Element == CallParticipant { - + /// Sorts the sequence's elements using a specified comparator and order. /// /// - Parameters: @@ -69,7 +69,7 @@ extension Sequence where Element == CallParticipant { } } } - + /// Sorts the sequence's elements using multiple comparators and a specified order. /// The comparators are applied in the order they are provided. /// @@ -100,7 +100,7 @@ func comparison( ) -> ComparisonResult { let lhsValue = lhs[keyPath: keyPath] let rhsValue = rhs[keyPath: keyPath] - + if lhsValue < rhsValue { return .orderedAscending } else if lhsValue > rhsValue { @@ -126,7 +126,7 @@ func comparison( ) -> ComparisonResult { let lhsValue = lhs[keyPath: keyPath] let rhsValue = rhs[keyPath: keyPath] - + switch (lhsValue, rhsValue) { case (true, false): return .orderedAscending diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index e134dc48a..ef8ac7c47 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -616,6 +616,9 @@ open class CallViewModel: ObservableObject { startsAt: startsAt ) let settings = localCallSettingsChange ? callSettings : nil + + call.updateParticipantsSorting(with: participantsSortComparators) + try await call.join( create: true, options: options, diff --git a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift index 2e45de962..ffbf24ff7 100644 --- a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift +++ b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift @@ -9,17 +9,19 @@ import SwiftUI public struct LivestreamPlayer: View { @Injected(\.colors) var colors - + + var handleParticipationWithLifecycle: Bool var onFullScreenStateChange: ((Bool) -> Void)? @StateObject var state: CallState @StateObject var viewModel: LivestreamPlayerViewModel - + public init( type: String, id: String, muted: Bool = false, showParticipantCount: Bool = true, + handleParticipationWithLifecycle: Bool = true, onFullScreenStateChange: ((Bool) -> Void)? = nil ) { let viewModel = LivestreamPlayerViewModel( @@ -30,6 +32,7 @@ public struct LivestreamPlayer: View { ) _viewModel = StateObject(wrappedValue: viewModel) _state = StateObject(wrappedValue: viewModel.call.state) + self.handleParticipationWithLifecycle = handleParticipationWithLifecycle self.onFullScreenStateChange = onFullScreenStateChange } @@ -87,6 +90,9 @@ public struct LivestreamPlayer: View { LivestreamButton(imageName: "viewfinder") { viewModel.update(fullScreen: !viewModel.fullScreen) } + LivestreamButton(imageName: "phone.down.fill") { + viewModel.leaveLivestream() + } } .padding() .background(colors.livestreamBackground.edgesIgnoringSafeArea(.all)) @@ -110,9 +116,11 @@ public struct LivestreamPlayer: View { } }) .onAppear { + guard handleParticipationWithLifecycle else { return } viewModel.joinLivestream() } .onDisappear { + guard handleParticipationWithLifecycle else { return } viewModel.leaveLivestream() } } From 2696168bba0fe810b8a72645e145e8cc426582d7 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Wed, 13 Nov 2024 15:54:23 +0200 Subject: [PATCH 2/4] Fix snapshot tests --- .../ViewModifiers/CallModifier/DemoCallModifier.swift | 2 ++ .../Livestreaming/LivestreamPlayer.swift | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift b/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift index 50624a5af..a362f6c05 100644 --- a/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift +++ b/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift @@ -29,6 +29,7 @@ struct DemoCallModifier: ViewModifier { contentView(content) } + @MainActor @ViewBuilder private func contentView(_ rootView: Content) -> some View { if @@ -41,6 +42,7 @@ struct DemoCallModifier: ViewModifier { type: call.callType, id: call.callId, handleParticipationWithLifecycle: false, + showsLeaveCallButton: true, onFullScreenStateChange: { [weak viewModel] in viewModel?.hideUIElements = $0 } ) } diff --git a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift index ffbf24ff7..2b8ecddc4 100644 --- a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift +++ b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift @@ -11,6 +11,7 @@ public struct LivestreamPlayer: View { @Injected(\.colors) var colors var handleParticipationWithLifecycle: Bool + var showsLeaveCallButton: Bool var onFullScreenStateChange: ((Bool) -> Void)? @StateObject var state: CallState @@ -22,6 +23,7 @@ public struct LivestreamPlayer: View { muted: Bool = false, showParticipantCount: Bool = true, handleParticipationWithLifecycle: Bool = true, + showsLeaveCallButton: Bool = false, onFullScreenStateChange: ((Bool) -> Void)? = nil ) { let viewModel = LivestreamPlayerViewModel( @@ -33,6 +35,7 @@ public struct LivestreamPlayer: View { _viewModel = StateObject(wrappedValue: viewModel) _state = StateObject(wrappedValue: viewModel.call.state) self.handleParticipationWithLifecycle = handleParticipationWithLifecycle + self.showsLeaveCallButton = showsLeaveCallButton self.onFullScreenStateChange = onFullScreenStateChange } @@ -90,8 +93,10 @@ public struct LivestreamPlayer: View { LivestreamButton(imageName: "viewfinder") { viewModel.update(fullScreen: !viewModel.fullScreen) } - LivestreamButton(imageName: "phone.down.fill") { - viewModel.leaveLivestream() + if showsLeaveCallButton { + LivestreamButton(imageName: "phone.down.fill") { + viewModel.leaveLivestream() + } } } .padding() From 283ebbf82dac201362dbb22c75f99d8fd9b07a94 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Wed, 20 Nov 2024 13:01:12 +0200 Subject: [PATCH 3/4] Address feedback --- Sources/StreamVideo/Utils/Sorting.swift | 24 ++--- .../Livestreaming/LivestreamPlayer.swift | 89 +++++++++++++++---- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/Sources/StreamVideo/Utils/Sorting.swift b/Sources/StreamVideo/Utils/Sorting.swift index 797644087..366b70788 100644 --- a/Sources/StreamVideo/Utils/Sorting.swift +++ b/Sources/StreamVideo/Utils/Sorting.swift @@ -28,18 +28,20 @@ public let defaultComparators: [StreamSortComparator] = [ ] /// The set of comparators used for sorting `CallParticipant` objects during livestreams. -/// - `ifInvisible(dominantSpeaker)`: Sorts participants based on whether they are the dominant speaker or not, but only if they are not visible. -/// - `ifInvisible(isSpeaking)`: Sorts participants based on whether they are speaking, but only if they are not visible. -/// - `ifInvisible(publishingVideo)`: Sorts participants based on their video status, but only if they are not visible. -/// - `ifInvisible(publishingAudio)`: Sorts participants based on their audio status, but only if they are not visible. -/// - `roles()`: Sorts participants based on their assigned roles. +/// - `dominantSpeaker`: Sorts participants based on whether they are the dominant speaker or not, but only if they are not visible. +/// - `isSpeaking`: Sorts participants based on whether they are speaking, but only if they are not visible. +/// - `publishingVideo`: Sorts participants based on their video status, but only if they are not visible. +/// - `publishingAudio`: Sorts participants based on their audio status, but only if they are not visible. +/// - `roles`: Sorts participants based on their assigned roles. public let livestreamComparators: [StreamSortComparator] = [ - combineComparators([ - dominantSpeaker, - isSpeaking, - publishingVideo, - publishingAudio - ]), + ifInvisible( + combineComparators([ + dominantSpeaker, + isSpeaking, + publishingVideo, + publishingAudio + ]) + ), roles() ] diff --git a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift index 2b8ecddc4..94da970f8 100644 --- a/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift +++ b/Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift @@ -5,24 +5,58 @@ import StreamVideo import SwiftUI +/// A SwiftUI view that renders a livestream video player for managing and +/// displaying a livestream. +/// +/// `LivestreamPlayer` provides features such as participant video rendering, +/// audio output toggling, fullscreen mode, and participant count display. +/// The view reacts dynamically to the state of the associated call and allows +/// customisation of its behaviour through policies and callback actions. @available(iOS 14.0, *) public struct LivestreamPlayer: View { - + + /// Determines the join behavior for the livestream. + public enum JoinPolicy { + /// No automatic action; users must manually join the livestream. + case none + /// Automatically joins the livestream on appearance and leave on disappearance. + case auto + } + + /// Accesses the color palette from the app's dependency injection. @Injected(\.colors) var colors - var handleParticipationWithLifecycle: Bool + /// The policy that defines how users join the livestream. + var joinPolicy: JoinPolicy + + /// Indicates whether a button to leave the livestream is shown. var showsLeaveCallButton: Bool + + /// A callback triggered when the fullscreen state changes. var onFullScreenStateChange: ((Bool) -> Void)? - + + /// The state object representing the call's current state. @StateObject var state: CallState + + /// The view model managing the livestream's behavior and state. @StateObject var viewModel: LivestreamPlayerViewModel + /// Initializes a `LivestreamPlayer` with the specified parameters. + /// + /// - Parameters: + /// - type: The type of the livestream (e.g., meeting or webinar). + /// - id: The unique identifier for the livestream. + /// - muted: Indicates whether the livestream starts muted. Defaults to `false`. + /// - showParticipantCount: Whether to show the count of participants. Defaults to `true`. + /// - joinPolicy: The policy dictating how users join the livestream. Defaults to `.auto`. + /// - showsLeaveCallButton: Whether to show a button to leave the call. Defaults to `false`. + /// - onFullScreenStateChange: A callback for fullscreen state changes. public init( type: String, id: String, muted: Bool = false, showParticipantCount: Bool = true, - handleParticipationWithLifecycle: Bool = true, + joinPolicy: JoinPolicy = .auto, showsLeaveCallButton: Bool = false, onFullScreenStateChange: ((Bool) -> Void)? = nil ) { @@ -34,11 +68,11 @@ public struct LivestreamPlayer: View { ) _viewModel = StateObject(wrappedValue: viewModel) _state = StateObject(wrappedValue: viewModel.call.state) - self.handleParticipationWithLifecycle = handleParticipationWithLifecycle + self.joinPolicy = joinPolicy self.showsLeaveCallButton = showsLeaveCallButton self.onFullScreenStateChange = onFullScreenStateChange } - + public var body: some View { ZStack { if viewModel.errorShown { @@ -65,7 +99,8 @@ public struct LivestreamPlayer: View { viewModel.controlsShown ? LivestreamPlayPauseButton( viewModel: viewModel ) { - participant.track?.isEnabled = !viewModel.streamPaused + participant.track?.isEnabled = + !viewModel.streamPaused if !viewModel.streamPaused { viewModel.update(controlsShown: false) } @@ -81,26 +116,40 @@ public struct LivestreamPlayer: View { LiveIndicator() if viewModel.showParticipantCount { LivestreamParticipantsView( - participantsCount: Int(viewModel.call.state.participantCount) + participantsCount: + Int( + viewModel.call.state + .participantCount + ) ) } Spacer() LivestreamButton( - imageName: !viewModel.muted ? "speaker.wave.2.fill" : "speaker.slash.fill" + imageName: !viewModel.muted + ? "speaker.wave.2.fill" + : "speaker.slash.fill" ) { viewModel.toggleAudioOutput() } LivestreamButton(imageName: "viewfinder") { - viewModel.update(fullScreen: !viewModel.fullScreen) + viewModel.update( + fullScreen: + !viewModel.fullScreen + ) } if showsLeaveCallButton { - LivestreamButton(imageName: "phone.down.fill") { + LivestreamButton( + imageName: "phone.down.fill" + ) { viewModel.leaveLivestream() } } } .padding() - .background(colors.livestreamBackground.edgesIgnoringSafeArea(.all)) + .background( + colors.livestreamBackground + .edgesIgnoringSafeArea(.all) + ) .foregroundColor(colors.livestreamCallControlsColor) .overlay( LivestreamDurationView( @@ -121,12 +170,20 @@ public struct LivestreamPlayer: View { } }) .onAppear { - guard handleParticipationWithLifecycle else { return } - viewModel.joinLivestream() + switch joinPolicy { + case .none: + break + case .auto: + viewModel.joinLivestream() + } } .onDisappear { - guard handleParticipationWithLifecycle else { return } - viewModel.leaveLivestream() + switch joinPolicy { + case .none: + break + case .auto: + viewModel.leaveLivestream() + } } } } From 0442b09797707b78d6ee6b5cb295edc12e9a5e28 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Wed, 20 Nov 2024 15:03:37 +0200 Subject: [PATCH 4/4] Fix compilation error --- .../Sources/ViewModifiers/CallModifier/DemoCallModifier.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift b/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift index a362f6c05..8bbd08979 100644 --- a/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift +++ b/DemoApp/Sources/ViewModifiers/CallModifier/DemoCallModifier.swift @@ -41,7 +41,7 @@ struct DemoCallModifier: ViewModifier { LivestreamPlayer( type: call.callType, id: call.callId, - handleParticipationWithLifecycle: false, + joinPolicy: .none, showsLeaveCallButton: true, onFullScreenStateChange: { [weak viewModel] in viewModel?.hideUIElements = $0 } )