| 1 | import 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. |
| 11 | public 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. |
| 72 | public 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. |
| 129 | struct 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. |
| 204 | enum 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 | |