diff --git a/Sources/LiveKit/Support/Video/DeviceManager+Public.swift b/Sources/LiveKit/Support/Video/DeviceManager+Public.swift new file mode 100644 index 000000000..89ecbfc46 --- /dev/null +++ b/Sources/LiveKit/Support/Video/DeviceManager+Public.swift @@ -0,0 +1,159 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import SwiftUI + +// MARK: - Computed Helpers + +public extension DeviceManager { + @MainActor + var defaultAudioInput: AudioDevice? { + audioInputDevices.first(where: \.isDefault) + } + + @MainActor + var defaultAudioOutput: AudioDevice? { + audioOutputDevices.first(where: \.isDefault) + } + + @MainActor + var canSwitchCameraPosition: Bool { + videoCaptureDevices.contains(where: { $0.position == .front }) && + videoCaptureDevices.contains(where: { $0.position == .back }) + } +} + +// MARK: - Selection (imperative) + +public extension DeviceManager { + @MainActor + func select(audioInput: AudioDevice) { + selectedAudioInput = audioInput + AudioManager.shared.inputDevice = audioInput + } + + @MainActor + func select(audioOutput: AudioDevice) { + selectedAudioOutput = audioOutput + AudioManager.shared.outputDevice = audioOutput + } + + @MainActor + func select(videoCapture: VideoCaptureDevice) { + selectedVideoCapture = videoCapture + } + + @MainActor + func selectCamera(position: CameraPosition) { + selectedVideoCapture = videoCaptureDevices.first(where: { $0.position == position }) + } + + @MainActor + func switchCameraPosition() { + let current = selectedVideoCapture?.position ?? .front + selectCamera(position: current == .front ? .back : .front) + } +} + +// MARK: - Selection by ID (for persistence/restoration) + +public extension DeviceManager { + @MainActor + @discardableResult + func selectAudioInput(byId deviceId: String) -> Bool { + guard let device = audioInputDevices.first(where: { $0.deviceId == deviceId }) else { + return false + } + select(audioInput: device) + return true + } + + @MainActor + @discardableResult + func selectAudioOutput(byId deviceId: String) -> Bool { + guard let device = audioOutputDevices.first(where: { $0.deviceId == deviceId }) else { + return false + } + select(audioOutput: device) + return true + } + + @MainActor + @discardableResult + func selectVideoCapture(byId deviceId: String) -> Bool { + guard let device = videoCaptureDevices.first(where: { $0.deviceId == deviceId }) else { + return false + } + select(videoCapture: device) + return true + } +} + +// MARK: - SwiftUI Bindings + +public extension DeviceManager { + @MainActor + var audioInputBinding: Binding { + Binding( + get: { self.selectedAudioInput }, + set: { newDevice in + if let newDevice { + self.select(audioInput: newDevice) + } else { + self.selectedAudioInput = nil + } + } + ) + } + + @MainActor + var audioOutputBinding: Binding { + Binding( + get: { self.selectedAudioOutput }, + set: { newDevice in + if let newDevice { + self.select(audioOutput: newDevice) + } else { + self.selectedAudioOutput = nil + } + } + ) + } + + @MainActor + var videoCaptureBinding: Binding { + Binding( + get: { self.selectedVideoCapture }, + set: { newDevice in + if let newDevice { + self.select(videoCapture: newDevice) + } else { + self.selectedVideoCapture = nil + } + } + ) + } + + @MainActor + var cameraPositionBinding: Binding { + Binding( + get: { self.selectedVideoCapture?.position ?? .unspecified }, + set: { newPosition in + self.selectCamera(position: newPosition) + } + ) + } +} diff --git a/Sources/LiveKit/Support/Video/DeviceManager.swift b/Sources/LiveKit/Support/Video/DeviceManager.swift index 2575d6bc5..0a9c71146 100644 --- a/Sources/LiveKit/Support/Video/DeviceManager.swift +++ b/Sources/LiveKit/Support/Video/DeviceManager.swift @@ -15,19 +15,33 @@ */ @preconcurrency import AVFoundation +import Combine -// Internal-only for now -class DeviceManager: @unchecked Sendable, Loggable { +public class DeviceManager: ObservableObject, @unchecked Sendable, Loggable { // MARK: - Public - static let shared = DeviceManager() + public static let shared = DeviceManager() - static func prepare() { + public static func prepare() { // Instantiate shared instance _ = shared } - // Async version, waits until inital device fetch is complete + // MARK: - Published Properties + + @Published public internal(set) var videoCaptureDevices: [VideoCaptureDevice] = [] + @Published public internal(set) var audioInputDevices: [AudioDevice] = [] + @Published public internal(set) var audioOutputDevices: [AudioDevice] = [] + + @Published public internal(set) var selectedVideoCapture: VideoCaptureDevice? + @Published public internal(set) var selectedAudioInput: AudioDevice? + @Published public internal(set) var selectedAudioOutput: AudioDevice? + + @Published public internal(set) var error: (any Error)? + + // MARK: - Internal (video discovery) + + // Async version, waits until initial device fetch is complete func devices() async throws -> [AVCaptureDevice] { try await _devicesCompleter.wait() } @@ -134,6 +148,89 @@ class DeviceManager: @unchecked Sendable, Loggable { } } #endif + + // Wire video device observation + _state.onDidMutate = { [weak self] newState, _ in + let videoDevices = newState.devices.map { VideoCaptureDevice(avCaptureDevice: $0) } + Task { @MainActor [weak self] in + guard let self else { return } + videoCaptureDevices = videoDevices + reconcileVideoSelection() + } + } + + // Wire audio device observation + observeAudioDevices() + } + + // MARK: - Audio Observation + + private func observeAudioDevices() { + let existingCallback = AudioManager.shared.onDeviceUpdate + + AudioManager.shared.onDeviceUpdate = { [weak self] audioManager in + let inputDevices = audioManager.inputDevices + let outputDevices = audioManager.outputDevices + let inputDevice = audioManager.inputDevice + let outputDevice = audioManager.outputDevice + + Task { @MainActor [weak self] in + guard let self else { return } + audioInputDevices = inputDevices + audioOutputDevices = outputDevices + // Update selected if not yet set + if selectedAudioInput == nil { + selectedAudioInput = inputDevice + } + if selectedAudioOutput == nil { + selectedAudioOutput = outputDevice + } + reconcileAudioSelection() + } + + existingCallback?(audioManager) + } + + // Initial population + Task { @MainActor [weak self] in + guard let self else { return } + audioInputDevices = AudioManager.shared.inputDevices + audioOutputDevices = AudioManager.shared.outputDevices + selectedAudioInput = AudioManager.shared.inputDevice + selectedAudioOutput = AudioManager.shared.outputDevice + } + } + + // MARK: - Reconciliation + + @MainActor + func reconcileVideoSelection() { + if let selected = selectedVideoCapture, + !videoCaptureDevices.contains(where: { $0.deviceId == selected.deviceId }) + { + selectedVideoCapture = videoCaptureDevices.first + } + } + + @MainActor + func reconcileAudioSelection() { + if let selected = selectedAudioInput, + !audioInputDevices.contains(where: { $0.deviceId == selected.deviceId }) + { + selectedAudioInput = audioInputDevices.first(where: \.isDefault) + } + if let selected = selectedAudioOutput, + !audioOutputDevices.contains(where: { $0.deviceId == selected.deviceId }) + { + selectedAudioOutput = audioOutputDevices.first(where: \.isDefault) + } + } + + // MARK: - Error + + @MainActor + public func dismissError() { + error = nil } } diff --git a/Sources/LiveKit/Types/AudioDevice.swift b/Sources/LiveKit/Types/AudioDevice.swift index 9f7227760..40c8ed21e 100644 --- a/Sources/LiveKit/Types/AudioDevice.swift +++ b/Sources/LiveKit/Types/AudioDevice.swift @@ -34,3 +34,7 @@ public class AudioDevice: NSObject, MediaDevice { extension AudioDevice: Identifiable { public var id: String { deviceId } } + +extension AudioDevice: DeviceProtocol {} + +extension AudioDevice: @unchecked Sendable {} diff --git a/Sources/LiveKit/Types/DeviceProtocol.swift b/Sources/LiveKit/Types/DeviceProtocol.swift new file mode 100644 index 000000000..cb8ac4ff1 --- /dev/null +++ b/Sources/LiveKit/Types/DeviceProtocol.swift @@ -0,0 +1,73 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@preconcurrency import AVFoundation + +/// A device that can capture or output media. +public protocol DeviceProtocol: Identifiable, Hashable, Sendable { + var deviceId: String { get } + var name: String { get } +} + +public extension DeviceProtocol { + var id: String { deviceId } +} + +// MARK: - CameraPosition + +/// The facing position of a camera device. +public enum CameraPosition: String, Sendable, CaseIterable { + case front + case back + case unspecified + + init(from position: AVCaptureDevice.Position) { + switch position { + case .front: self = .front + case .back: self = .back + default: self = .unspecified + } + } +} + +// MARK: - VideoCaptureDevice + +/// A video capture device (camera). +public struct VideoCaptureDevice: DeviceProtocol { + public let deviceId: String + public let name: String + public let position: CameraPosition + + /// Internal — used to bridge to CameraCaptureOptions. + let _avCaptureDevice: AVCaptureDevice + + init(avCaptureDevice: AVCaptureDevice) { + deviceId = avCaptureDevice.uniqueID + name = avCaptureDevice.localizedName + position = CameraPosition(from: avCaptureDevice.facingPosition) + _avCaptureDevice = avCaptureDevice + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(deviceId) + } + + public static func == (lhs: VideoCaptureDevice, rhs: VideoCaptureDevice) -> Bool { + lhs.deviceId == rhs.deviceId + } +} diff --git a/Sources/LiveKit/Types/Options/CameraCaptureOptions+VideoCaptureDevice.swift b/Sources/LiveKit/Types/Options/CameraCaptureOptions+VideoCaptureDevice.swift new file mode 100644 index 000000000..637bec06e --- /dev/null +++ b/Sources/LiveKit/Types/Options/CameraCaptureOptions+VideoCaptureDevice.swift @@ -0,0 +1,25 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +public extension CameraCaptureOptions { + convenience init( + device: VideoCaptureDevice, + dimensions: Dimensions = .h720_169, + fps: Int = 30 + ) { + self.init(device: device._avCaptureDevice, dimensions: dimensions, fps: fps) + } +}