Files
flutterApp/packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift
2026-10-22 13:22:35 +08:00

273 lines
9.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}
}