import StreamVideo
import StreamVideoSwiftUI
let callViewModel = CallViewModel()
callViewModel.callJoinInterceptor = ParticipantReadyCallJoinInterceptor(streamVideo: streamVideo)Join Call Interception
CallJoinIntercepting lets you run app-specific logic at the end of the join flow. The SDK calls it after the backend join request succeeds and the local Call state has been updated, but before the call is treated as fully joined.
This is useful in ringing flows where you want to delay the transition into the in-call UI until another participant is actually ready.
The callReadyToJoin(_:) callback has three possible outcomes:
- Return normally and the join continues.
- Throw and the join fails with a
CallJoinInterceptionError. Your original error is preserved inunderlyingError. - Take too long and the SDK stops waiting, then continues the join automatically.
Attaching the interceptor
If you use CallViewModel, assign the interceptor before you start, join, or accept a call:
If you are joining calls without CallViewModel, pass the interceptor directly to call.join(joinInterceptor:).
Example: wait for another participant
The following sample mirrors the behavior used in our demo app. It only changes ringing calls. When the local user is ready to join, it sends a custom participant.ready event and waits until another participant sends the same signal.
import Combine
import StreamVideo
@MainActor
final class ParticipantReadyCallJoinInterceptor: CallJoinIntercepting {
private let streamVideo: StreamVideo
private let disposableBag = DisposableBag()
private var ringingCallCancellable: AnyCancellable?
private var customEventCancellable: AnyCancellable?
private let customEventKey: String
private let currentUserID: String
private let hasOtherReadyParticipants = CurrentValueSubject<Bool, Never>(false)
init(streamVideo: StreamVideo, customEventKey: String = "participant.ready") {
self.streamVideo = streamVideo
self.customEventKey = customEventKey
currentUserID = streamVideo.state.user.id
ringingCallCancellable = streamVideo
.state
.$ringingCall
.receive(on: DispatchQueue.main)
.removeDuplicates { $0?.cId == $1?.cId }
.sinkTask(storeIn: disposableBag) { @MainActor [weak self] ringingCall in
self?.didUpdate(ringingCall: ringingCall)
}
}
func callReadyToJoin(_ call: Call) async throws {
// Non-ringing calls should continue without extra coordination.
guard customEventCancellable != nil else {
return
}
do {
try await call.sendCustomEvent([customEventKey: .string(currentUserID)])
} catch {
// Keep the example non-blocking even if the readiness ping fails.
}
_ = try? await hasOtherReadyParticipants
.filter { $0 }
.nextValue()
}
private func didUpdate(ringingCall: Call?) {
cancelCustomEventObservation()
guard let ringingCall else {
return
}
customEventCancellable = ringingCall
.eventPublisher(for: CustomVideoEvent.self)
.compactMap { [customEventKey] in $0.custom[customEventKey]?.stringValue }
.filter { [currentUserID] in $0 != currentUserID }
.map { _ in true }
.sinkTask(storeIn: disposableBag) { @MainActor [weak self] hasReadyParticipant in
self?.hasOtherReadyParticipants.send(hasReadyParticipant)
}
}
private func cancelCustomEventObservation() {
customEventCancellable?.cancel()
customEventCancellable = nil
hasOtherReadyParticipants.send(false)
}
}This interceptor uses streamVideo.state.ringingCall to scope the behavior to ringing flows and CustomVideoEvent to coordinate readiness between participants. For non-ringing calls, the interceptor returns immediately and the join proceeds as usual.
This sample is intentionally tolerant. If the readiness event cannot be sent, or if nobody else sends one back, the SDK eventually continues the join on its own. If your app must block entry until some prerequisite succeeds, throw from callReadyToJoin(_:) instead of ignoring the failure.
For more background, see the ringing guide and custom events.