oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3/// What the camera is doing, said the way the self tile and the device
4/// panel say it.
5///
6/// This exists because of a report against Calls 0.3.1: an ATEM Mini that
7/// had dropped off USB left the self tile on "Waiting for video" and the
8/// device panel with no Camera row at all, and nothing on screen said what
9/// had happened or offered a way back. Every state here has words of its
10/// own, and none of them is "waiting" without saying for what.
11public enum CameraStatus: Equatable, Sendable {
12 /// Opening a camera, and no frame from it yet.
13 case starting(String)
14 /// Frames are arriving from the named camera.
15 case live(String)
16 /// The camera opened but sends nothing — an ATEM with no HDMI input, or
17 /// one that is changing format.
18 case noSignal(String)
19 /// The camera in use went away and there is no other to use.
20 case disconnected(String)
21 /// There is no camera at all.
22 case noCamera
23 /// The camera would not open, and there is no other to use.
24 case failed(String)
25
26 /// What the self tile says in place of video. Nil while frames arrive.
27 public var tileText: String? {
28 switch self {
29 case .starting: return "Starting camera…"
30 case .live: return nil
31 case .noSignal(let name): return "No signal from \(name) — check its input"
32 case .disconnected: return "Camera disconnected"
33 case .noCamera: return "No camera found"
34 case .failed(let name): return "\(name) would not start"
35 }
36 }
37
38 /// The plain-words line under the camera picker. Nil when all is well.
39 public var panelText: String? {
40 switch self {
41 case .live: return nil
42 case .starting(let name): return "Starting \(name)…"
43 case .noSignal(let name):
44 return "\(name) is connected but sending no picture. Check its input, or choose another camera."
45 case .disconnected(let name): return "\(name) disconnected. Calls will use it again when it is back."
46 case .noCamera: return "No camera found"
47 case .failed(let name): return "\(name) would not start. Try again, or choose another camera."
48 }
49 }
50
51 var live: Bool {
52 if case .live = self { return true }
53 return false
54 }
55 var disconnected: Bool {
56 if case .disconnected = self { return true }
57 return false
58 }
59}
60
61/// The person's camera, remembered between calls and launches.
62///
63/// Kept by `uniqueID`, with the model and name beside it. The uniqueID of
64/// a USB camera follows the port it is plugged into, so the same ATEM
65/// Mini on another port is a new id; `find` falls back to the model and
66/// then the name, so the remembered camera is still found. The name also
67/// lets the panel say which camera it is waiting for while it is gone.
68///
69/// Only a person's own pick writes this (`CameraDirector.choose`, and the
70/// panel before a call). A fallback, a skip and a first-run default never
71/// do: which camera a call is on is not the same as which one you chose.
72public struct CameraPreference: Equatable, Sendable {
73 public var id: String
74 public var name: String
75 /// The camera's model, for finding it again under a new id. Empty for a
76 /// choice saved before 0.3.4, which is then found by name.
77 public var model: String
78 public init(id: String, name: String, model: String = "") {
79 self.id = id
80 self.name = name
81 self.model = model
82 }
83 public init(_ device: CameraDevice) {
84 self.init(id: device.id, name: device.name, model: device.model)
85 }
86
87 static let idKey = "preferredCameraID"
88 static let nameKey = "preferredCameraName"
89 static let modelKey = "preferredCameraModel"
90
91 public static func load(_ defaults: UserDefaults = .standard) -> CameraPreference? {
92 guard let id = defaults.string(forKey: idKey), !id.isEmpty else { return nil }
93 return CameraPreference(
94 id: id, name: defaults.string(forKey: nameKey) ?? "", model: defaults.string(forKey: modelKey) ?? "")
95 }
96
97 /// The connected camera this preference means: by id, then by model,
98 /// then by name.
99 public func find(in devices: [CameraDevice]) -> CameraDevice? {
100 if let device = devices.first(where: { $0.id == id }) { return device }
101 if !model.isEmpty, let device = devices.first(where: { $0.model == model }) { return device }
102 if !name.isEmpty, let device = devices.first(where: { $0.name == name }) { return device }
103 return nil
104 }
105
106 public func save(_ defaults: UserDefaults = .standard) {
107 defaults.set(id, forKey: Self.idKey)
108 defaults.set(name, forKey: Self.nameKey)
109 defaults.set(model, forKey: Self.modelKey)
110 }
111}
112
113/// The first-frame and stall watchdog, as a pure state machine over a
114/// frame counter and a clock.
115///
116/// A camera that opens is not a camera that works: the ATEM Mini opens
117/// happily with nothing plugged into it and delivers no frame, ever. So
118/// the counter the capturer feeds is watched, and a camera with no first
119/// frame within `firstFrame`, or no frame for `stall` after it had been
120/// sending, is escalated one step at a time:
121///
122/// 1. restart capture on the same device and format,
123/// 2. try the device's next format,
124/// 3. fall back to another camera.
125///
126/// Frames arriving again at any point put it back at the bottom of the
127/// ladder. Each step waits for the capture to be opened again before
128/// the clock restarts, so a slow open is never counted as a stall.
129struct CameraWatchdog: Equatable {
130 enum Step: Equatable { case restart, nextFormat, fallback }
131
132 var firstFrame: TimeInterval = 3
133 var stall: TimeInterval = 3
134
135 /// How far up the ladder this device has gone since its last frame.
136 private(set) var level = 0
137 /// Whether a frame has arrived since the capture was last opened.
138 private(set) var flowing = false
139 /// When the clock started: the open, or the last new frame. Nil while
140 /// nothing is being watched (between a step and the next open).
141 private var since: TimeInterval?
142 private var count: UInt64 = 0
143 /// Top of the ladder with nowhere to go: no more steps, but a frame
144 /// arriving still counts as the camera working again.
145 private(set) var exhausted = false
146
147 /// The capture has (re)opened. The clock starts for a first frame.
148 mutating func opened(at now: TimeInterval, frames: UInt64) {
149 since = now
150 count = frames
151 flowing = false
152 exhausted = false
153 }
154
155 /// Nothing left to try: stop escalating, keep listening for frames.
156 mutating func giveUp() {
157 since = nil
158 exhausted = true
159 }
160
161 /// A different camera, or the person's own choice: the ladder starts
162 /// again from the bottom.
163 mutating func reset() {
164 level = 0
165 since = nil
166 flowing = false
167 exhausted = false
168 }
169
170 /// Stops watching until the next open.
171 mutating func pause() { since = nil }
172
173 /// Frames are not expected just now (an iPhone app in the background):
174 /// the clock is held where it is, without climbing the ladder.
175 mutating func hold(at now: TimeInterval, frames: UInt64) {
176 guard since != nil else { return }
177 since = now
178 count = frames
179 }
180
181 /// Called on every tick with the frame counter. Answers the next step
182 /// when one is due, and stops watching until `opened` is called again.
183 mutating func observe(frames: UInt64, at now: TimeInterval) -> Step? {
184 if frames != count {
185 count = frames
186 if since != nil || exhausted {
187 since = now
188 flowing = true
189 level = 0
190 exhausted = false
191 }
192 return nil
193 }
194 guard let started = since else { return nil }
195 guard now - started >= (flowing ? stall : firstFrame) else { return nil }
196 since = nil
197 let step: Step = level == 0 ? .restart : level == 1 ? .nextFormat : .fallback
198 level = min(level + 1, 2)
199 return step
200 }
201}
202
203/// What the camera capture should do next.
204enum CameraCommand: Equatable {
205 /// Open this device with the format at this rank (0 is the best).
206 case open(String, format: Int)
207 /// Stop capturing: there is nothing to capture from.
208 case stop
209}
210
211/// The rules that decide which camera a call is on, and what it says
212/// about it — apart from AVFoundation, so they can be held by tests.
213///
214/// - At join the remembered camera is used if it is connected, otherwise
215/// the built-in one, otherwise whatever there is.
216/// - A suspended camera (a Mac's built-in one with the lid closed, which
217/// is listed but never sends a frame) is passed over while there is
218/// another, and the panel says why ("MacBook Air Camera is off while
219/// the lid is closed; using Blackmagic Design"). It is still the
220/// remembered camera: opening the lid, mid-call or before the next one,
221/// goes back to it. Closing the lid mid-call falls back at once rather
222/// than waiting out the watchdog.
223/// - Choosing a camera remembers it, including the one the call is
224/// already on by fallback. Nothing else writes the preference. While
225/// the call is on a working camera other than the remembered one, the
226/// panel offers to remember it ("Use Blackmagic Design from now on"):
227/// one tap, never an automatic switch.
228/// - The camera in use disconnecting falls back to another at once, and
229/// says so ("ATEM Mini disconnected; using FaceTime HD Camera").
230/// - The remembered camera reconnecting, or appearing later, is switched
231/// back to, without renegotiation (the capturer feeds the same source).
232/// - A camera that sends nothing is restarted, then tried in its next
233/// format, then fallen back from; with nothing to fall back to, it is
234/// named as having no signal and kept, in case its signal returns.
235/// - Session errors and waking from sleep restart capture, at most once
236/// every `restartSpacing` seconds.
237@MainActor final class CameraDirector {
238 private(set) var devices: [CameraDevice] = []
239 private(set) var active: String?
240 private(set) var format = 0
241 private(set) var status: CameraStatus = .noCamera
242 /// A fallback the person should know about. Cleared by their own
243 /// choice and by the remembered camera coming back.
244 private(set) var note = ""
245 private(set) var watchdog = CameraWatchdog()
246 /// Cameras fallen back from for sending nothing, while they stay
247 /// connected. Not returned to automatically until they reconnect.
248 private var silent: Set<String> = []
249 /// Cameras that would not open, this round.
250 private var refused: Set<String> = []
251 private var lastRestart: TimeInterval = -.infinity
252 var restartSpacing: TimeInterval = 5
253
254 private let defaults: UserDefaults
255 /// How many formats a device has to try, ranked. Supplied by the
256 /// capture; the tests give numbers.
257 private let formats: (String) -> Int
258
259 init(defaults: UserDefaults = .standard, formats: @escaping (String) -> Int) {
260 self.defaults = defaults
261 self.formats = formats
262 }
263
264 var preference: CameraPreference? { CameraPreference.load(defaults) }
265
266 /// The camera in use, when it is working and is not the remembered
267 /// one: what the panel offers to remember.
268 var cameraToKeep: CameraDevice? {
269 guard status.live, let id = active, let device = devices.first(where: { $0.id == id }) else { return nil }
270 guard let preference, preference.find(in: devices)?.id != id else { return nil }
271 return device
272 }
273
274 /// "MacBook Air Camera is off while the lid is closed": what a
275 /// suspended camera is called when it is passed over.
276 nonisolated static func off(_ device: CameraDevice) -> String {
277 #if os(macOS)
278 if device.builtIn { return "\(device.name) is off while the lid is closed" }
279 #endif
280 return "\(device.name) is off"
281 }
282
283 private func name(_ id: String?) -> String {
284 guard let id else { return "The camera" }
285 if let device = devices.first(where: { $0.id == id }) { return device.name }
286 if let preference, preference.id == id, !preference.name.isEmpty { return preference.name }
287 return "The camera"
288 }
289
290 /// The camera a call should be on, given what is connected: the
291 /// remembered one, then the built-in one, then the first — each only if
292 /// it is not suspended, and a suspended one only when there is nothing
293 /// else. Pure, and what `start` and every fallback ask.
294 nonisolated static func choose(
295 _ devices: [CameraDevice], preferred: CameraPreference?, excluding: Set<String> = []
296 ) -> CameraDevice? {
297 let usable = devices.filter { !excluding.contains($0.id) }
298 let awake = usable.filter { !$0.suspended }
299 if let device = preferred?.find(in: awake) { return device }
300 if let device = awake.first(where: \.builtIn) ?? awake.first { return device }
301 return preferred?.find(in: usable) ?? usable.first(where: \.builtIn) ?? usable.first
302 }
303
304 /// A call starts: pick the camera and open it.
305 func start(devices: [CameraDevice]) -> CameraCommand? {
306 self.devices = devices
307 silent = []
308 refused = []
309 note = ""
310 watchdog.reset()
311 return pick(missingNote: true)
312 }
313
314 /// Chooses from scratch, excluding what has failed this round.
315 private func pick(missingNote: Bool) -> CameraCommand? {
316 let preferred = preference
317 guard let device = Self.choose(devices, preferred: preferred, excluding: silent.union(refused))
318 else {
319 active = nil
320 status = devices.isEmpty ? .noCamera : status
321 if devices.isEmpty { note = "" }
322 return .stop
323 }
324 if missingNote, let preferred {
325 if let found = preferred.find(in: devices) {
326 if found.id != device.id, found.suspended { note = "\(Self.off(found)); using \(device.name)" }
327 } else {
328 let missing = preferred.name.isEmpty ? "Your camera" : preferred.name
329 note = "\(missing) isn’t connected; using \(device.name)"
330 }
331 }
332 return open(device.id)
333 }
334
335 private func open(_ id: String, format: Int = 0) -> CameraCommand {
336 if active != id { watchdog.reset() }
337 active = id
338 self.format = format
339 status = .starting(name(id))
340 watchdog.pause()
341 return .open(id, format: format)
342 }
343
344 /// The capture opened `id`; the watchdog starts timing a first frame.
345 func opened(_ id: String, at now: TimeInterval, frames: UInt64) {
346 guard id == active else { return }
347 refused.remove(id)
348 watchdog.opened(at: now, frames: frames)
349 }
350
351 /// The capture could not open `id`. Another camera is tried; with none
352 /// left, the panel says so and offers to try again.
353 func openFailed(_ id: String) -> CameraCommand? {
354 guard id == active else { return nil }
355 let failedName = name(id)
356 refused.insert(id)
357 if let other = Self.choose(devices, preferred: preference, excluding: silent.union(refused)) {
358 note = "\(failedName) would not start; using \(other.name)"
359 return open(other.id)
360 }
361 status = .failed(failedName)
362 watchdog.pause()
363 return nil
364 }
365
366 /// A tick while frames are not expected — the iPhone app in the
367 /// background, where the system stops every camera.
368 func hold(frames: UInt64, at now: TimeInterval) {
369 watchdog.hold(at: now, frames: frames)
370 }
371
372 /// A tick: the frame counter, read.
373 func tick(frames: UInt64, at now: TimeInterval) -> CameraCommand? {
374 guard let id = active else { return nil }
375 let step = watchdog.observe(frames: frames, at: now)
376 if watchdog.flowing, !status.live {
377 status = .live(name(id))
378 silent.remove(id)
379 }
380 guard let step else { return nil }
381 switch step {
382 case .restart:
383 return open(id, format: format)
384 case .nextFormat:
385 if format + 1 < formats(id) { return open(id, format: format + 1) }
386 return fallBack(from: id)
387 case .fallback:
388 return fallBack(from: id)
389 }
390 }
391
392 private func fallBack(from id: String) -> CameraCommand? {
393 let silentName = name(id)
394 if let other = Self.choose(
395 devices, preferred: preference, excluding: silent.union(refused).union([id]))
396 {
397 silent.insert(id)
398 if let device = devices.first(where: { $0.id == id }), device.suspended {
399 note = "\(Self.off(device)); using \(other.name)"
400 } else {
401 note = "No signal from \(silentName); using \(other.name)"
402 }
403 return open(other.id)
404 }
405 // Nothing to fall back to. Stay on it, say so, and keep counting: an
406 // ATEM whose input comes back starts sending on the same session.
407 status = .noSignal(silentName)
408 watchdog.giveUp()
409 return nil
410 }
411
412 /// The connected cameras changed.
413 func devicesChanged(_ now: [CameraDevice]) -> CameraCommand? {
414 let before = Set(devices.map(\.id))
415 let wasSuspended = Set(devices.filter(\.suspended).map(\.id))
416 let previous = devices
417 devices = now
418 let present = Set(now.map(\.id))
419 // A camera that left is forgiven: when it comes back it is a new
420 // session, and may be sending again.
421 silent.formIntersection(present)
422 refused.formIntersection(present)
423 let preferred = preference
424 if let id = active, !present.contains(id) {
425 let gone = previous.first(where: { $0.id == id })?.name ?? name(id)
426 if let other = Self.choose(now, preferred: preferred, excluding: silent.union(refused)) {
427 note = "\(gone) disconnected; using \(other.name)"
428 return open(other.id)
429 }
430 active = nil
431 note = ""
432 status = .disconnected(gone)
433 watchdog.reset()
434 return .stop
435 }
436 // The camera in use was suspended (the lid closed on it): fall back
437 // now, while there is somewhere awake to go, rather than wait out the
438 // watchdog's three tries on a camera that cannot send.
439 if let id = active, let device = now.first(where: { $0.id == id }), device.suspended,
440 let other = Self.choose(now, preferred: preferred, excluding: silent.union(refused).union([id])),
441 !other.suspended
442 {
443 note = "\(Self.off(device)); using \(other.name)"
444 return open(other.id)
445 }
446 // The remembered camera is back, has just appeared, or has woken (the
447 // lid opened): go back to it.
448 if let target = preferred?.find(in: now), active != target.id, !target.suspended,
449 !before.contains(target.id) || wasSuspended.contains(target.id) || active == nil
450 {
451 silent.remove(target.id)
452 note = ""
453 return open(target.id)
454 }
455 // Nothing running and something to run on.
456 if active == nil, !now.isEmpty {
457 if case .disconnected = status { note = "" }
458 return pick(missingNote: true)
459 }
460 // "Camera disconnected" stays said until something comes back.
461 if active == nil, now.isEmpty, !status.disconnected { status = .noCamera }
462 return nil
463 }
464
465 /// The person chose a camera: it is remembered and used. Choosing the
466 /// camera the call is already on — there by fallback, say — is a choice
467 /// like any other; when that camera is working it is only remembered,
468 /// not reopened, so the picture does not blink.
469 func choose(_ id: String) -> CameraCommand? {
470 guard let device = devices.first(where: { $0.id == id }) else { return nil }
471 CameraPreference(device).save(defaults)
472 silent.remove(id)
473 refused.remove(id)
474 note = ""
475 if active == id, status.live { return nil }
476 watchdog.reset()
477 return open(id)
478 }
479
480 /// "Try again": everything that failed is forgiven and the choice is
481 /// made from scratch.
482 func retry() -> CameraCommand? {
483 silent = []
484 refused = []
485 note = ""
486 watchdog.reset()
487 return pick(missingNote: true)
488 }
489
490 /// A capture session error or interruption, or the Mac waking: restart
491 /// on the same device, no more often than `restartSpacing`.
492 func restart(at now: TimeInterval) -> CameraCommand? {
493 guard let id = active, now - lastRestart >= restartSpacing else { return nil }
494 lastRestart = now
495 return open(id, format: format)
496 }
497
498 /// The call is over.
499 func stop() {
500 active = nil
501 watchdog.reset()
502 }
503}
504