Merge branch 'fix/camera-preference' into main
Merge fix/camera-preference: remember the camera you pick, and pass over a closed lid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+328
−43
native/apple/Sources/CallsKit/Call.swift+6 −0native/apple/Sources/CallsKit/CameraRecovery.swift+106 −27native/apple/Sources/CallsKit/Capture.swift+40 −4native/apple/Sources/CallsKit/DevicePanel.swift+26 −10native/apple/Sources/CallsKit/Media.swift+2 −1native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift+148 −1
CI passed — run details
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 3f32290..e0a2ce3 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -80,6 +80,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// call. |
| @Published public private(set) var cameraStatus: CameraStatus? |
| @Published public private(set) var cameraNote = "" |
| + /// The working camera in use when it is not the remembered one, which |
| + /// the panel offers to remember. |
| + @Published public private(set) var cameraToKeep: CameraDevice? |
| /// A real camera, so the panel shows its row even with nothing in it. |
| @Published public private(set) var offersCameras = false |
| /// Audio routing belongs to this call. |
| @@ -188,6 +191,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| currentCamera = nil |
| cameraStatus = nil |
| cameraNote = "" |
| + cameraToKeep = nil |
| knocks.reset() |
| cameraSwitches += 1 |
| cameraWork?.cancel() |
| @@ -265,6 +269,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| if cameraStatus != status { cameraStatus = status } |
| let note = media.cameraNote |
| if cameraNote != note { cameraNote = note } |
| + let keep = media.cameraToKeep |
| + if cameraToKeep != keep { cameraToKeep = keep } |
| } |
| /// What your own tile says in place of your picture, or nil to show it. |
diff --git a/native/apple/Sources/CallsKit/CameraRecovery.swift b/native/apple/Sources/CallsKit/CameraRecovery.swift| index 10b57c0..4c92111 100644 |
| --- a/native/apple/Sources/CallsKit/CameraRecovery.swift |
| +++ b/native/apple/Sources/CallsKit/CameraRecovery.swift |
| @@ -60,28 +60,53 @@ public enum CameraStatus: Equatable, Sendable { |
| /// The person's camera, remembered between calls and launches. |
| /// |
| -/// Kept by `uniqueID`, which survives unplugging and replugging, with the |
| -/// name beside it so the panel can say which camera it is waiting for |
| -/// while that camera is not connected. |
| +/// Kept by `uniqueID`, with the model and name beside it. The uniqueID of |
| +/// a USB camera follows the port it is plugged into, so the same ATEM |
| +/// Mini on another port is a new id; `find` falls back to the model and |
| +/// then the name, so the remembered camera is still found. The name also |
| +/// lets the panel say which camera it is waiting for while it is gone. |
| +/// |
| +/// Only a person's own pick writes this (`CameraDirector.choose`, and the |
| +/// panel before a call). A fallback, a skip and a first-run default never |
| +/// do: which camera a call is on is not the same as which one you chose. |
| public struct CameraPreference: Equatable, Sendable { |
| public var id: String |
| public var name: String |
| - public init(id: String, name: String) { |
| + /// The camera's model, for finding it again under a new id. Empty for a |
| + /// choice saved before 0.3.4, which is then found by name. |
| + public var model: String |
| + public init(id: String, name: String, model: String = "") { |
| self.id = id |
| self.name = name |
| + self.model = model |
| + } |
| + public init(_ device: CameraDevice) { |
| + self.init(id: device.id, name: device.name, model: device.model) |
| } |
| static let idKey = "preferredCameraID" |
| static let nameKey = "preferredCameraName" |
| + static let modelKey = "preferredCameraModel" |
| public static func load(_ defaults: UserDefaults = .standard) -> CameraPreference? { |
| guard let id = defaults.string(forKey: idKey), !id.isEmpty else { return nil } |
| - return CameraPreference(id: id, name: defaults.string(forKey: nameKey) ?? "") |
| + return CameraPreference( |
| + id: id, name: defaults.string(forKey: nameKey) ?? "", model: defaults.string(forKey: modelKey) ?? "") |
| + } |
| + |
| + /// The connected camera this preference means: by id, then by model, |
| + /// then by name. |
| + public func find(in devices: [CameraDevice]) -> CameraDevice? { |
| + if let device = devices.first(where: { $0.id == id }) { return device } |
| + if !model.isEmpty, let device = devices.first(where: { $0.model == model }) { return device } |
| + if !name.isEmpty, let device = devices.first(where: { $0.name == name }) { return device } |
| + return nil |
| } |
| public func save(_ defaults: UserDefaults = .standard) { |
| defaults.set(id, forKey: Self.idKey) |
| defaults.set(name, forKey: Self.nameKey) |
| + defaults.set(model, forKey: Self.modelKey) |
| } |
| } |
| @@ -188,7 +213,18 @@ enum CameraCommand: Equatable { |
| /// |
| /// - At join the remembered camera is used if it is connected, otherwise |
| /// the built-in one, otherwise whatever there is. |
| -/// - Choosing a camera remembers it. |
| +/// - A suspended camera (a Mac's built-in one with the lid closed, which |
| +/// is listed but never sends a frame) is passed over while there is |
| +/// another, and the panel says why ("MacBook Air Camera is off while |
| +/// the lid is closed; using Blackmagic Design"). It is still the |
| +/// remembered camera: opening the lid, mid-call or before the next one, |
| +/// goes back to it. Closing the lid mid-call falls back at once rather |
| +/// than waiting out the watchdog. |
| +/// - Choosing a camera remembers it, including the one the call is |
| +/// already on by fallback. Nothing else writes the preference. While |
| +/// the call is on a working camera other than the remembered one, the |
| +/// panel offers to remember it ("Use Blackmagic Design from now on"): |
| +/// one tap, never an automatic switch. |
| /// - The camera in use disconnecting falls back to another at once, and |
| /// says so ("ATEM Mini disconnected; using FaceTime HD Camera"). |
| /// - The remembered camera reconnecting, or appearing later, is switched |
| @@ -227,6 +263,23 @@ enum CameraCommand: Equatable { |
| var preference: CameraPreference? { CameraPreference.load(defaults) } |
| + /// The camera in use, when it is working and is not the remembered |
| + /// one: what the panel offers to remember. |
| + var cameraToKeep: CameraDevice? { |
| + guard status.live, let id = active, let device = devices.first(where: { $0.id == id }) else { return nil } |
| + guard let preference, preference.find(in: devices)?.id != id else { return nil } |
| + return device |
| + } |
| + |
| + /// "MacBook Air Camera is off while the lid is closed": what a |
| + /// suspended camera is called when it is passed over. |
| + nonisolated static func off(_ device: CameraDevice) -> String { |
| + #if os(macOS) |
| + if device.builtIn { return "\(device.name) is off while the lid is closed" } |
| + #endif |
| + return "\(device.name) is off" |
| + } |
| + |
| private func name(_ id: String?) -> String { |
| guard let id else { return "The camera" } |
| if let device = devices.first(where: { $0.id == id }) { return device.name } |
| @@ -235,14 +288,17 @@ enum CameraCommand: Equatable { |
| } |
| /// The camera a call should be on, given what is connected: the |
| - /// remembered one, then the built-in one, then the first. Pure, and |
| - /// what `start` and every fallback ask. |
| + /// remembered one, then the built-in one, then the first — each only if |
| + /// it is not suspended, and a suspended one only when there is nothing |
| + /// else. Pure, and what `start` and every fallback ask. |
| nonisolated static func choose( |
| - _ devices: [CameraDevice], preferred: String?, excluding: Set<String> = [] |
| + _ devices: [CameraDevice], preferred: CameraPreference?, excluding: Set<String> = [] |
| ) -> CameraDevice? { |
| let usable = devices.filter { !excluding.contains($0.id) } |
| - if let preferred, let device = usable.first(where: { $0.id == preferred }) { return device } |
| - return usable.first(where: \.builtIn) ?? usable.first |
| + let awake = usable.filter { !$0.suspended } |
| + if let device = preferred?.find(in: awake) { return device } |
| + if let device = awake.first(where: \.builtIn) ?? awake.first { return device } |
| + return preferred?.find(in: usable) ?? usable.first(where: \.builtIn) ?? usable.first |
| } |
| /// A call starts: pick the camera and open it. |
| @@ -258,18 +314,20 @@ enum CameraCommand: Equatable { |
| /// Chooses from scratch, excluding what has failed this round. |
| private func pick(missingNote: Bool) -> CameraCommand? { |
| let preferred = preference |
| - guard let device = Self.choose(devices, preferred: preferred?.id, excluding: silent.union(refused)) |
| + guard let device = Self.choose(devices, preferred: preferred, excluding: silent.union(refused)) |
| else { |
| active = nil |
| status = devices.isEmpty ? .noCamera : status |
| if devices.isEmpty { note = "" } |
| return .stop |
| } |
| - if missingNote, let preferred, preferred.id != device.id, |
| - !devices.contains(where: { $0.id == preferred.id }) |
| - { |
| - let missing = preferred.name.isEmpty ? "Your camera" : preferred.name |
| - note = "\(missing) isn’t connected; using \(device.name)" |
| + if missingNote, let preferred { |
| + if let found = preferred.find(in: devices) { |
| + if found.id != device.id, found.suspended { note = "\(Self.off(found)); using \(device.name)" } |
| + } else { |
| + let missing = preferred.name.isEmpty ? "Your camera" : preferred.name |
| + note = "\(missing) isn’t connected; using \(device.name)" |
| + } |
| } |
| return open(device.id) |
| } |
| @@ -296,7 +354,7 @@ enum CameraCommand: Equatable { |
| guard id == active else { return nil } |
| let failedName = name(id) |
| refused.insert(id) |
| - if let other = Self.choose(devices, preferred: preference?.id, excluding: silent.union(refused)) { |
| + if let other = Self.choose(devices, preferred: preference, excluding: silent.union(refused)) { |
| note = "\(failedName) would not start; using \(other.name)" |
| return open(other.id) |
| } |
| @@ -334,10 +392,14 @@ enum CameraCommand: Equatable { |
| private func fallBack(from id: String) -> CameraCommand? { |
| let silentName = name(id) |
| if let other = Self.choose( |
| - devices, preferred: preference?.id, excluding: silent.union(refused).union([id])) |
| + devices, preferred: preference, excluding: silent.union(refused).union([id])) |
| { |
| silent.insert(id) |
| - note = "No signal from \(silentName); using \(other.name)" |
| + if let device = devices.first(where: { $0.id == id }), device.suspended { |
| + note = "\(Self.off(device)); using \(other.name)" |
| + } else { |
| + note = "No signal from \(silentName); using \(other.name)" |
| + } |
| return open(other.id) |
| } |
| // Nothing to fall back to. Stay on it, say so, and keep counting: an |
| @@ -350,6 +412,7 @@ enum CameraCommand: Equatable { |
| /// The connected cameras changed. |
| func devicesChanged(_ now: [CameraDevice]) -> CameraCommand? { |
| let before = Set(devices.map(\.id)) |
| + let wasSuspended = Set(devices.filter(\.suspended).map(\.id)) |
| let previous = devices |
| devices = now |
| let present = Set(now.map(\.id)) |
| @@ -360,7 +423,7 @@ enum CameraCommand: Equatable { |
| let preferred = preference |
| if let id = active, !present.contains(id) { |
| let gone = previous.first(where: { $0.id == id })?.name ?? name(id) |
| - if let other = Self.choose(now, preferred: preferred?.id, excluding: silent.union(refused)) { |
| + if let other = Self.choose(now, preferred: preferred, excluding: silent.union(refused)) { |
| note = "\(gone) disconnected; using \(other.name)" |
| return open(other.id) |
| } |
| @@ -370,12 +433,24 @@ enum CameraCommand: Equatable { |
| watchdog.reset() |
| return .stop |
| } |
| - // The remembered camera is back (or has just appeared): go back to it. |
| - if let preferred, active != preferred.id, present.contains(preferred.id), |
| - !before.contains(preferred.id) || active == nil |
| + // The camera in use was suspended (the lid closed on it): fall back |
| + // now, while there is somewhere awake to go, rather than wait out the |
| + // watchdog's three tries on a camera that cannot send. |
| + if let id = active, let device = now.first(where: { $0.id == id }), device.suspended, |
| + let other = Self.choose(now, preferred: preferred, excluding: silent.union(refused).union([id])), |
| + !other.suspended |
| + { |
| + note = "\(Self.off(device)); using \(other.name)" |
| + return open(other.id) |
| + } |
| + // The remembered camera is back, has just appeared, or has woken (the |
| + // lid opened): go back to it. |
| + if let target = preferred?.find(in: now), active != target.id, !target.suspended, |
| + !before.contains(target.id) || wasSuspended.contains(target.id) || active == nil |
| { |
| + silent.remove(target.id) |
| note = "" |
| - return open(preferred.id) |
| + return open(target.id) |
| } |
| // Nothing running and something to run on. |
| if active == nil, !now.isEmpty { |
| @@ -387,13 +462,17 @@ enum CameraCommand: Equatable { |
| return nil |
| } |
| - /// The person chose a camera: it is remembered and used. |
| + /// The person chose a camera: it is remembered and used. Choosing the |
| + /// camera the call is already on — there by fallback, say — is a choice |
| + /// like any other; when that camera is working it is only remembered, |
| + /// not reopened, so the picture does not blink. |
| func choose(_ id: String) -> CameraCommand? { |
| guard let device = devices.first(where: { $0.id == id }) else { return nil } |
| - CameraPreference(id: device.id, name: device.name).save(defaults) |
| + CameraPreference(device).save(defaults) |
| silent.remove(id) |
| refused.remove(id) |
| note = "" |
| + if active == id, status.live { return nil } |
| watchdog.reset() |
| return open(id) |
| } |
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift| index a39a622..96926f0 100644 |
| --- a/native/apple/Sources/CallsKit/Capture.swift |
| +++ b/native/apple/Sources/CallsKit/Capture.swift |
| @@ -20,10 +20,22 @@ public struct CameraDevice: Identifiable, Hashable, Sendable { |
| /// The Mac's own camera (the iPhone's front one): where a call goes |
| /// when the remembered camera is not connected. |
| public let builtIn: Bool |
| - public init(id: String, name: String, builtIn: Bool = false) { |
| + /// `AVCaptureDevice.modelID`: the same for the same kind of camera |
| + /// wherever it is plugged in. A USB camera's `uniqueID` is built from |
| + /// the port it is on, so an ATEM Mini moved to another port (or through |
| + /// a different hub) comes back under a new id; the model, and failing |
| + /// that the name, is how a remembered choice still finds it. |
| + public let model: String |
| + /// `AVCaptureDevice.isSuspended`: listed and connected, but the system |
| + /// will not let it capture. On a Mac this is the built-in camera with |
| + /// the lid closed, where it opens and never sends a frame. |
| + public let suspended: Bool |
| + public init(id: String, name: String, builtIn: Bool = false, model: String = "", suspended: Bool = false) { |
| self.id = id |
| self.name = name |
| self.builtIn = builtIn |
| + self.model = model |
| + self.suspended = suspended |
| } |
| } |
| @@ -51,6 +63,10 @@ public struct CameraDevice: Identifiable, Hashable, Sendable { |
| /// A fallback worth saying ("ATEM Mini disconnected; using FaceTime HD |
| /// Camera"), and usually nothing. |
| var cameraNote: String { get } |
| + /// The camera in use when it is working and is not the remembered one: |
| + /// what the panel offers to remember ("Use Blackmagic Design from now |
| + /// on"). Nil otherwise. |
| + var cameraToKeep: CameraDevice? { get } |
| /// Called on the main actor whenever any of the above changes on its |
| /// own — a camera connecting, a fallback, the first frame. |
| func observe(_ changed: @escaping @MainActor () -> Void) |
| @@ -65,6 +81,7 @@ extension VideoCapture { |
| public var offersCameras: Bool { false } |
| public var cameraStatus: CameraStatus? { nil } |
| public var cameraNote: String { "" } |
| + public var cameraToKeep: CameraDevice? { nil } |
| public func observe(_ changed: @escaping @MainActor () -> Void) {} |
| public func retryCamera() async {} |
| } |
| @@ -158,6 +175,7 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| public var offersCameras: Bool { true } |
| public var cameraStatus: CameraStatus? { running ? director.status : nil } |
| public var cameraNote: String { running ? director.note : "" } |
| + public var cameraToKeep: CameraDevice? { running ? director.cameraToKeep : nil } |
| public func observe(_ changed: @escaping @MainActor () -> Void) { self.changed = changed } |
| public func start(source: RTCVideoSource) async throws { |
| @@ -246,9 +264,9 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| } |
| } |
| - private var published: (CameraStatus, String, String?)? |
| + private var published: (CameraStatus, String, String?, CameraDevice?)? |
| private func notify() { |
| - let now = (director.status, director.note, currentCamera) |
| + let now = (director.status, director.note, currentCamera, director.cameraToKeep) |
| if let published, published == now { return } |
| published = now |
| changed?() |
| @@ -315,6 +333,9 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| public private(set) var cameras: [CameraDevice] = [] |
| private var listeners: [UUID: @MainActor () -> Void] = [:] |
| private var tokens: [NSObjectProtocol] = [] |
| + /// Each listed camera's `isSuspended`, watched: the lid closing or |
| + /// opening changes it without any camera connecting or leaving. |
| + private var suspension: [String: NSKeyValueObservation] = [:] |
| #if os(macOS) |
| private let session = AVCaptureDevice.DiscoverySession( |
| deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], |
| @@ -335,6 +356,18 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| } |
| #endif |
| cameras = Self.list(devices) |
| + watchSuspension() |
| + } |
| + |
| + private func watchSuspension() { |
| + let current = devices |
| + let ids = Set(current.map(\.uniqueID)) |
| + for id in suspension.keys where !ids.contains(id) { suspension.removeValue(forKey: id) } |
| + for device in current where suspension[device.uniqueID] == nil { |
| + suspension[device.uniqueID] = device.observe(\.isSuspended, options: [.new]) { _, _ in |
| + DispatchQueue.main.async { MainActor.assumeIsolated { CameraWatch.shared.refresh() } } |
| + } |
| + } |
| } |
| var devices: [AVCaptureDevice] { |
| @@ -354,13 +387,16 @@ final class FrameCounter: NSObject, RTCVideoCapturerDelegate, @unchecked Sendabl |
| #else |
| let builtIn = $0.position == .front |
| #endif |
| - return CameraDevice(id: $0.uniqueID, name: $0.localizedName, builtIn: builtIn) |
| + return CameraDevice( |
| + id: $0.uniqueID, name: $0.localizedName, builtIn: builtIn, model: $0.modelID, |
| + suspended: $0.isSuspended) |
| } |
| } |
| public func refresh() { |
| let now = Self.list(devices) |
| guard now != cameras else { return } |
| + watchSuspension() |
| cameras = now |
| for listener in listeners.values { listener() } |
| } |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index cde891b..0594867 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -76,15 +76,20 @@ import SwiftUI |
| try? await Task.sleep(for: .seconds(1)) |
| } |
| } |
| - .onChange(of: camera) { was, now in |
| - guard let now, now != was, now != call.currentCamera else { return } |
| - cameraRequest += 1 |
| - let request = cameraRequest |
| - Task { |
| - await call.useCamera(now) |
| - // Only the latest pick puts the picker back where the camera is. |
| - if request == cameraRequest { camera = call.currentCamera } |
| - } |
| + } |
| + |
| + /// A camera the person picked, remembered and switched to. Driven by the |
| + /// picker's own setter, which only a person's choice calls — so choosing |
| + /// the camera the call is already on (there by fallback, say) counts, as |
| + /// it did not in 0.3.3, and the picker following the capture never does. |
| + private func pickCamera(_ id: String) { |
| + camera = id |
| + cameraRequest += 1 |
| + let request = cameraRequest |
| + Task { |
| + await call.useCamera(id) |
| + // Only the latest pick puts the picker back where the camera is. |
| + if request == cameraRequest { camera = call.currentCamera } |
| } |
| } |
| @@ -101,7 +106,10 @@ import SwiftUI |
| row("Camera", "No camera found") |
| } else { |
| picker( |
| - "Camera", selection: $camera, |
| + "Camera", |
| + selection: Binding( |
| + get: { camera }, |
| + set: { id in if let id { pickCamera(id) } }), |
| options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker") |
| } |
| if case .picker(let line, _) = state, let line { |
| @@ -115,6 +123,14 @@ import SwiftUI |
| .fixedSize(horizontal: false, vertical: true) |
| .accessibilityIdentifier("cameraNote") |
| } |
| + if let keep = call.cameraToKeep { |
| + Button("Use \(keep.name) from now on", systemImage: "checkmark.circle") { |
| + pickCamera(keep.id) |
| + } |
| + .buttonStyle(.bordered) |
| + .accessibilityIdentifier("cameraKeep") |
| + .accessibilityHint("Remembers this camera for your next calls.") |
| + } |
| if state.offersRetry { |
| Button("Try again", systemImage: "arrow.clockwise") { |
| Task { await call.retryCamera() } |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index a33cd0c..47c45eb 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -83,11 +83,12 @@ import LiveKitWebRTC |
| private(set) var offersCameras = false |
| var cameraStatus: CameraStatus? { capture?.cameraStatus } |
| var cameraNote: String { capture?.cameraNote ?? "" } |
| + var cameraToKeep: CameraDevice? { capture?.cameraToKeep } |
| func use(camera id: String) async throws { |
| if let capture { |
| try await capture.use(camera: id) |
| } else if offersCameras, let device = CameraWatch.shared.cameras.first(where: { $0.id == id }) { |
| - CameraPreference(id: device.id, name: device.name).save() |
| + CameraPreference(device).save() |
| } |
| } |
| func retryCamera() async { |
diff --git a/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift b/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift| index db5a2f6..8e83b3a 100644 |
| --- a/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/CameraRecoveryTests.swift |
| @@ -46,7 +46,7 @@ import XCTest |
| XCTAssertEqual(d.start(devices: [atem, faceTime]), .open("facetime", format: 0)) |
| XCTAssertEqual(d.note, "") |
| XCTAssertEqual(CameraDirector.choose([atem, phone], preferred: nil)?.id, "atem") |
| - XCTAssertNil(CameraDirector.choose([], preferred: "atem")) |
| + XCTAssertNil(CameraDirector.choose([], preferred: CameraPreference(id: "atem", name: "ATEM Mini"))) |
| } |
| func testNoCameraAtAllIsSaidAndStopped() { |
| @@ -67,6 +67,137 @@ import XCTest |
| XCTAssertNil(d.choose("missing"), "a camera that is not connected cannot be chosen") |
| } |
| + // MARK: Paul's 0.3.3 report: "This keeps forgetting my input" |
| + // |
| + // A MacBook Air in clamshell, an ATEM Mini ("Blackmagic Design") on USB. |
| + // The remembered camera was the MacBook's own, which is listed with the |
| + // lid closed but sends nothing; every call fell back to the Blackmagic |
| + // and said "No signal from MacBook Air Camera; using Blackmagic Design", |
| + // and choosing the Blackmagic in the panel did not stick. |
| + |
| + private let macBook = CameraDevice( |
| + id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera") |
| + private let lidClosed = CameraDevice( |
| + id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera", suspended: true) |
| + private let blackmagic = CameraDevice( |
| + id: "0x1100000_1edb_be55", name: "Blackmagic Design", model: "UVC Camera VendorID_7899 ProductID_48725") |
| + |
| + /// The fallback, as 0.3.3 reached it: preferred camera silent, the |
| + /// Blackmagic live. |
| + private func onBlackmagicByFallback(_ store: UserDefaults) -> CameraDirector { |
| + CameraPreference(macBook).save(store) |
| + let d = director(store, formats: 1) |
| + _ = d.start(devices: [macBook, blackmagic]) |
| + d.opened("builtin", at: 0, frames: 0) |
| + _ = d.tick(frames: 0, at: 3) |
| + d.opened("builtin", at: 3, frames: 0) |
| + XCTAssertEqual(d.tick(frames: 0, at: 6), .open(blackmagic.id, format: 0)) |
| + d.opened(blackmagic.id, at: 6, frames: 0) |
| + _ = d.tick(frames: 1, at: 6.5) |
| + XCTAssertEqual(d.status, .live("Blackmagic Design")) |
| + return d |
| + } |
| + |
| + func testChoosingTheCameraYouAreOnByFallbackRemembersItWithoutRestartingIt() { |
| + let store = defaults() |
| + let d = onBlackmagicByFallback(store) |
| + XCTAssertNil(d.choose(blackmagic.id), "already live: nothing to reopen, no glitch") |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) |
| + XCTAssertEqual(d.note, "") |
| + XCTAssertEqual(d.status, .live("Blackmagic Design")) |
| + XCTAssertNil(d.cameraToKeep, "nothing left to offer") |
| + // And the next call starts on it. |
| + let next = director(store) |
| + XCTAssertEqual(next.start(devices: [macBook, blackmagic]), .open(blackmagic.id, format: 0)) |
| + } |
| + |
| + func testFallbacksAndDefaultsNeverWriteThePreference() { |
| + let store = defaults() |
| + _ = onBlackmagicByFallback(store) |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "a fallback is not a choice") |
| + let fresh = defaults() |
| + let d = director(fresh) |
| + _ = d.start(devices: [blackmagic, macBook]) |
| + _ = d.devicesChanged([blackmagic]) |
| + _ = d.openFailed(blackmagic.id) |
| + XCTAssertNil(CameraPreference.load(fresh), "a first-run default is not a choice either") |
| + } |
| + |
| + func testTheCameraInUseIsOfferedWhenItIsNotTheRememberedOne() { |
| + let store = defaults() |
| + let d = onBlackmagicByFallback(store) |
| + XCTAssertEqual(d.cameraToKeep, blackmagic) |
| + XCTAssertEqual(d.choose(blackmagic.id), nil) |
| + XCTAssertEqual(CameraPreference.load(store)?.id, blackmagic.id) |
| + // Nothing remembered and on the default: nothing to offer. |
| + let plain = director(defaults()) |
| + _ = plain.start(devices: [macBook, blackmagic]) |
| + plain.opened("builtin", at: 0, frames: 0) |
| + _ = plain.tick(frames: 1, at: 0.5) |
| + XCTAssertNil(plain.cameraToKeep) |
| + } |
| + |
| + func testAReenumeratedCameraStillMatches() { |
| + // Moved to another USB port: a new uniqueID, the same model. |
| + let store = defaults() |
| + CameraPreference(blackmagic).save(store) |
| + let moved = CameraDevice(id: "0x1400000_1edb_be55", name: "Blackmagic Design", model: blackmagic.model) |
| + XCTAssertEqual(director(store).start(devices: [macBook, moved]), .open(moved.id, format: 0)) |
| + // A choice saved by 0.3.3 has no model: the name finds it. |
| + let old = defaults() |
| + CameraPreference(id: blackmagic.id, name: "Blackmagic Design").save(old) |
| + let d = director(old) |
| + XCTAssertEqual(d.start(devices: [macBook, moved]), .open(moved.id, format: 0)) |
| + XCTAssertEqual(d.note, "", "found, so not \"isn’t connected\"") |
| + XCTAssertNil(d.cameraToKeep, "it is the remembered camera") |
| + // The id still wins over the model when both are there. |
| + let twin = CameraDevice(id: "twin", name: "Blackmagic Design", model: blackmagic.model) |
| + XCTAssertEqual(director(store).start(devices: [twin, blackmagic]), .open(blackmagic.id, format: 0)) |
| + } |
| + |
| + func testLidClosedSkipsTheBuiltInCameraAtJoinAndSaysWhy() { |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let d = director(store) |
| + XCTAssertEqual(d.start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| + XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "skipped, not forgotten") |
| + d.opened(blackmagic.id, at: 0, frames: 0) |
| + _ = d.tick(frames: 1, at: 0.5) |
| + XCTAssertEqual(d.cameraToKeep, blackmagic) |
| + // With nothing remembered, a closed lid is not the first choice either. |
| + XCTAssertEqual(director(defaults()).start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| + // And with nothing else, it is tried anyway. |
| + XCTAssertEqual(director(defaults()).start(devices: [lidClosed]), .open("builtin", format: 0)) |
| + } |
| + |
| + func testLidClosingMidCallFallsBackAtOnceAndOpeningItReturns() { |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let d = director(store) |
| + _ = d.start(devices: [macBook, blackmagic]) |
| + d.opened("builtin", at: 0, frames: 0) |
| + _ = d.tick(frames: 1, at: 0.5) |
| + XCTAssertEqual(d.devicesChanged([lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| + XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + XCTAssertEqual(d.devicesChanged([macBook, blackmagic]), .open("builtin", format: 0)) |
| + XCTAssertEqual(d.note, "") |
| + } |
| + |
| + func testNoSignalFromALidClosedCameraSaysSo() { |
| + // The lid closed after the list was read: the watchdog finds it. |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let d = director(store, formats: 1) |
| + _ = d.start(devices: [macBook, blackmagic]) |
| + d.opened("builtin", at: 0, frames: 0) |
| + _ = d.tick(frames: 0, at: 3) |
| + d.opened("builtin", at: 3, frames: 0) |
| + _ = d.devicesChanged([lidClosed, blackmagic]) |
| + XCTAssertEqual(d.active, blackmagic.id) |
| + XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + } |
| + |
| // MARK: Coming and going |
| func testActiveCameraDisconnectingFallsBackAtOnceAndSaysSo() { |
| @@ -319,6 +450,22 @@ import XCTest |
| camera.stop() |
| } |
| + func testManagedCameraOnTheLidClosedFallbackRemembersThePick() async throws { |
| + let store = defaults() |
| + CameraPreference(macBook).save(store) |
| + let backend = FakeBackend([lidClosed, blackmagic]) |
| + let camera = ManagedCamera(backend: backend, defaults: store) |
| + RTCInitializeSSL() |
| + try await camera.start(source: RTCPeerConnectionFactory().videoSource()) |
| + XCTAssertEqual(backend.opened, [blackmagic.id], "the closed lid's camera is never opened") |
| + XCTAssertEqual(camera.cameraNote, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| + // Picking the camera it is already on is remembered. |
| + try await camera.use(camera: blackmagic.id) |
| + XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) |
| + XCTAssertEqual(camera.cameraNote, "") |
| + camera.stop() |
| + } |
| + |
| private func waitFor(_ condition: @escaping @MainActor () -> Bool) async throws { |
| for _ in 0..<200 { |
| if condition() { return } |