273 lines
9.8 KiB
Swift
273 lines
9.8 KiB
Swift
//
|
||
// 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<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
|
||
}
|
||
}
|