// // UsbCameraEngine.swift // usb_camera_android // // iOS 端 UVC 外接摄像头引擎(iPadOS 17+)。 // 通过 AVCaptureDevice.DiscoverySession 枚举外接摄像头, // 用 AVCaptureSession + AVCaptureVideoPreviewLayer 出画面。 // 协议与 Android 端完全对齐:deviceId 使用 device.uniqueID。 // // 兼容性:整个 Runner / pod 的最低系统版本仍为 iOS 12。 // 外接摄像头类型用 rawValue 字符串构造(不直接引用 iOS 17 SDK 才有的静态符号), // 保证旧 SDK 也能编译;枚举逻辑仍用 #available(iOS 17.0,*) 保护, // iOS 17 以下枚举返回空、isSupported 返回 false,走优雅降级。 // import AVFoundation import CoreMedia 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: - 枚举 /// 外接摄像头设备类型。 /// /// 用 rawValue 字符串构造,而不是直接引用 `.external` / `.externalUnknown` /// 这两个 iOS 17 SDK 才有的静态符号——这样在较旧的 Xcode/SDK 下也能编译通过, /// 运行时(iPadOS 17+ 真机/模拟器)再真正枚举到 UVC 设备。 /// "AVCaptureDeviceTypeExternal" 是 iPadOS 17 对 UVC 外接摄像头的正式类型; /// "AVCaptureDeviceTypeExternalUnknown" 为其过时同义词,一并带上做兼容。 private var externalDeviceTypes: [AVCaptureDevice.DeviceType] { [ AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternal"), AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown"), ] } private func discoverySession() -> AVCaptureDevice.DiscoverySession { if #available(iOS 17.0, *) { return AVCaptureDevice.DiscoverySession( deviceTypes: externalDeviceTypes, 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() 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 } }