Я разрабатываю приложение для VisionOS и тестирую его на AVP (visionOS 26), на устройствах iOS 17 и 26, а также в симуляторах (visionOS 2.5). Идея приложения — случайные видеозвонки.
Для видеозвонков я использую LiveKit SDK. На данный момент SDK имеет версию 2.11.0.
Возможно, это поможет прояснить, в чем проблема:
Пользователь нажимает кнопку «Пуск», что вызывает matchViewModel.startMatching(). После этого matchViewModel.connectionState изменится на .searching. Другой пользователь нажимает «Старт» и происходит то же самое.
Затем API возвращает информацию для обоих пользователей (myInfo, партнеры, roomId, myLiveKitToken). Когда пользователь получает информацию о комнате, matchViewModel.connectionState меняется на .connecting.
На этом этапе должно произойти подключение к LiveKit.
В файле MatchingWrapperView метод handleChangeRoomIdOrPartners проверяет, следует ли ему подключаться к LiveKit (когда доступна информация о партнере и идентификатор комнаты) или отключаться от комнату (когда партнер завершает вызов).
Метод ConnectRoom обрабатывает подключение к комнате LiveKit, включает камеру и запускает emeHasConnectedToLiveKit() (matchingViewModel.connectionState изменяется на .connected, а другому партнеру отправляется информация о том, что пользователь присоединился к LiveKit).
При тестировании устройства VisionOS + устройства VisionOS или iPhone или симулятора VisionOS большую часть времени видео с iPhone не отображается на устройстве VisionOS. Реже видео с устройства VisionOS не отображается на iPhone или в симуляторе.
При тестировании iPhone + iPhone + симулятор VisionOS обычно все работает нормально. Иногда видео не появляется, но это случается гораздо реже.
Вот весь код основного функционала. Если вам нужен дополнительный код, дайте мне знать.
RoomModel.swift
import Combine
import SwiftUI
final class RoomModel: Notifiable, ObservableObject {
@Injected var services: Services
private var cancellables: Set = 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. Этот файл содержит все представления, использующие MatchingViewModel.
import AVFoundation
import Combine
@preconcurrency import LiveKit
import LiveKitComponents
import SwiftUI
import os
struct MatchingWrapperView: 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? = 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)
}
}
Подробнее здесь: https://stackoverflow.com/questions/798 ... ners-video
Иногда LiveKit SwiftUI SDK не показывает видео партнера ⇐ IOS
Программируем под IOS
-
Anonymous
1768406286
Anonymous
Я разрабатываю приложение для VisionOS и тестирую его на AVP (visionOS 26), на устройствах iOS 17 и 26, а также в симуляторах (visionOS 2.5). Идея приложения — случайные видеозвонки.
Для видеозвонков я использую LiveKit SDK. На данный момент SDK имеет версию 2.11.0.
Возможно, это поможет прояснить, в чем проблема:
Пользователь нажимает кнопку «Пуск», что вызывает matchViewModel.startMatching(). После этого matchViewModel.connectionState изменится на .searching. Другой пользователь нажимает «Старт» и происходит то же самое.
Затем API возвращает информацию для обоих пользователей (myInfo, партнеры, roomId, myLiveKitToken). Когда пользователь получает информацию о комнате, matchViewModel.connectionState меняется на .connecting.
На этом этапе должно произойти подключение к LiveKit.
В файле MatchingWrapperView метод handleChangeRoomIdOrPartners проверяет, следует ли ему подключаться к LiveKit (когда доступна информация о партнере и идентификатор комнаты) или отключаться от комнату (когда партнер завершает вызов).
Метод ConnectRoom обрабатывает подключение к комнате LiveKit, включает камеру и запускает emeHasConnectedToLiveKit() (matchingViewModel.connectionState изменяется на .connected, а другому партнеру отправляется информация о том, что пользователь присоединился к LiveKit).
При тестировании устройства VisionOS + устройства VisionOS или iPhone или симулятора VisionOS большую часть времени видео с iPhone не отображается на устройстве VisionOS. Реже видео с устройства VisionOS не отображается на iPhone или в симуляторе.
При тестировании iPhone + iPhone + симулятор VisionOS обычно все работает нормально. Иногда видео не появляется, но это случается гораздо реже.
Вот весь код основного функционала. Если вам нужен дополнительный код, дайте мне знать.
RoomModel.swift
import Combine
import SwiftUI
final class RoomModel: Notifiable, ObservableObject {
@Injected var services: Services
private var cancellables: Set = 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. Этот файл содержит все представления, использующие MatchingViewModel.
import AVFoundation
import Combine
@preconcurrency import LiveKit
import LiveKitComponents
import SwiftUI
import os
struct MatchingWrapperView: 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? = 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)
}
}
Подробнее здесь: [url]https://stackoverflow.com/questions/79867908/sometimes-livekit-swiftui-sdk-not-showing-partners-video[/url]
Ответить
1 сообщение
• Страница 1 из 1
Перейти
- Кемерово-IT
- ↳ Javascript
- ↳ C#
- ↳ JAVA
- ↳ Elasticsearch aggregation
- ↳ Python
- ↳ Php
- ↳ Android
- ↳ Html
- ↳ Jquery
- ↳ C++
- ↳ IOS
- ↳ CSS
- ↳ Excel
- ↳ Linux
- ↳ Apache
- ↳ MySql
- Детский мир
- Для души
- ↳ Музыкальные инструменты даром
- ↳ Печатная продукция даром
- Внешняя красота и здоровье
- ↳ Одежда и обувь для взрослых даром
- ↳ Товары для здоровья
- ↳ Физкультура и спорт
- Техника - даром!
- ↳ Автомобилистам
- ↳ Компьютерная техника
- ↳ Плиты: газовые и электрические
- ↳ Холодильники
- ↳ Стиральные машины
- ↳ Телевизоры
- ↳ Телефоны, смартфоны, плашеты
- ↳ Швейные машинки
- ↳ Прочая электроника и техника
- ↳ Фототехника
- Ремонт и интерьер
- ↳ Стройматериалы, инструмент
- ↳ Мебель и предметы интерьера даром
- ↳ Cантехника
- Другие темы
- ↳ Разное даром
- ↳ Давай меняться!
- ↳ Отдам\возьму за копеечку
- ↳ Работа и подработка в Кемерове
- ↳ Давай с тобой поговорим...
Мобильная версия