ARTICLE AD BOX
I’m developing an app for visionOS and testing it on AVP (visionOS 26), on iOS 17 and 26 devices, and in simulators (visionOS 2.5). The idea of the app is random video calls.
For video calls I use LiveKit SDK. At the moment, SDK the version is 2.11.0.
Maybe this will help clarify where the problem is:
A user presses the "Start" button, which calls matchingViewModel.startMatching(). After that, matchingViewModel.connectionState changes to .searching. Another user presses "Start" and the same thing happens.
Then the API returns information for both users (myInfo, partners, roomId, myLiveKitToken). When a user receives the room information, matchingViewModel.connectionState changes to .connecting.
At this point, the connection to LiveKit should happen.
In the MatchingWrapperView file, the handleChangeRoomIdOrPartners method checks whether it should connect to LiveKit (when partner information and a room ID are available) or disconnect from the room (when the partner ends the call). The connectRoom method handles connecting to the LiveKit room, enables the camera, and runs emitHasConnectedToLiveKit() (matchingViewModel.connectionState changes to .connected, and information is sent to the other partner that the user has joined LiveKit).
When testing visionOS device + visionOS device or iPhone or visionOS simulator, most of the time the video from the iPhone is not shown on the visionOS device. Less often, the video from the visionOS device is not shown on the iPhone or in the simulator.
When testing iPhone + iPhone + visionOS simulator, everything usually works fine. Occasionally the video doesn’t appear, but this happens much less often.
Here is all the code for the core functionality. If you need any additional code, please let me know.
RoomModel.swift
import Combine import SwiftUI final class RoomModel: Notifiable, ObservableObject { @Injected var services: Services private var cancellables: Set<AnyCancellable> = Set() var globalManager: RoomGlobalManager? // var invitesManager: RoomInvitesManager? // var eventsManager: RoomEventsManager? @Published private(set) var socketConnected: Bool = false @Published private(set) var shouldCall: Bool = false @Published private(set) var roomId: String = "" @Published private(set) var myInfo: UserProfileModel? @Published private(set) var partners: [UserProfileModel] = [] @Published private(set) var joinedVideoPartners: [String] = [] @Published private(set) var myLiveKitToken: String = "" @Published private(set) var timerValue: Int = -1 @Published private(set) var hasExtendedTimer: Bool = false @Published private(set) var currentMaxTimerValue: Int = AppSettings .callTimerValue @Published private(set) var sentConnectUserRequests: [String] = [] @Published private var partnersSentConnectUserRequest: [String] = [] @Published private var friendsUsers: [String] = [] @Published private var usersOnline: [UserOnlineStatusModel] = [] private let socketService = SocketIOService.shared private let userId: String private let token: String init(userId: String, token: String) { self.userId = userId self.token = token setupManagers() socketService.initSocket(userId: userId, token: token) } private func setupManagers() { self.globalManager = RoomGlobalManager( roomModel: self, userId: userId, token: token ) // self.invitesManager = RoomInvitesManager(roomModel: self) // self.eventsManager = RoomEventsManager(roomModel: self) self.bindService() } var notificationModel: OrnamentNotificationModel? func setNotificationModel(_ model: OrnamentNotificationModel) { self.notificationModel = model } func destroySocket() { socketService.disconnectAndDestroy() } func reset() { roomId = "" myInfo = nil partners = [] joinedVideoPartners = [] myLiveKitToken = "" timerValue = -1 hasExtendedTimer = false currentMaxTimerValue = AppSettings.callTimerValue } private(set) var settingsViewModel: SettingsViewModel? func bindSettingsViewModel(_ settingsViewModel: SettingsViewModel) { self.settingsViewModel = settingsViewModel } } // MARK: Variables extension RoomModel { var myId: String { services.storageService.userProfile?.id ?? "" } } extension RoomModel { // MARK: Matching func changeShouldCall(_ shouldCall: Bool) { self.shouldCall = shouldCall socketService.changeShouldCall(shouldCall: shouldCall) UIApplication.shared.isIdleTimerDisabled = shouldCall } func endCall() { self.changeShouldCall(false) socketService.sendEnd() self.reset() } func joinedVideo() { socketService .joinedVideo( roomId: self.roomId, meetTime: self.currentMaxTimerValue ) } // MARK: Listeners private func bindService() { socketService.onSocketConnected = { [weak self] in self?.socketConnected = true self?.changeUserOnline( isOnline: true, isBusy: false, completion: self?.getUsersOnline ) } socketService.onSocketDisconnected = { [weak self] in self?.socketConnected = false self?.shouldCall = false } socketService.onShowNotification = { [weak self] payload in guard let self = self else { return } let notificationInfo = RoomModel.decodeNotificationInfo( from: payload ) guard let notificationInfo = notificationInfo else { return } self.notificationModel?.showNotification( OrnamentNotification( title: notificationInfo.text, message: notificationInfo.description, type: notificationInfo.type, customData: [ "roomId": self.roomId, "hideOnEndCall": notificationInfo.hideOnEndCall ?? false, ], customDuration: notificationInfo.customDuration ) ) } socketService.onError = { [weak self] text, description in self?.notificationModel?.showNotification( OrnamentNotification( title: text, message: description, type: .error ) ) SentryService .sendMessage( "Received error. Title: \(text) Description: \(description ?? "")" ) } // MARK: Matching listeners socketService.onGetPartnerInfo = { [weak self] payload in guard let self = self, self.shouldCall else { return } let roomInfo = RoomModel.decodeRoomInfo(from: payload) guard let roomInfo = roomInfo else { return } var shouldUpdate: Bool if self.partners.isEmpty { shouldUpdate = true } else { let currentIDs = Set(self.partners.map { $0.id }) let newIDs = Set(roomInfo.partners.map { $0.id }) shouldUpdate = currentIDs != newIDs } guard shouldUpdate else { return } self.roomId = roomInfo.roomId self.myInfo = roomInfo.myInfo self.partners = roomInfo.partners self.myLiveKitToken = roomInfo.myLiveKitToken if let friendIds = roomInfo.myInfo.friendIds, !friendIds.isEmpty { for friendId in friendIds { if !self.friendsUsers.contains(friendId) { self.friendsUsers.append(friendId) } } } services.storageService.updateUserProfile( \.matchesCount, value: myInfo?.matchesCount ) } socketService.onPartnerLeft = { [weak self] partnerId in guard let self = self, self.shouldCall else { return } self.partners.removeAll { $0.id == partnerId } if self.partners.isEmpty { self.reset() } } socketService.onPartnerJoinedVideo = { [weak self] userId in guard let self = self, !userId.isEmpty else { return } self.joinedVideoPartners.append(userId) } // MARK: Timer listeners socketService.onTimerUpdate = { [weak self] timerValue in self?.timerValue = timerValue } socketService.onTimerExtended = { time in self.hasExtendedTimer = true } socketService.onTimerEnded = { [weak self] in guard let self = self else { return } self.reset() } // MARK: Users Online listenrs socketService.onUserOnlineChanged = { [weak self] payload in guard let self = self, let userOnlineInfo = RoomModel.decodeUserOnlineInfo( from: payload ) else { return } self.updateUserStatus(userOnlineInfo) } // MARK: Connect Book listeners socketService.onConnectedUser = { [weak self] userId, cancel, isFriends in guard let self = self, !userId.isEmpty else { return } if cancel { self.partnersSentConnectUserRequest .removeAll(where: { $0 == userId }) } else { self.partnersSentConnectUserRequest.append(userId) if self.sentConnectUserRequests.contains(userId) { let partner = self.partners .first(where: { $0.id == userId }) if settingsViewModel?.audioSettings?.allSounds == true { services.soundService.playSound( named: "Friend-Accepted", duration: 3 ) } self.notificationModel?.showNotification( OrnamentNotification( title: "Partner has accepted your connection", type: .success, contentView: { AnyView( AcceptedConnectionNotificationContentView( user: partner ?? nil ) ) } ) ) NotificationCenter.default.post( name: .didAddFriend, object: nil, userInfo: nil ) } } } socketService.onRemovedUser = { userId in guard !userId.isEmpty else { return } NotificationCenter.default.post( name: .didRemoveUser, object: nil, userInfo: ["userId": userId] ) self.removeFriendLocal(userId: userId) // self.removeInvitation(fromUserId: userId) } } }RoomGlobalManager.swift
import Combine import SwiftUI final class RoomGlobalManager: ObservableObject { private weak var roomModel: RoomModel? let socketService = SocketIOService.shared private let userId: String private let token: String init(roomModel: RoomModel, userId: String, token: String) { self.roomModel = roomModel self.userId = userId self.token = token } // MARK: - Matching Methods func startMatch() { guard !(roomModel?.shouldCall ?? true) else { return } roomModel?.changeShouldCall(true) socketService.connect(userId: userId, token: token) } func restartMatch() { socketService.connect(userId: userId, token: token) } func skip(completion: SocketAckCompletion? = nil) { socketService.sendSkipCall(completion: completion) roomModel?.reset() } }MatchingViewModel.swift
import Combine @preconcurrency import LiveKit import LiveKitComponents import SwiftUI let wsURL = "wss://*****.livekit.cloud" enum ConnectionState { case searching // when on waiting room, but play case connecting // when receive roomID case connected // users LiveKit connection started case disconnecting // user has pressed exit/skip case disconnected // when no lobby var isNotConnected: Bool { switch self { case .disconnecting, .searching, .disconnected: return true default: return false } } } enum ConnectionType { case global case invites case events } class MatchingViewModel: NotifiableWrapper, ObservableObject { @Injected private var services: Services @Published private(set) var connectionType: ConnectionType? = nil @Published private(set) var connectionState: ConnectionState = .disconnected private var globalMatchingViewModel: GlobalMatchingViewModel? private var currentMatchingViewModel: (any MatchingTypeViewModel)? { switch connectionType { case .global: return globalMatchingViewModel default: return nil } } override init() { super.init() setupViewModels() } private(set) var room: Room? private(set) var roomModel: RoomModel? private func setupViewModels() { globalMatchingViewModel = GlobalMatchingViewModel() setupStateObservers() } private func setupStateObservers() { globalMatchingViewModel?.onStateChange = { [weak self] state in self?.handleChildStateChange(.global, state: state) } } func attachRoom(_ room: Room) { self.room = room globalMatchingViewModel?.attachRoom(room) } func bindSocket(_ roomModel: RoomModel) { self.roomModel = roomModel globalMatchingViewModel?.bindSocket(roomModel) } override func setNotificationModel(_ model: OrnamentNotificationModel) { super.setNotificationModel(model) globalMatchingViewModel?.setNotificationModel(model) } func changeConnectionType(_ newType: ConnectionType) { guard newType != self.connectionType else { return } if connectionState != .disconnected { endCall(state: .disconnected, notifyChangeCallStatus: false) } self.connectionType = newType self.connectionState = .disconnected } func changeConnectionState( _ newState: ConnectionState, connectionType: ConnectionType? = nil ) { guard newState != self.connectionState else { return } if let connectionType = connectionType { self.connectionType = connectionType } self.connectionState = newState } private func handleChildStateChange( _ type: ConnectionType, state: ConnectionState ) { if self.connectionType == nil { self.connectionType = type } guard self.connectionType == type else { return } self.connectionState = state } } extension MatchingViewModel { public func startMatching() { currentMatchingViewModel?.startMatching() } public func skipOrEndCall() { self.skip() } public func skip(completion: SocketAckCompletion? = nil) { currentMatchingViewModel?.skip(completion: completion) } public func endCall( state: ConnectionState, notifyChangeCallStatus: Bool? = true ) { if let currentVM = currentMatchingViewModel { currentVM.endCall( state: state, notifyChangeCallStatus: notifyChangeCallStatus ) } else { print("end call - no current VM, executing directly") DispatchQueue.main.async { Task { self.roomModel?.endCall() await self.room?.disconnect() self.changeConnectionState(state) } } } } func emitHasConnectedToLiveKit() { self.changeConnectionState(.connected) roomModel?.joinedVideo() } } protocol MatchingTypeViewModel: ObservableObject { var onStateChange: ((ConnectionState) -> Void)? { get set } func startMatching() func skip(completion: SocketAckCompletion?) func endCall(state: ConnectionState, notifyChangeCallStatus: Bool?) }GlobalMatchingViewModel.swift
import Combine @preconcurrency import LiveKit import SwiftUI class GlobalMatchingViewModel: NotifiableWrapper, MatchingTypeViewModel { @Injected private var services: Services var onStateChange: ((ConnectionState) -> Void)? private(set) var room: Room? private(set) var roomModel: RoomModel? override init() { super.init() } func attachRoom(_ room: Room) { self.room = room } func bindSocket(_ roomModel: RoomModel) { self.roomModel = roomModel } private func propagateState(_ newState: ConnectionState) { onStateChange?(newState) } } extension GlobalMatchingViewModel { func startMatching() { roomModel?.globalManager?.startMatch() } func skip(completion: SocketAckCompletion? = nil) { DispatchQueue.main.async { Task { self.roomModel?.globalManager?.skip(completion: completion) await self.room?.disconnect() } } } func endCall( state: ConnectionState, notifyChangeCallStatus: Bool? = true ) { guard self.roomModel?.shouldCall == true else { return } DispatchQueue.main.async { Task { self.roomModel?.endCall() await self.room?.disconnect() self.propagateState(state) } } } }MatchingContentView.swift
import Combine @preconcurrency import LiveKit import LiveKitComponents import SDWebImageSwiftUI import SwiftUI struct MatchingContentView: View { @EnvironmentObject private var matchingViewModel: MatchingViewModel @EnvironmentObject private var roomModel: RoomModel @EnvironmentObject private var room: Room @Environment(\.isFocused) var isFocused: Bool @State private var partnerCountryName: String = "" var body: some View { Group { GeometryReader { geometry in VStack(spacing: 16) { if matchingViewModel.connectionState.isNotConnected { self.searchingStateView(geometry: geometry) } else { #if os(visionOS) self.connectedStateView(geometry: geometry) #else ScrollView(.horizontal) { ScrollView { self.connectedStateView(geometry: geometry) } } #endif } } } } } extension MatchingContentView { //MARK: UI Views private func searchingStateView(geometry: GeometryProxy) -> some View { WaitingRoomView(geometry: geometry) } private func connectedStateView(geometry: GeometryProxy) -> some View { Group { #if os(visionOS) HStack(spacing: 15) { ParticipantsList(geometry: geometry) ParticipantInfoView() } #else ScrollView { VStack(spacing: 15) { ParticipantsList(geometry: geometry) ParticipantInfoView() } .background(.gray.opacity(0.5)) } #endif } .padding() } }MatchingWrapperView.swift. This file contains all views that use MatchingViewModel.
import AVFoundation import Combine @preconcurrency import LiveKit import LiveKitComponents import SwiftUI import os struct MatchingWrapperView<Content: View>: View { let content: () -> Content init(@ViewBuilder content: @escaping () -> Content) { self.content = content } @EnvironmentObject private var matchingViewModel: MatchingViewModel @EnvironmentObject private var roomModel: RoomModel @EnvironmentObject private var eventsViewModel: EventsViewModel @EnvironmentObject private var settingsViewModel: SettingsViewModel @EnvironmentObject private var notificationModel: OrnamentNotificationModel @EnvironmentObject private var room: Room @EnvironmentObject private var soundService: SoundService @Environment(\.selection) private var selection @State private var isConnecting = false @State private var connectTask: Task<Void, Never>? = nil let logger = Logger(subsystem: "persona.vision", category: "LiveKit") var body: some View { content() .onAppear { self.handleConnectionStateChange( from: nil, to: matchingViewModel.connectionState ) } .onChange(of: room.connectionState) { oldState, newState in guard roomModel.shouldCall else { return } switch newState { case .disconnected: // here check selection status to start new matching or end it if matchingViewModel.connectionState == .disconnecting { if selection != 2 { matchingViewModel .changeConnectionState( .disconnected, connectionType: nil ) roomModel.changeShouldCall(false) return } else { matchingViewModel.changeConnectionState(.searching) } } roomModel.globalManager?.restartMatch() break case .connected: // if partners are empty skip call to prevent show empty partner info if roomModel.partners.isEmpty { matchingViewModel.changeConnectionState(.disconnecting) self.notificationModel.showNotification( OrnamentNotification( title: "Failed to receive partner info", type: .error, customDuration: 5 ) ) } case .disconnecting: break default: break } } .onChange(of: roomModel.partners.count) { self.handleChangeRoomIdOrPartners() } .onChange(of: matchingViewModel.connectionState) { oldState, newState in self.handleConnectionStateChange( from: oldState, to: newState ) } } } extension MatchingWrapperView { // MARK: Functions func connectRoom(token: String) { guard room.connectionState == .disconnected else { return } Task { do { try await room.connect( url: wsURL, token: token, connectOptions: ConnectOptions(enableMicrophone: true) ) } catch { return } await enableCamera() matchingViewModel.emitHasConnectedToLiveKit() } } private func enableCamera( maxRetries: Int = 10, delaySeconds: Double = 0.5 ) async { #if !targetEnvironment(simulator) do { try await room.localParticipant.setCamera(enabled: true) return } catch { } #endif } private func reattemptConnect(token: String) { Task { [self] in self.connectRoom(token: token) } } private func handleConnectionStateChange( from oldState: ConnectionState?, to newState: ConnectionState ) { guard oldState != newState else { return } print( "Connection state changed: \(String(describing: oldState)) → \(newState)" ) if newState == .searching { matchingViewModel.startMatching() if settingsViewModel.audioSettings?.waitingSound == true { soundService.playAudio( name: "music_for_waiting_with_delay", type: "mp3", volume: 0.5 ) } } else if oldState == .searching && newState == .disconnected { matchingViewModel.endCall(state: .disconnected) soundService.stopAudio() } else { soundService.stopAudio() } } @MainActor func handleChangeRoomIdOrPartners() { guard matchingViewModel.connectionState != .disconnected else { return } let hasRoomId = !roomModel.roomId.isEmpty let hasPartner = !roomModel.partners.isEmpty let isDisconnectedRoom = room.connectionState == .disconnected let isConnected = matchingViewModel.connectionState == .connected let isDisconnecting = matchingViewModel.connectionState == .disconnecting if hasRoomId, hasPartner, isDisconnectedRoom, !isConnected { self.connectToLiveKit() return } if isDisconnecting || isDisconnectedRoom { return } matchingViewModel.changeConnectionState(.disconnecting) Task { await room.disconnect() } if self.notificationModel.notification?.contentView != nil { self.notificationModel.dismissNotification() } } func connectToLiveKit() { matchingViewModel.changeConnectionState(.connecting) let roomId = self.roomModel.roomId let token = self.roomModel.myLiveKitToken guard !roomId.isEmpty, !token.isEmpty else { self.notificationModel.showNotification( OrnamentNotification( title: "Failed to receive room id or LiveKit token", type: .error, customDuration: 5 ) ) matchingViewModel.changeConnectionState(.searching) SentryService .sendMessage( "Failed to receive room id or LiveKit token", context: SentryContext(extra: ["userId": roomModel.myId]) ) return } connectRoom(token: token) } }