USB 摄像头适配iPadOS17+ios端的配置

This commit is contained in:
2026-10-22 12:54:08 +08:00
parent 58a23ef218
commit 615d76a8ce
4 changed files with 401 additions and 0 deletions

View File

@@ -0,0 +1,256 @@
//
// UsbCameraEngine.swift
// usb_camera_android
//
// iOS 端 UVC 外接摄像头引擎(iPadOS 17+)。
// 通过 AVCaptureDevice.DiscoverySession 枚举外接摄像头,
// 用 AVCaptureSession + AVCaptureVideoPreviewLayer 出画面。
// 协议与 Android 端完全对齐:deviceId 使用 device.uniqueID。
//
// 兼容性:整个 Runner / pod 的最低系统版本仍为 iOS 12,
// 因此对 iOS 17 才有的 API(.externalUnknown)一律用 #available 运行时保护,
// iOS 17 以下枚举返回空、isSupported 返回 false,走优雅降级。
//
import AVFoundation
import Flutter
import UIKit
final class UsbCameraEngine: NSObject, FlutterStreamHandler {
/// 预览会话,供 PlatformView 绑定 layer。
private(set) var captureSession: AVCaptureSession?
private(set) var currentDevice: AVCaptureDevice?
private var isRunning = false
private var eventSink: FlutterEventSink?
private let workQueue = DispatchQueue(label: "com.maibu.usb_camera.engine")
// MARK: - 能力探测
/// iPhone 无论系统版本都不支持外接摄像头输入;iPad 需 iPadOS 17+。
func isSupported() -> Bool {
if #available(iOS 17.0, *) {
return UIDevice.current.userInterfaceIdiom != .phone
}
return false
}
// MARK: - 枚举
private func discoverySession() -> AVCaptureDevice.DiscoverySession {
if #available(iOS 17.0, *) {
return AVCaptureDevice.DiscoverySession(
deviceTypes: [.externalUnknown],
mediaType: .video,
position: .unspecified
)
}
return AVCaptureDevice.DiscoverySession(
deviceTypes: [],
mediaType: .video,
position: .unspecified
)
}
/// 返回设备列表,字段与 Android 对齐:deviceId / vendorId / productId / productName。
func getDeviceList() -> [[String: Any]] {
return discoverySession().devices.map { device in
return [
"deviceId": device.uniqueID,
"vendorId": 0,
"productId": 0,
"productName": device.localizedName,
]
}
}
// MARK: - 打开 / 关闭
func open(deviceId: String?, resolution: String?, result: @escaping FlutterResult) {
guard let deviceId = deviceId else {
result(FlutterError(code: "INVALID_ARG", message: "deviceId 为空", details: nil))
return
}
let handle = { [weak self] in
guard let self = self else { return }
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
self.doOpen(deviceId: deviceId, resolution: resolution, result: result)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
if granted {
self.doOpen(deviceId: deviceId, resolution: resolution, result: result)
} else {
result(FlutterError(code: "PERMISSION_DENIED",
message: "用户拒绝了相机权限", details: nil))
}
}
}
default:
result(FlutterError(code: "PERMISSION_DENIED",
message: "相机权限未授权,请在系统设置中开启", details: nil))
}
}
if Thread.isMainThread { handle() } else { DispatchQueue.main.async(execute: handle) }
}
private func doOpen(deviceId: String, resolution: String?, result: @escaping FlutterResult) {
let candidates = discoverySession().devices
guard let device = candidates.first(where: { $0.uniqueID == deviceId }) else {
result(FlutterError(code: "DEVICE_NOT_FOUND",
message: "USB 设备已拔出或未检测到,请重新插入后重试", details: nil))
return
}
stopRunningIfNeeded()
let session = AVCaptureSession()
session.beginConfiguration()
if let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) {
session.addInput(input)
}
if let resolution = resolution {
applyResolution(resolution, to: device)
}
session.commitConfiguration()
self.captureSession = session
self.currentDevice = device
workQueue.async {
session.startRunning()
}
isRunning = true
postEvent(type: "opened", deviceId: deviceId)
result(nil)
}
func close(result: FlutterResult?) {
stopRunningIfNeeded()
captureSession = nil
currentDevice = nil
result?(nil)
}
private func stopRunningIfNeeded() {
if isRunning, let session = captureSession {
workQueue.async {
if session.isRunning { session.stopRunning() }
}
}
isRunning = false
}
// MARK: - 分辨率
func getSupportedResolutions() -> [String] {
guard let device = currentDevice else { return [] }
var seen = Set<String>()
var list: [String] = []
for fmt in device.formats {
let dims = CMFormatDescriptionGetDimensions(fmt.description)
let key = "\(dims.width)x\(dims.height)"
if seen.insert(key).inserted { list.append(key) }
}
return list
}
func getPreviewSize() -> [String: Any]? {
guard isRunning, let device = currentDevice else { return nil }
let dims = CMFormatDescriptionGetDimensions(device.activeFormat.description)
return ["width": Int(dims.width), "height": Int(dims.height)]
}
func setResolution(_ resolution: String?, result: FlutterResult) {
guard let resolution = resolution, let device = currentDevice else {
result(FlutterError(code: "NO_DEVICE", message: "摄像头未打开", details: nil))
return
}
if applyResolution(resolution, to: device) {
postEvent(type: "opened", deviceId: device.uniqueID)
result(nil)
} else {
result(FlutterError(code: "RESOLUTION_UNSUPPORTED",
message: "不支持的分辨率 \(resolution)", details: nil))
}
}
@discardableResult
private func applyResolution(_ resolution: String, to device: AVCaptureDevice) -> Bool {
let parts = resolution.lowercased().split(separator: "x")
guard parts.count == 2, let w = Int(parts[0]), let h = Int(parts[1]) else { return false }
do {
try device.lockForConfiguration()
for fmt in device.formats {
let dims = CMFormatDescriptionGetDimensions(fmt.description)
if Int(dims.width) == w && Int(dims.height) == h {
device.activeFormat = fmt
device.unlockForConfiguration()
return true
}
}
device.unlockForConfiguration()
} catch {
return false
}
return false
}
// MARK: - 插拔监听
func startMonitoring() {
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(deviceConnected(_:)),
name: AVCaptureDevice.wasConnectedNotification, object: nil)
nc.addObserver(self, selector: #selector(deviceDisconnected(_:)),
name: AVCaptureDevice.wasDisconnectedNotification, object: nil)
}
@objc private func deviceConnected(_ note: Notification) {
guard let device = note.object as? AVCaptureDevice else { return }
postEvent(type: "attached", deviceId: device.uniqueID)
}
@objc private func deviceDisconnected(_ note: Notification) {
guard let device = note.object as? AVCaptureDevice else { return }
if device.uniqueID == currentDevice?.uniqueID {
stopRunningIfNeeded()
}
postEvent(type: "detached", deviceId: device.uniqueID)
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
private func postEvent(type: String, deviceId: String?) {
let payload: [String: Any] = [
"type": type,
"data": deviceId == nil ? [:] : ["deviceId": deviceId!],
]
DispatchQueue.main.async { [weak self] in
self?.eventSink?(payload)
}
}
func dispose() {
NotificationCenter.default.removeObserver(self)
stopRunningIfNeeded()
captureSession = nil
currentDevice = nil
}
}