@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.
Files changed (34) hide show
  1. package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +25 -8
  2. package/android/src/main/java/dev/poolse/callcore/android/CallVideo.kt +36 -0
  3. package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
  4. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallVideoView.kt +64 -0
  5. package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +10 -0
  6. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +142 -14
  7. package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitVideoSurface.kt +107 -0
  8. package/dist/index.cjs +34 -0
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +44 -2
  11. package/dist/index.d.ts +44 -2
  12. package/dist/index.js +33 -2
  13. package/dist/index.js.map +1 -1
  14. package/expo-module.config.json +6 -1
  15. package/ios/Core/CallCoreRuntime.swift +393 -0
  16. package/ios/Core/CallHttpClient.swift +210 -0
  17. package/ios/Core/CallMachine.swift +304 -0
  18. package/ios/Core/PORTS_READY.md +93 -0
  19. package/ios/Core/Ports.swift +193 -0
  20. package/ios/Core/SnapshotEncoding.swift +75 -0
  21. package/ios/Module/PoolseCallVideoView.swift +62 -0
  22. package/ios/Module/PoolseCallsAppDelegateSubscriber.swift +16 -0
  23. package/ios/Module/PoolseCallsModule.swift +111 -0
  24. package/ios/Platform/CallCore.swift +464 -0
  25. package/ios/Platform/CallKitController.swift +263 -0
  26. package/ios/Platform/LiveKitMediaPort.swift +445 -0
  27. package/ios/Platform/LiveKitVideoSurface.swift +67 -0
  28. package/ios/Platform/Ringback.swift +61 -0
  29. package/ios/Platform/VoipPush.swift +172 -0
  30. package/ios/PoolseCalls.podspec +64 -0
  31. package/package.json +7 -1
  32. package/src/expo-modules-core.d.ts +5 -0
  33. package/src/index.ts +85 -2
  34. 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
+ }