Files
flutterApp/packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift

257 lines
8.9 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,
// 因此对 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
}
}