USB 摄像头适配iPadOS17+ios端的配置
This commit is contained in:
256
packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift
Normal file
256
packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user