oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import AVFoundation
2import Foundation
3import LiveKitWebRTC
4import os
5
6#if os(macOS)
7 import AppKit
8#else
9 import UIKit
10#endif
11
12/// One camera the person can choose, named the way a menu wants it.
13///
14/// The id is `AVCaptureDevice.uniqueID`, which survives unplugging and
15/// replugging the same camera, so a remembered choice means the same
16/// device next time rather than whatever now sits at that index.
17public struct CameraDevice: Identifiable, Hashable, Sendable {
18 public let id: String
19 public let name: String
20 /// The Mac's own camera (the iPhone's front one): where a call goes
21 /// when the remembered camera is not connected.
22 public let builtIn: Bool
23 /// `AVCaptureDevice.modelID`: the same for the same kind of camera
24 /// wherever it is plugged in. A USB camera's `uniqueID` is built from
25 /// the port it is on, so an ATEM Mini moved to another port (or through
26 /// a different hub) comes back under a new id; the model, and failing
27 /// that the name, is how a remembered choice still finds it.
28 public let model: String
29 /// `AVCaptureDevice.isSuspended`: listed and connected, but the system
30 /// will not let it capture. On a Mac this is the built-in camera with
31 /// the lid closed, where it opens and never sends a frame.
32 public let suspended: Bool
33 public init(id: String, name: String, builtIn: Bool = false, model: String = "", suspended: Bool = false) {
34 self.id = id
35 self.name = name
36 self.builtIn = builtIn
37 self.model = model
38 self.suspended = suspended
39 }
40}
41
42// Capture is injected separately from signaling and rendering. App targets
43// provide the platform camera; the interop executable provides synthetic frames.
44@MainActor public protocol VideoCapture: AnyObject {
45 func start(source: RTCVideoSource) async throws
46 func stop()
47
48 /// The cameras this capture can offer, and which one it is on. A capture
49 /// with nothing to choose between answers an empty list, and the picker
50 /// does not appear — which is what the interop executable's synthetic
51 /// frames want, and why these carry defaults rather than being required.
52 var cameras: [CameraDevice] { get }
53 var currentCamera: String? { get }
54 func use(camera id: String) async throws
55
56 /// Whether the call should always show a Camera row, even with no
57 /// camera to list: true for real cameras, where "No camera found" is
58 /// something a person needs to be told.
59 var offersCameras: Bool { get }
60 /// What the camera is doing, for the self tile and the panel. Nil for a
61 /// capture that does not say.
62 var cameraStatus: CameraStatus? { get }
63 /// A fallback worth saying ("ATEM Mini disconnected; using FaceTime HD
64 /// Camera"), and usually nothing.
65 var cameraNote: String { get }
66 /// The camera in use when it is working and is not the remembered one:
67 /// what the panel offers to remember ("Use Blackmagic Design from now
68 /// on"). Nil otherwise.
69 var cameraToKeep: CameraDevice? { get }
70 /// Called on the main actor whenever any of the above changes on its
71 /// own — a camera connecting, a fallback, the first frame.
72 func observe(_ changed: @escaping @MainActor () -> Void)
73 /// The panel's "Try again".
74 func retryCamera() async
75}
76
77extension VideoCapture {
78 public var cameras: [CameraDevice] { [] }
79 public var currentCamera: String? { nil }
80 public func use(camera id: String) async throws {}
81 public var offersCameras: Bool { false }
82 public var cameraStatus: CameraStatus? { nil }
83 public var cameraNote: String { "" }
84 public var cameraToKeep: CameraDevice? { nil }
85 public func observe(_ changed: @escaping @MainActor () -> Void) {}
86 public func retryCamera() async {}
87}
88
89/// A screen, as §5.4 sends one: frames into a source of its own, which
90/// the call carries on a `sendonly` transceiver beside the camera's.
91///
92/// `start` returns once the person has chosen what to share and frames
93/// are on their way; a refusal at the system's own picker is a throw, and
94/// nothing has been announced. `ended` is for the capture ending on its
95/// own — the system's stop control, a shared window closing, a broadcast
96/// stopped from Control Center — and is called at most once, on the main
97/// actor. `stop` is the call ending it, and calls nothing back.
98@MainActor public protocol ScreenCapture: AnyObject {
99 func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws
100 func stop()
101 /// What the capture has produced so far, for the screen watchdog.
102 var counts: ScreenCaptureCounts { get }
103 /// Starts the same capture again — the same content into the same
104 /// source — after the watchdog has seen it stall. False when this kind
105 /// of capture cannot, or the attempt failed.
106 func restart() async -> Bool
107 /// The size the capture delivers frames at, when it knows before the
108 /// first frame: the send policy's H.264 level cap (ScreenSendPolicy)
109 /// then holds from the first frame rather than from the first
110 /// statistics a second later, a second in which a browser's level-3.1
111 /// encoder refuses every frame of a Motion share (spec §15.22).
112 var frameSize: (width: Int, height: Int)? { get }
113}
114
115extension ScreenCapture {
116 public var counts: ScreenCaptureCounts { ScreenCaptureCounts() }
117 public func restart() async -> Bool { false }
118 public var frameSize: (width: Int, height: Int)? { nil }
119}
120
121/// A screen capture's running totals (see ScreenWatchdog).
122public struct ScreenCaptureCounts: Equatable, Sendable {
123 /// Everything the capture has reported, still-screen reports included:
124 /// ScreenCaptureKit reports "idle" at the frame rate while nothing on
125 /// screen changes (measured 2026-09-28: 177 idle reports in six seconds
126 /// of a still window), so a capture that reports nothing at all has
127 /// stopped, not gone quiet. Nil for a capture that cannot tell the two
128 /// apart — a ReplayKit broadcast sends nothing for a still screen —
129 /// which turns the watchdog's capture check off for it.
130 public var heartbeats: UInt64?
131 /// Frames handed to the call's source, repeats of a still screen included.
132 public var delivered: UInt64 = 0
133
134 public init(heartbeats: UInt64? = nil, delivered: UInt64 = 0) {
135 self.heartbeats = heartbeats
136 self.delivered = delivered
137 }
138}
139
140// ---------------------------------------------------------------------
141// The managed camera: device choice, recovery and the watchdog
142// ---------------------------------------------------------------------
143
144/// Where frames actually come from, under the recovery rules: AVFoundation
145/// in the apps, synthetic frames in the interop gate. Everything about
146/// *which* camera and *when to try again* lives in `ManagedCamera` and
147/// `CameraDirector`; a backend only lists, opens and closes.
148@MainActor public protocol CameraBackend: AnyObject {
149 /// Asks for camera (and microphone) permission. Throws when refused.
150 func requestAccess() async throws
151 /// The cameras connected now.
152 var devices: [CameraDevice] { get }
153 /// How many formats `open` can be asked for on this device, ranked.
154 func formatCount(_ id: String) -> Int
155 /// Opens the device in its ranked format, delivering frames to
156 /// `delegate`. Throws when it will not open.
157 func open(_ id: String, format: Int, delegate: RTCVideoCapturerDelegate) async throws
158 func close() async
159 /// Set by the managed camera: the device list changed.
160 var devicesChanged: (@MainActor () -> Void)? { get set }
161 /// Set by the managed camera: the session reported an error or an
162 /// interruption, or the machine woke. Capture should be restarted.
163 var trouble: (@MainActor () -> Void)? { get set }
164}
165
166/// Counts frames on their way to the video source, which is all the
167/// watchdog needs to know whether a camera is working. The capturer holds
168/// its delegate weakly, so the managed camera holds this.
169final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendable {
170 private let source: RTCVideoSource
171 private let lock = OSAllocatedUnfairLock(initialState: UInt64(0))
172 init(source: RTCVideoSource) { self.source = source }
173 var count: UInt64 { lock.withLock { $0 } }
174 func capturer(_ capturer: RTCVideoCapturer, didCapture frame: RTCVideoFrame) {
175 lock.withLock { $0 += 1 }
176 source.capturer(capturer, didCapture: frame)
177 }
178}
179
180/// A camera under the recovery rules (see `CameraDirector`): remembered
181/// between calls, followed as devices come and go, and watched for frames.
182///
183/// Every open and close runs one at a time, and an open the director has
184/// since moved on from is skipped, so the last decision is the one that
185/// holds.
186@MainActor public final class ManagedCamera: VideoCapture {
187 private let backend: CameraBackend
188 private let director: CameraDirector
189 private var counter: FrameCounter?
190 private var work: Task<Void, Never>?
191 private var ticker: Task<Void, Never>?
192 private var running = false
193 private var changed: (@MainActor () -> Void)?
194 private(set) public var currentCamera: String?
195 /// Opens performed, for the gate: a stall that recovered is one more.
196 public private(set) var opens = 0
197 private let clock: () -> TimeInterval
198 var tickInterval: Duration = .milliseconds(500)
199
200 public init(
201 backend: CameraBackend, defaults: UserDefaults = .standard,
202 clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
203 ) {
204 self.backend = backend
205 self.clock = clock
206 director = CameraDirector(defaults: defaults, formats: { [weak backend] in backend?.formatCount($0) ?? 1 })
207 backend.devicesChanged = { [weak self] in self?.devicesChanged() }
208 backend.trouble = { [weak self] in self?.trouble() }
209 }
210
211 public var cameras: [CameraDevice] { backend.devices }
212 public var offersCameras: Bool { true }
213 public var cameraStatus: CameraStatus? { running ? director.status : nil }
214 public var cameraNote: String { running ? director.note : "" }
215 public var cameraToKeep: CameraDevice? { running ? director.cameraToKeep : nil }
216 public func observe(_ changed: @escaping @MainActor () -> Void) { self.changed = changed }
217
218 public func start(source: RTCVideoSource) async throws {
219 try await backend.requestAccess()
220 counter = FrameCounter(source: source)
221 running = true
222 ticker = Task { @MainActor [weak self] in
223 var slow = 0
224 while !Task.isCancelled {
225 guard let self else { return }
226 try? await Task.sleep(for: self.tickInterval)
227 guard !Task.isCancelled else { return }
228 self.tick()
229 // Belt and braces for the device notifications: while there is no
230 // working camera, look again every few seconds.
231 slow += 1
232 if slow % 6 == 0, !(self.director.status.live) { self.devicesChanged() }
233 }
234 }
235 // Returns once the first open has been tried, so a call starts with
236 // its camera (or its reason for having none) already known.
237 await perform(director.start(devices: backend.devices))
238 }
239
240 public func stop() {
241 running = false
242 ticker?.cancel()
243 ticker = nil
244 director.stop()
245 currentCamera = nil
246 let backend = backend
247 let previous = work
248 work = Task { @MainActor in
249 await previous?.value
250 await backend.close()
251 }
252 counter = nil
253 }
254
255 public func use(camera id: String) async throws {
256 guard running else { return }
257 guard let command = director.choose(id) else {
258 throw NSError(
259 domain: "Calls", code: 4,
260 userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."])
261 }
262 await perform(command)
263 }
264
265 public func retryCamera() async {
266 guard running else { return }
267 await perform(director.retry())
268 }
269
270 private func tick() {
271 guard running, let counter else { return }
272 #if os(iOS)
273 // iOS stops every camera while the app is in the background; that
274 // is not a stall to recover from.
275 if UIApplication.shared.applicationState != .active {
276 director.hold(frames: counter.count, at: clock())
277 return
278 }
279 #endif
280 if let command = director.tick(frames: counter.count, at: clock()) {
281 Task { await self.perform(command) }
282 }
283 notify()
284 }
285
286 private func devicesChanged() {
287 guard running else {
288 changed?()
289 return
290 }
291 if let command = director.devicesChanged(backend.devices) {
292 Task { await self.perform(command) }
293 }
294 notify()
295 }
296
297 private func trouble() {
298 guard running else { return }
299 if let command = director.restart(at: clock()) {
300 Task { await self.perform(command) }
301 }
302 }
303
304 private var published: (CameraStatus, String, String?, CameraDevice?)?
305 private func notify() {
306 let now = (director.status, director.note, currentCamera, director.cameraToKeep)
307 if let published, published == now { return }
308 published = now
309 changed?()
310 }
311
312 /// Runs a command after whatever is running, and returns when it has.
313 private func perform(_ command: CameraCommand?) async {
314 guard let command else {
315 notify()
316 return
317 }
318 let previous = work
319 let task = Task { @MainActor [weak self] in
320 await previous?.value
321 await self?.execute(command)
322 }
323 work = task
324 await task.value
325 }
326
327 private func execute(_ command: CameraCommand) async {
328 guard running else { return }
329 switch command {
330 case .stop:
331 await backend.close()
332 currentCamera = nil
333 notify()
334 case .open(let id, let format):
335 // The director has moved on (a later pick, a fallback): skip.
336 guard director.active == id, director.format == format, let counter else { return }
337 await backend.close()
338 currentCamera = nil
339 notify()
340 guard running, director.active == id else { return }
341 do {
342 try await backend.open(id, format: format, delegate: counter)
343 opens += 1
344 guard running, director.active == id else { return }
345 currentCamera = id
346 director.opened(id, at: clock(), frames: counter.count)
347 } catch {
348 guard running, director.active == id else { return }
349 if let next = director.openFailed(id) {
350 notify()
351 await execute(next)
352 return
353 }
354 }
355 notify()
356 }
357 }
358}
359
360// ---------------------------------------------------------------------
361// AVFoundation
362// ---------------------------------------------------------------------
363
364/// The cameras connected to this device, followed as they come and go:
365/// AVFoundation's connect and disconnect notifications, and on Mac the
366/// discovery session's device list by KVO. One for the process, so the
367/// panel can list cameras whether or not a capture is running.
368@MainActor public final class CameraWatch {
369 public static let shared = CameraWatch()
370 public private(set) var cameras: [CameraDevice] = []
371 private var listeners: [UUID: @MainActor () -> Void] = [:]
372 private var tokens: [NSObjectProtocol] = []
373 /// Each listed camera's `isSuspended`, watched: the lid closing or
374 /// opening changes it without any camera connecting or leaving.
375 private var suspension: [String: NSKeyValueObservation] = [:]
376 #if os(macOS)
377 private let session = AVCaptureDevice.DiscoverySession(
378 deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
379 mediaType: .video, position: .unspecified)
380 private var kvo: NSKeyValueObservation?
381 #endif
382
383 private init() {
384 for name in [AVCaptureDevice.wasConnectedNotification, AVCaptureDevice.wasDisconnectedNotification] {
385 tokens.append(
386 NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { _ in
387 MainActor.assumeIsolated { CameraWatch.shared.refresh() }
388 })
389 }
390 #if os(macOS)
391 kvo = session.observe(\.devices, options: [.new]) { _, _ in
392 DispatchQueue.main.async { MainActor.assumeIsolated { CameraWatch.shared.refresh() } }
393 }
394 #endif
395 cameras = Self.list(devices)
396 watchSuspension()
397 }
398
399 private func watchSuspension() {
400 let current = devices
401 let ids = Set(current.map(\.uniqueID))
402 for id in suspension.keys where !ids.contains(id) { suspension.removeValue(forKey: id) }
403 for device in current where suspension[device.uniqueID] == nil {
404 suspension[device.uniqueID] = device.observe(\.isSuspended, options: [.new]) { _, _ in
405 DispatchQueue.main.async { MainActor.assumeIsolated { CameraWatch.shared.refresh() } }
406 }
407 }
408 }
409
410 var devices: [AVCaptureDevice] {
411 #if os(macOS)
412 session.devices
413 #else
414 RTCCameraVideoCapturer.captureDevices()
415 #endif
416 }
417
418 func device(_ id: String) -> AVCaptureDevice? { devices.first { $0.uniqueID == id } }
419
420 static func list(_ devices: [AVCaptureDevice]) -> [CameraDevice] {
421 devices.filter(\.isConnected).map {
422 #if os(macOS)
423 let builtIn = $0.deviceType == .builtInWideAngleCamera
424 #else
425 let builtIn = $0.position == .front
426 #endif
427 return CameraDevice(
428 id: $0.uniqueID, name: $0.localizedName, builtIn: builtIn, model: $0.modelID,
429 suspended: $0.isSuspended)
430 }
431 }
432
433 public func refresh() {
434 let now = Self.list(devices)
435 guard now != cameras else { return }
436 watchSuspension()
437 cameras = now
438 for listener in listeners.values { listener() }
439 }
440
441 func listen(_ listener: @escaping @MainActor () -> Void) -> UUID {
442 let id = UUID()
443 listeners[id] = listener
444 return id
445 }
446 func unlisten(_ id: UUID) { listeners.removeValue(forKey: id) }
447}
448
449/// The formats a camera is opened in, best first, and the frame rate to
450/// ask for in each — held apart from AVFoundation so the rule can be
451/// tested with numbers.
452///
453/// Nearest 640 wide first (a call does not need more, and the encoder
454/// scales down anyway), then nearest 30 frames a second without going
455/// over, then the device's own order. The rate asked for is always one
456/// the format supports: 30 where a range covers it, otherwise the
457/// fastest rate at or under 30, otherwise the slowest the format offers.
458/// An ATEM Mini offers 1080p at exactly 23.98, 24, 25, 29.97, 30, 50,
459/// 59.94 and 60 — ranges of one rate each — and asking one of those for a
460/// rate outside its range is an exception AVFoundation raises and WebRTC
461/// swallows, leaving the camera at whatever rate it was on.
462public struct CameraFormatChoice: Equatable, Sendable {
463 public struct Candidate: Equatable, Sendable {
464 public var width: Int
465 public var height: Int
466 /// Each supported range as (min, max) frames a second.
467 public var ranges: [ClosedRange<Double>]
468 public init(width: Int, height: Int, ranges: [ClosedRange<Double>]) {
469 self.width = width
470 self.height = height
471 self.ranges = ranges
472 }
473 }
474
475 /// The rate to run a format at, and whether it is a whole number WebRTC
476 /// can be given as-is.
477 public static func rate(for ranges: [ClosedRange<Double>], target: Double = 30) -> Double? {
478 guard !ranges.isEmpty else { return nil }
479 if ranges.contains(where: { $0.contains(target) }) { return target }
480 let under = ranges.map(\.upperBound).filter { $0 <= target }
481 if let best = under.max() { return best }
482 return ranges.map(\.lowerBound).min()
483 }
484
485 /// The candidates' indexes, best first, one per distinct size and rate.
486 public static func rank(_ candidates: [Candidate], width target: Int = 640) -> [Int] {
487 var seen = Set<String>()
488 return candidates.indices.sorted { a, b in
489 let ca = candidates[a], cb = candidates[b]
490 let wa = abs(ca.width - target), wb = abs(cb.width - target)
491 if wa != wb { return wa < wb }
492 let ra = 30 - (rate(for: ca.ranges) ?? 0), rb = 30 - (rate(for: cb.ranges) ?? 0)
493 if abs(ra) != abs(rb) { return (ra >= 0 ? ra : 1000 - ra) < (rb >= 0 ? rb : 1000 - rb) }
494 return a < b
495 }.filter {
496 let c = candidates[$0]
497 let key = "\(c.width)x\(c.height)@\(rate(for: c.ranges) ?? 0)"
498 return seen.insert(key).inserted
499 }
500 }
501}
502
503/// AVFoundation's camera, through WebRTC's capturer.
504@MainActor final class AVCameraBackend: CameraBackend {
505 private var capturer: RTCCameraVideoCapturer?
506 private var observed: [NSObjectProtocol] = []
507 private var listener: UUID?
508 #if os(macOS)
509 private var woke: NSObjectProtocol?
510 #endif
511 var devicesChanged: (@MainActor () -> Void)?
512 var trouble: (@MainActor () -> Void)?
513 /// How long an open may take before it counts as refused. A camera
514 /// that re-enumerates mid-open can leave the completion uncalled.
515 static let openTimeout: TimeInterval = 6
516
517 init() {
518 let watch = CameraWatch.shared
519 listener = watch.listen { [weak self] in self?.devicesChanged?() }
520 #if os(macOS)
521 woke = (
522 NSWorkspace.shared.notificationCenter.addObserver(
523 forName: NSWorkspace.didWakeNotification, object: nil, queue: .main
524 ) { [weak self] _ in
525 MainActor.assumeIsolated {
526 CameraWatch.shared.refresh()
527 self?.trouble?()
528 }
529 })
530 #endif
531 }
532
533 func requestAccess() async throws {
534 for kind in [AVMediaType.video, .audio] {
535 let granted = await AVCaptureDevice.requestAccess(for: kind)
536 if !granted {
537 throw NSError(
538 domain: "Calls", code: 2,
539 userInfo: [NSLocalizedDescriptionKey: "Allow camera and microphone access in Settings to join."])
540 }
541 }
542 }
543
544 var devices: [CameraDevice] {
545 CameraWatch.shared.refresh()
546 return CameraWatch.shared.cameras
547 }
548
549 private func formats(_ device: AVCaptureDevice) -> [AVCaptureDevice.Format] {
550 let all = RTCCameraVideoCapturer.supportedFormats(for: device)
551 let candidates = all.map { format -> CameraFormatChoice.Candidate in
552 let size = CMVideoFormatDescriptionGetDimensions(format.formatDescription)
553 return CameraFormatChoice.Candidate(
554 width: Int(size.width), height: Int(size.height),
555 ranges: format.videoSupportedFrameRateRanges.map { $0.minFrameRate...$0.maxFrameRate })
556 }
557 return CameraFormatChoice.rank(candidates).map { all[$0] }
558 }
559
560 func formatCount(_ id: String) -> Int {
561 guard let device = CameraWatch.shared.device(id) else { return 0 }
562 return formats(device).count
563 }
564
565 func open(_ id: String, format index: Int, delegate: RTCVideoCapturerDelegate) async throws {
566 guard let device = CameraWatch.shared.device(id) else {
567 throw NSError(
568 domain: "Calls", code: 4, userInfo: [NSLocalizedDescriptionKey: "That camera is no longer available."])
569 }
570 let ranked = formats(device)
571 guard !ranked.isEmpty else {
572 throw NSError(
573 domain: "Calls", code: 3, userInfo: [NSLocalizedDescriptionKey: "No camera is available."])
574 }
575 let format = ranked[min(index, ranked.count - 1)]
576 let ranges = format.videoSupportedFrameRateRanges
577 let rate = CameraFormatChoice.rate(for: ranges.map { $0.minFrameRate...$0.maxFrameRate }) ?? 30
578 let capture = capturer ?? makeCapturer(delegate: delegate)
579 // WebRTC takes a whole number of frames a second. For a rate that is
580 // not one (29.97), it is given the nearest, and the exact duration
581 // from the format's own range is set on the device afterwards.
582 let fps = max(1, Int(rate.rounded()))
583 let once = OSAllocatedUnfairLock(initialState: false)
584 do {
585 try await start(capture, device: device, format: format, fps: fps, once: once)
586 } catch {
587 // A failed or abandoned open may still finish later on the
588 // capturer's own queue and start delivering frames from this
589 // device. The capturer is cut loose — no delegate, stopped, not
590 // reused — so those frames reach nobody, and the next open gets a
591 // fresh one.
592 capture.delegate = nil
593 capture.stopCapture(completionHandler: {})
594 capturer = nil
595 throw error
596 }
597 if Double(fps) != rate,
598 let range = ranges.first(where: { $0.minFrameRate <= rate && rate <= $0.maxFrameRate })
599 {
600 let duration = rate == range.maxFrameRate ? range.minFrameDuration : range.maxFrameDuration
601 if (try? device.lockForConfiguration()) != nil {
602 device.activeVideoMinFrameDuration = duration
603 device.activeVideoMaxFrameDuration = duration
604 device.unlockForConfiguration()
605 }
606 }
607 }
608
609 private func start(
610 _ capture: RTCCameraVideoCapturer, device: AVCaptureDevice, format: AVCaptureDevice.Format,
611 fps: Int, once: OSAllocatedUnfairLock<Bool>
612 ) async throws {
613 try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
614 let finish: @Sendable (Error?) -> Void = { error in
615 let first = once.withLock { done -> Bool in
616 defer { done = true }
617 return !done
618 }
619 guard first else { return }
620 if let error { continuation.resume(throwing: error) } else { continuation.resume() }
621 }
622 capture.startCapture(with: device, format: format, fps: fps) { finish($0) }
623 DispatchQueue.main.asyncAfter(deadline: .now() + AVCameraBackend.openTimeout) {
624 finish(
625 NSError(
626 domain: "Calls", code: 6, userInfo: [NSLocalizedDescriptionKey: "The camera did not start."]))
627 }
628 }
629 }
630
631 private func makeCapturer(delegate: RTCVideoCapturerDelegate) -> RTCCameraVideoCapturer {
632 let capture = RTCCameraVideoCapturer(delegate: delegate)
633 capturer = capture
634 let session = capture.captureSession
635 // A runtime error, or an interruption ending: WebRTC's capturer
636 // retries a runtime error once itself; these restart it again, rate
637 // limited by the director.
638 for name in [
639 AVCaptureSession.runtimeErrorNotification, AVCaptureSession.interruptionEndedNotification,
640 ] {
641 observed.append(
642 NotificationCenter.default.addObserver(forName: name, object: session, queue: .main) {
643 [weak self] _ in
644 MainActor.assumeIsolated { self?.trouble?() }
645 })
646 }
647 return capture
648 }
649
650 func close() async {
651 guard let capturer else { return }
652 await capturer.stopCapture()
653 }
654
655 deinit {
656 for token in observed { NotificationCenter.default.removeObserver(token) }
657 #if os(macOS)
658 if let woke { NSWorkspace.shared.notificationCenter.removeObserver(woke) }
659 #endif
660 if let listener {
661 Task { @MainActor in CameraWatch.shared.unlisten(listener) }
662 }
663 }
664}
665
666/// The platform camera: AVFoundation under the recovery rules.
667@MainActor final class CameraCapture {
668 static func make() -> ManagedCamera { ManagedCamera(backend: AVCameraBackend()) }
669}
670