@poolse/react-native-calls 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
- package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -2
- package/dist/index.d.ts +44 -2
- package/dist/index.js +33 -2
- package/dist/index.js.map +1 -1
- package/expo-module.config.json +6 -1
- package/ios/Core/CallCoreRuntime.swift +393 -0
- package/ios/Core/CallHttpClient.swift +210 -0
- package/ios/Core/CallMachine.swift +304 -0
- package/ios/Core/PORTS_READY.md +93 -0
- package/ios/Core/Ports.swift +193 -0
- package/ios/Core/SnapshotEncoding.swift +75 -0
- package/ios/Module/PoolseCallVideoView.swift +62 -0
- package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
- package/ios/Module/PoolseCallsModule.swift +111 -0
- package/ios/Platform/CallCore.swift +464 -0
- package/ios/Platform/CallKitController.swift +263 -0
- package/ios/Platform/LiveKitMediaPort.swift +445 -0
- package/ios/Platform/LiveKitVideoSurface.swift +67 -0
- package/ios/Platform/Ringback.swift +61 -0
- package/ios/Platform/VoipPush.swift +172 -0
- package/ios/PoolseCalls.podspec +64 -0
- package/package.json +7 -1
- package/src/expo-modules-core.d.ts +5 -0
- package/src/index.ts +85 -2
- package/src/react-shims.d.ts +22 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
#if canImport(LiveKitClient)
|
|
4
|
+
import LiveKitClient // CocoaPods module name
|
|
5
|
+
#else
|
|
6
|
+
import LiveKit // SwiftPM module name
|
|
7
|
+
#endif
|
|
8
|
+
import UIKit
|
|
9
|
+
|
|
10
|
+
/// Media-level signals the SFU adapter reports back to the core
|
|
11
|
+
/// (CallCore.kt's MediaEvents). Delivered on the main thread.
|
|
12
|
+
protocol MediaEvents: AnyObject {
|
|
13
|
+
func onConnected()
|
|
14
|
+
func onFailed()
|
|
15
|
+
/// Unexpected disconnect — the SFU gave up; the core asks the server and rejoins.
|
|
16
|
+
func onLost()
|
|
17
|
+
/// The SFU is resuming the session itself; only reflected in the UI.
|
|
18
|
+
func onReconnecting()
|
|
19
|
+
func onRecovered()
|
|
20
|
+
func onRemoteJoined()
|
|
21
|
+
/// The REMOTE roster (the local participant excluded).
|
|
22
|
+
func onParticipants(_ participants: [SnapshotParticipant])
|
|
23
|
+
/// `earpiece` | `speaker` | `bluetooth` | `headset`.
|
|
24
|
+
func onAudioRoute(_ route: String)
|
|
25
|
+
/// The local camera track is (not) published + enabled; facing `front` | `back`.
|
|
26
|
+
func onLocalCamera(on: Bool, facing: String?)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// The shipped MediaPort over LiveKit's Swift SDK — LiveKitMediaPort.kt
|
|
30
|
+
/// for iOS. Joins with the server-minted token, publishes the mic (and
|
|
31
|
+
/// the camera for video), tracks the roster and reports room transitions.
|
|
32
|
+
///
|
|
33
|
+
/// Audio is CallKit's: LiveKit's automatic AVAudioSession handling is off
|
|
34
|
+
/// and its engine is only available between CallKit's didActivate and
|
|
35
|
+
/// didDeactivate (LiveKit's documented CallKit integration).
|
|
36
|
+
///
|
|
37
|
+
/// Threading: every method is called on main; LiveKit's delegate
|
|
38
|
+
/// callbacks hop to main; async work re-checks that the room it captured
|
|
39
|
+
/// is still the current one.
|
|
40
|
+
final class LiveKitMediaPort: NSObject, MediaPort {
|
|
41
|
+
private weak var events: MediaEvents?
|
|
42
|
+
|
|
43
|
+
private(set) var room: Room?
|
|
44
|
+
private var observer: RoomObserver?
|
|
45
|
+
private var media = "audio"
|
|
46
|
+
private var connectedRoom: ObjectIdentifier?
|
|
47
|
+
|
|
48
|
+
/// Speaker forced by the user; nil = the media-type default.
|
|
49
|
+
private var speakerWanted: Bool?
|
|
50
|
+
/// The user's choices for the CURRENT call; survive a rejoin.
|
|
51
|
+
private var mutedWanted = false
|
|
52
|
+
private var cameraWanted: Bool?
|
|
53
|
+
private var joinedCallId: String?
|
|
54
|
+
|
|
55
|
+
private let surfaces = NSHashTable<LiveKitVideoSurface>.weakObjects()
|
|
56
|
+
private var audioPrepared = false
|
|
57
|
+
private var audioActive = false
|
|
58
|
+
|
|
59
|
+
init(events: MediaEvents) {
|
|
60
|
+
self.events = events
|
|
61
|
+
super.init()
|
|
62
|
+
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
|
|
63
|
+
name: AVAudioSession.routeChangeNotification, object: nil)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// MARK: - audio session (CallKit-driven)
|
|
67
|
+
|
|
68
|
+
/// Before any room exists: LiveKit must not configure the session nor
|
|
69
|
+
/// start its engine outside CallKit's activation window.
|
|
70
|
+
private func prepareAudio() {
|
|
71
|
+
guard !audioPrepared else { return }
|
|
72
|
+
audioPrepared = true
|
|
73
|
+
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
|
|
74
|
+
do { try AudioManager.shared.setEngineAvailability(.none) } catch {
|
|
75
|
+
NSLog("[PoolseCalls] setEngineAvailability(.none) failed: %@", "\(error)")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// CallKit activated the session (or, without CallKit, we did).
|
|
80
|
+
func audioActivated(_ session: AVAudioSession, media: String) {
|
|
81
|
+
prepareAudio()
|
|
82
|
+
audioActive = true
|
|
83
|
+
let video = media == "video"
|
|
84
|
+
do {
|
|
85
|
+
try session.setCategory(.playAndRecord, mode: video && speakerWanted != false ? .videoChat : .voiceChat,
|
|
86
|
+
options: [.allowBluetooth, .allowBluetoothA2DP])
|
|
87
|
+
} catch {
|
|
88
|
+
NSLog("[PoolseCalls] audio session category failed: %@", "\(error)")
|
|
89
|
+
}
|
|
90
|
+
do { try AudioManager.shared.setEngineAvailability(.default) } catch {
|
|
91
|
+
NSLog("[PoolseCalls] setEngineAvailability(.default) failed: %@", "\(error)")
|
|
92
|
+
}
|
|
93
|
+
if media != self.media && room == nil { self.media = media }
|
|
94
|
+
applyRoute()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func audioDeactivated() {
|
|
98
|
+
audioActive = false
|
|
99
|
+
guard audioPrepared else { return }
|
|
100
|
+
do { try AudioManager.shared.setEngineAvailability(.none) } catch {
|
|
101
|
+
NSLog("[PoolseCalls] setEngineAvailability(.none) failed: %@", "\(error)")
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// No CallKit call holds the session (CallKit refused the outgoing
|
|
106
|
+
/// transaction): activate it ourselves so the call still has audio.
|
|
107
|
+
func activateAudioWithoutCallKit(media: String) {
|
|
108
|
+
let s = AVAudioSession.sharedInstance()
|
|
109
|
+
do { try s.setActive(true) } catch { NSLog("[PoolseCalls] session activate failed: %@", "\(error)") }
|
|
110
|
+
audioActivated(s, media: media)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// MARK: - MediaPort
|
|
114
|
+
|
|
115
|
+
func join(bundle: CallSessionBundle) {
|
|
116
|
+
prepareAudio()
|
|
117
|
+
if let joined = joinedCallId, joined != bundle.callId { resetWanted() }
|
|
118
|
+
joinedCallId = bundle.callId
|
|
119
|
+
detach()
|
|
120
|
+
media = bundle.media
|
|
121
|
+
let wantsVideo = bundle.media == "video"
|
|
122
|
+
|
|
123
|
+
// A 1:1/small call: never let the SFU pause a subscribed camera for
|
|
124
|
+
// "not visible" — a view that mounts late would stay black.
|
|
125
|
+
let r = Room(roomOptions: RoomOptions(adaptiveStream: false, dynacast: false))
|
|
126
|
+
let obs = RoomObserver(port: self, room: r)
|
|
127
|
+
r.add(delegate: obs)
|
|
128
|
+
room = r
|
|
129
|
+
observer = obs
|
|
130
|
+
refreshVideo()
|
|
131
|
+
|
|
132
|
+
let url = bundle.livekitUrl
|
|
133
|
+
let token = bundle.livekitToken
|
|
134
|
+
Task { [weak self] in
|
|
135
|
+
do {
|
|
136
|
+
try await r.connect(url: url, token: token, connectOptions: ConnectOptions(autoSubscribe: true))
|
|
137
|
+
} catch {
|
|
138
|
+
NSLog("[PoolseCalls] LiveKit connect failed: %@", "\(error)")
|
|
139
|
+
await MainActor.run {
|
|
140
|
+
guard let self = self, self.room === r else { return }
|
|
141
|
+
self.room = nil
|
|
142
|
+
self.observer = nil
|
|
143
|
+
self.refreshVideo()
|
|
144
|
+
self.events?.onFailed()
|
|
145
|
+
}
|
|
146
|
+
await r.disconnect()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
let proceed: Bool = await MainActor.run {
|
|
150
|
+
guard let self = self, self.room === r else { return false }
|
|
151
|
+
self.markConnected(r)
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
guard proceed, let self = self else {
|
|
155
|
+
if !proceed { await r.disconnect() }
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
let muted = await MainActor.run { self.mutedWanted }
|
|
159
|
+
do { try await r.localParticipant.setMicrophone(enabled: !muted) } catch {
|
|
160
|
+
NSLog("[PoolseCalls] microphone publish failed: %@", "\(error)")
|
|
161
|
+
}
|
|
162
|
+
let camera = await MainActor.run { self.cameraWanted ?? wantsVideo }
|
|
163
|
+
if camera { await self.enableCamera(r, true) }
|
|
164
|
+
await MainActor.run {
|
|
165
|
+
self.reportLocalCamera(r)
|
|
166
|
+
self.refreshVideo()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Connected (once per room): the core moves on; anyone already in is "joined".
|
|
172
|
+
private func markConnected(_ r: Room) {
|
|
173
|
+
guard room === r, connectedRoom != ObjectIdentifier(r) else { return }
|
|
174
|
+
connectedRoom = ObjectIdentifier(r)
|
|
175
|
+
if speakerWanted != nil { applyRoute() }
|
|
176
|
+
events?.onConnected()
|
|
177
|
+
if !r.remoteParticipants.isEmpty { events?.onRemoteJoined() }
|
|
178
|
+
emit(r)
|
|
179
|
+
refreshVideo()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private func cameraGranted() -> Bool {
|
|
183
|
+
AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Without the camera grant a video call joins audio-only; a later
|
|
187
|
+
/// setCamera(true) after the user grants it publishes then.
|
|
188
|
+
private func enableCamera(_ r: Room, _ on: Bool) async {
|
|
189
|
+
if on && !cameraGranted() {
|
|
190
|
+
NSLog("[PoolseCalls] camera not authorized: staying audio-only")
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
do {
|
|
194
|
+
try await r.localParticipant.setCamera(enabled: on, captureOptions: on ? CameraCaptureOptions(position: .front) : nil)
|
|
195
|
+
} catch {
|
|
196
|
+
NSLog("[PoolseCalls] camera %@ failed: %@", on ? "publish" : "unpublish", "\(error)")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func leave() {
|
|
201
|
+
detach()
|
|
202
|
+
resetWanted()
|
|
203
|
+
joinedCallId = nil
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private func resetWanted() {
|
|
207
|
+
mutedWanted = false
|
|
208
|
+
cameraWanted = nil
|
|
209
|
+
speakerWanted = nil
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private func detach() {
|
|
213
|
+
guard let r = room else { return }
|
|
214
|
+
if let obs = observer { r.remove(delegate: obs) }
|
|
215
|
+
room = nil
|
|
216
|
+
observer = nil
|
|
217
|
+
connectedRoom = nil
|
|
218
|
+
refreshVideo()
|
|
219
|
+
Task { await r.disconnect() }
|
|
220
|
+
events?.onLocalCamera(on: false, facing: nil)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func setMuted(_ muted: Bool) {
|
|
224
|
+
mutedWanted = muted
|
|
225
|
+
guard let r = room, connectedRoom == ObjectIdentifier(r) else { return }
|
|
226
|
+
Task { _ = try? await r.localParticipant.setMicrophone(enabled: !muted) }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func setCamera(_ on: Bool) {
|
|
230
|
+
cameraWanted = on
|
|
231
|
+
guard let r = room, connectedRoom == ObjectIdentifier(r) else { return }
|
|
232
|
+
Task { [weak self] in
|
|
233
|
+
await self?.enableCamera(r, on)
|
|
234
|
+
await MainActor.run {
|
|
235
|
+
guard let self = self, self.room === r else { return }
|
|
236
|
+
self.reportLocalCamera(r)
|
|
237
|
+
self.refreshVideo()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
func switchCamera() {
|
|
243
|
+
guard let r = room, let track = localCameraTrack(r), let capturer = track.capturer as? CameraCapturer else { return }
|
|
244
|
+
Task { [weak self] in
|
|
245
|
+
do { _ = try await capturer.switchCameraPosition() } catch {
|
|
246
|
+
NSLog("[PoolseCalls] switchCamera failed: %@", "\(error)")
|
|
247
|
+
}
|
|
248
|
+
await MainActor.run {
|
|
249
|
+
guard let self = self, self.room === r else { return }
|
|
250
|
+
self.reportLocalCamera(r)
|
|
251
|
+
self.refreshVideo()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func setSpeaker(_ on: Bool) {
|
|
257
|
+
speakerWanted = on
|
|
258
|
+
applyRoute()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// MARK: - audio routing
|
|
262
|
+
|
|
263
|
+
/// A voice call is held to the ear (earpiece, or a headset/Bluetooth
|
|
264
|
+
/// route first); video defaults to the loudspeaker.
|
|
265
|
+
private func applyRoute() {
|
|
266
|
+
guard audioActive else { return }
|
|
267
|
+
let s = AVAudioSession.sharedInstance()
|
|
268
|
+
let want = speakerWanted ?? (media == "video")
|
|
269
|
+
do {
|
|
270
|
+
if want {
|
|
271
|
+
try s.overrideOutputAudioPort(.speaker)
|
|
272
|
+
} else {
|
|
273
|
+
// .videoChat forces the speaker; the private route needs .voiceChat.
|
|
274
|
+
if s.mode == .videoChat { try s.setMode(.voiceChat) }
|
|
275
|
+
try s.overrideOutputAudioPort(.none)
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
NSLog("[PoolseCalls] audio route failed: %@", "\(error)")
|
|
279
|
+
}
|
|
280
|
+
reportRoute()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private func currentRoute() -> String {
|
|
284
|
+
let outputs = AVAudioSession.sharedInstance().currentRoute.outputs
|
|
285
|
+
guard let port = outputs.first?.portType else { return media == "video" ? "speaker" : "earpiece" }
|
|
286
|
+
switch port {
|
|
287
|
+
case .builtInSpeaker: return "speaker"
|
|
288
|
+
case .builtInReceiver: return "earpiece"
|
|
289
|
+
case .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .carAudio, .airPlay: return "bluetooth"
|
|
290
|
+
case .headphones, .usbAudio, .lineOut, .HDMI: return "headset"
|
|
291
|
+
default: return "headset"
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private func reportRoute() {
|
|
296
|
+
guard joinedCallId != nil || audioActive else { return }
|
|
297
|
+
events?.onAudioRoute(currentRoute())
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
@objc private func routeChanged(_ note: Notification) {
|
|
301
|
+
DispatchQueue.main.async { [weak self] in
|
|
302
|
+
guard let self = self, self.audioActive else { return }
|
|
303
|
+
// A user's speaker choice survives route changes, except that a
|
|
304
|
+
// newly connected headset/Bluetooth device always wins.
|
|
305
|
+
let route = self.currentRoute()
|
|
306
|
+
if let wanted = self.speakerWanted, route == "speaker" || route == "earpiece", (route == "speaker") != wanted {
|
|
307
|
+
self.applyRoute()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
self.reportRoute()
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// MARK: - video
|
|
315
|
+
|
|
316
|
+
func createVideoSurface() -> LiveKitVideoSurface {
|
|
317
|
+
let s = LiveKitVideoSurface(port: self)
|
|
318
|
+
surfaces.add(s)
|
|
319
|
+
return s
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func unregister(_ s: LiveKitVideoSurface) { surfaces.remove(s) }
|
|
323
|
+
|
|
324
|
+
private func refreshVideo() {
|
|
325
|
+
for s in surfaces.allObjects { s.rebind() }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private func localCameraTrack(_ r: Room) -> LocalVideoTrack? {
|
|
329
|
+
guard let pub = r.localParticipant.trackPublications.values.first(where: { $0.source == .camera }), !pub.isMuted else { return nil }
|
|
330
|
+
return pub.track as? LocalVideoTrack
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// The local camera, or a remote's — only once published, SUBSCRIBED and unmuted.
|
|
334
|
+
func cameraTrack(participantId: String?, local: Bool) -> VideoTrack? {
|
|
335
|
+
guard let r = room else { return nil }
|
|
336
|
+
if local { return localCameraTrack(r) }
|
|
337
|
+
guard let id = participantId,
|
|
338
|
+
let p = r.remoteParticipants.values.first(where: { $0.identity?.stringValue == id }) else { return nil }
|
|
339
|
+
return remoteCamera(p)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private func remoteCamera(_ p: RemoteParticipant) -> VideoTrack? {
|
|
343
|
+
guard let pub = p.trackPublications.values.first(where: { $0.source == .camera }) as? RemoteTrackPublication,
|
|
344
|
+
pub.isSubscribed, !pub.isMuted else { return nil }
|
|
345
|
+
return pub.track as? VideoTrack
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// `front` | `back` of the published local camera, or nil when none.
|
|
349
|
+
func cameraFacing() -> String? {
|
|
350
|
+
guard let r = room, let track = localCameraTrack(r) else { return nil }
|
|
351
|
+
return (track.capturer as? CameraCapturer)?.position == .back ? "back" : "front"
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private func reportLocalCamera(_ r: Room) {
|
|
355
|
+
guard room === r else { return }
|
|
356
|
+
let on = localCameraTrack(r) != nil
|
|
357
|
+
events?.onLocalCamera(on: on, facing: on ? cameraFacing() : nil)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// MARK: - room events (main thread, via RoomObserver)
|
|
361
|
+
|
|
362
|
+
fileprivate func roomEvent(_ r: Room, _ kind: RoomObserver.Kind) {
|
|
363
|
+
guard room === r else { return }
|
|
364
|
+
switch kind {
|
|
365
|
+
case .connected:
|
|
366
|
+
markConnected(r)
|
|
367
|
+
case .reconnecting:
|
|
368
|
+
events?.onReconnecting()
|
|
369
|
+
case .reconnected:
|
|
370
|
+
events?.onRecovered()
|
|
371
|
+
case .disconnected:
|
|
372
|
+
events?.onLost()
|
|
373
|
+
case .participantJoined:
|
|
374
|
+
events?.onRemoteJoined()
|
|
375
|
+
emit(r)
|
|
376
|
+
refreshVideo()
|
|
377
|
+
case .tracksChanged:
|
|
378
|
+
emit(r)
|
|
379
|
+
reportLocalCamera(r)
|
|
380
|
+
refreshVideo()
|
|
381
|
+
case .rosterChanged:
|
|
382
|
+
emit(r)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// The REMOTE roster — the local participant is never included.
|
|
387
|
+
private func emit(_ r: Room) {
|
|
388
|
+
let roster = r.remoteParticipants.values.map { p -> SnapshotParticipant in
|
|
389
|
+
let identity = p.identity?.stringValue ?? ""
|
|
390
|
+
return SnapshotParticipant(
|
|
391
|
+
userId: identity,
|
|
392
|
+
externalId: externalId(p) ?? identity,
|
|
393
|
+
speaking: p.isSpeaking,
|
|
394
|
+
muted: !p.isMicrophoneEnabled(),
|
|
395
|
+
cameraOn: remoteCamera(p) != nil
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
events?.onParticipants(roster)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/// The host's own user id, if the server put it in attributes/metadata.
|
|
402
|
+
private func externalId(_ p: Participant) -> String? {
|
|
403
|
+
if let v = p.attributes["external_id"], !v.isEmpty { return v }
|
|
404
|
+
guard let m = p.metadata, let d = m.data(using: .utf8),
|
|
405
|
+
let obj = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any],
|
|
406
|
+
let v = obj["external_id"] as? String, !v.isEmpty else { return nil }
|
|
407
|
+
return v
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/// LiveKit's delegate → main-thread room events for the port.
|
|
412
|
+
final class RoomObserver: NSObject, RoomDelegate, @unchecked Sendable {
|
|
413
|
+
enum Kind { case connected, reconnecting, reconnected, disconnected, participantJoined, tracksChanged, rosterChanged }
|
|
414
|
+
|
|
415
|
+
private weak var port: LiveKitMediaPort?
|
|
416
|
+
private weak var room: Room?
|
|
417
|
+
|
|
418
|
+
init(port: LiveKitMediaPort, room: Room) {
|
|
419
|
+
self.port = port
|
|
420
|
+
self.room = room
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private func send(_ r: Room, _ kind: Kind) {
|
|
424
|
+
DispatchQueue.main.async { [weak self] in self?.port?.roomEvent(r, kind) }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
func roomDidConnect(_ room: Room) { send(room, .connected) }
|
|
428
|
+
func room(_ room: Room, didStartReconnectWithMode reconnectMode: ReconnectMode) { send(room, .reconnecting) }
|
|
429
|
+
func room(_ room: Room, didCompleteReconnectWithMode reconnectMode: ReconnectMode) { send(room, .reconnected) }
|
|
430
|
+
// Our own leave() removes this delegate before disconnecting, so any
|
|
431
|
+
// disconnect seen here is the SFU giving up (or the server removing us).
|
|
432
|
+
func room(_ room: Room, didDisconnectWithError error: LiveKitError?) { send(room, .disconnected) }
|
|
433
|
+
func room(_ room: Room, participantDidConnect participant: RemoteParticipant) { send(room, .participantJoined) }
|
|
434
|
+
func room(_ room: Room, participantDidDisconnect participant: RemoteParticipant) { send(room, .tracksChanged) }
|
|
435
|
+
func room(_ room: Room, participant: RemoteParticipant, didSubscribeTrack publication: RemoteTrackPublication) { send(room, .tracksChanged) }
|
|
436
|
+
func room(_ room: Room, participant: RemoteParticipant, didUnsubscribeTrack publication: RemoteTrackPublication) { send(room, .tracksChanged) }
|
|
437
|
+
func room(_ room: Room, participant: RemoteParticipant, didPublishTrack publication: RemoteTrackPublication) { send(room, .tracksChanged) }
|
|
438
|
+
func room(_ room: Room, participant: RemoteParticipant, didUnpublishTrack publication: RemoteTrackPublication) { send(room, .tracksChanged) }
|
|
439
|
+
func room(_ room: Room, participant: LocalParticipant, didPublishTrack publication: LocalTrackPublication) { send(room, .tracksChanged) }
|
|
440
|
+
func room(_ room: Room, participant: LocalParticipant, didUnpublishTrack publication: LocalTrackPublication) { send(room, .tracksChanged) }
|
|
441
|
+
func room(_ room: Room, participant: Participant, trackPublication: TrackPublication, didUpdateIsMuted isMuted: Bool) { send(room, .tracksChanged) }
|
|
442
|
+
func room(_ room: Room, didUpdateSpeakingParticipants participants: [Participant]) { send(room, .rosterChanged) }
|
|
443
|
+
func room(_ room: Room, participant: Participant, didUpdateMetadata metadata: String?) { send(room, .rosterChanged) }
|
|
444
|
+
func room(_ room: Room, participant: Participant, didUpdateAttributes attributes: [String: String]) { send(room, .rosterChanged) }
|
|
445
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#if canImport(LiveKitClient)
|
|
2
|
+
import LiveKitClient // CocoaPods module name
|
|
3
|
+
#else
|
|
4
|
+
import LiveKit // SwiftPM module name
|
|
5
|
+
#endif
|
|
6
|
+
import UIKit
|
|
7
|
+
|
|
8
|
+
/// One on-screen video slot over LiveKit's VideoView — LiveKitVideoSurface.kt
|
|
9
|
+
/// for iOS. The port rebinds every live surface whenever a camera may have
|
|
10
|
+
/// appeared, vanished, muted, or the room was replaced (rejoin).
|
|
11
|
+
///
|
|
12
|
+
/// Main thread only, like the port that owns it.
|
|
13
|
+
final class LiveKitVideoSurface: UIView {
|
|
14
|
+
private weak var port: LiveKitMediaPort?
|
|
15
|
+
private let videoView = VideoView()
|
|
16
|
+
private var released = false
|
|
17
|
+
|
|
18
|
+
private var participantId: String?
|
|
19
|
+
private var local = false
|
|
20
|
+
private var mirror: Bool?
|
|
21
|
+
private var fit = "cover"
|
|
22
|
+
|
|
23
|
+
init(port: LiveKitMediaPort) {
|
|
24
|
+
self.port = port
|
|
25
|
+
super.init(frame: .zero)
|
|
26
|
+
backgroundColor = .clear
|
|
27
|
+
clipsToBounds = true
|
|
28
|
+
videoView.frame = bounds
|
|
29
|
+
videoView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
30
|
+
videoView.isHidden = true
|
|
31
|
+
addSubview(videoView)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
35
|
+
|
|
36
|
+
/// [participantId] is the remote's userId (SFU identity); [local] shows
|
|
37
|
+
/// the self view. [mirror] nil = mirrored for the local front camera only.
|
|
38
|
+
func configure(participantId: String?, local: Bool, mirror: Bool?, fit: String) {
|
|
39
|
+
self.participantId = participantId
|
|
40
|
+
self.local = local
|
|
41
|
+
self.mirror = mirror
|
|
42
|
+
self.fit = fit
|
|
43
|
+
rebind()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func rebind() {
|
|
47
|
+
guard !released else { return }
|
|
48
|
+
let next = port?.cameraTrack(participantId: participantId, local: local)
|
|
49
|
+
if (videoView.track as AnyObject?) !== (next as AnyObject?) {
|
|
50
|
+
videoView.track = next
|
|
51
|
+
}
|
|
52
|
+
// No camera: hide so a last frozen frame never shows; the UI draws
|
|
53
|
+
// the avatar from the snapshot's cameraOn underneath.
|
|
54
|
+
videoView.isHidden = next == nil
|
|
55
|
+
let mirrored = mirror ?? (local && port?.cameraFacing() != "back")
|
|
56
|
+
videoView.mirrorMode = mirrored ? .mirror : .off
|
|
57
|
+
videoView.layoutMode = fit == "contain" ? .fit : .fill
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Detach from any track. Idempotent.
|
|
61
|
+
func release() {
|
|
62
|
+
guard !released else { return }
|
|
63
|
+
released = true
|
|
64
|
+
videoView.track = nil
|
|
65
|
+
port?.unregister(self)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Outgoing ringback. The incoming ringtone is CallKit's own; the caller's
|
|
5
|
+
/// "ring ring" is a host-bundled tone (configure options.ringback, looked
|
|
6
|
+
/// up in the main bundle as mp3/caf/wav), else silence. Played inside the
|
|
7
|
+
/// CallKit-activated session, so it follows the call route (earpiece).
|
|
8
|
+
final class Ringback {
|
|
9
|
+
private var player: AVAudioPlayer?
|
|
10
|
+
private var wanted = false
|
|
11
|
+
var resourceName: () -> String?
|
|
12
|
+
|
|
13
|
+
init(resourceName: @escaping () -> String?) {
|
|
14
|
+
self.resourceName = resourceName
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func start() {
|
|
18
|
+
wanted = true
|
|
19
|
+
play()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func stop() {
|
|
23
|
+
wanted = false
|
|
24
|
+
player?.stop()
|
|
25
|
+
player = nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// The session just became active: a ringback asked for earlier starts now.
|
|
29
|
+
func sessionActivated() {
|
|
30
|
+
if wanted && player?.isPlaying != true { play() }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private func play() {
|
|
34
|
+
guard let url = Ringback.url(for: resourceName()) else { return }
|
|
35
|
+
do {
|
|
36
|
+
if player == nil {
|
|
37
|
+
let p = try AVAudioPlayer(contentsOf: url)
|
|
38
|
+
p.numberOfLoops = -1
|
|
39
|
+
p.volume = 0.6
|
|
40
|
+
p.prepareToPlay()
|
|
41
|
+
player = p
|
|
42
|
+
}
|
|
43
|
+
player?.play()
|
|
44
|
+
} catch {
|
|
45
|
+
NSLog("[PoolseCalls] ringback failed: %@", "\(error)")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static func url(for name: String?) -> URL? {
|
|
50
|
+
guard let name = name, !name.isEmpty else { return nil }
|
|
51
|
+
let ns = name as NSString
|
|
52
|
+
if !ns.pathExtension.isEmpty,
|
|
53
|
+
let u = Bundle.main.url(forResource: ns.deletingPathExtension, withExtension: ns.pathExtension) {
|
|
54
|
+
return u
|
|
55
|
+
}
|
|
56
|
+
for ext in ["mp3", "caf", "wav", "m4a"] {
|
|
57
|
+
if let u = Bundle.main.url(forResource: name, withExtension: ext) { return u }
|
|
58
|
+
}
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
}
|