USB 摄像头适配iPadOS17+ios端的配置
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// UsbCameraAndroidPlugin.swift
|
||||
// usb_camera_android
|
||||
//
|
||||
// Flutter iOS 插件入口:注册 MethodChannel / EventChannel / PlatformView,
|
||||
// 通道名与 Android 端完全一致,上层 Dart 无需区分平台。
|
||||
//
|
||||
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
public class UsbCameraAndroidPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
|
||||
private static let METHOD_CHANNEL = "com.maibu.usb_camera_android/methods"
|
||||
private static let EVENT_CHANNEL = "com.maibu.usb_camera_android/events"
|
||||
private static let VIEW_TYPE = "com.maibu.usb_camera_android/preview"
|
||||
|
||||
private let engine = UsbCameraEngine()
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let instance = UsbCameraAndroidPlugin()
|
||||
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: METHOD_CHANNEL, binaryMessenger: registrar.messenger())
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: EVENT_CHANNEL, binaryMessenger: registrar.messenger())
|
||||
eventChannel.setStreamHandler(instance)
|
||||
|
||||
registrar.register(UsbCameraViewFactory(engine: instance.engine),
|
||||
withId: VIEW_TYPE)
|
||||
|
||||
instance.engine.startMonitoring()
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler(转发给 engine)
|
||||
|
||||
public func onListen(withArguments arguments: Any?,
|
||||
eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
return engine.onListen(withArguments: arguments, eventSink: events)
|
||||
}
|
||||
|
||||
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
return engine.onCancel(withArguments: arguments)
|
||||
}
|
||||
|
||||
// MARK: - MethodCallHandler
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "isSupported":
|
||||
result(engine.isSupported())
|
||||
case "getDeviceList":
|
||||
result(engine.getDeviceList())
|
||||
case "getSupportedResolutions":
|
||||
result(engine.getSupportedResolutions())
|
||||
case "getPreviewSize":
|
||||
result(engine.getPreviewSize())
|
||||
case "open":
|
||||
let args = call.arguments as? [String: Any]
|
||||
engine.open(deviceId: args?["deviceId"] as? String,
|
||||
resolution: args?["resolution"] as? String,
|
||||
result: result)
|
||||
case "close":
|
||||
engine.close(result: result)
|
||||
case "setResolution":
|
||||
let args = call.arguments as? [String: Any]
|
||||
engine.setResolution(args?["resolution"] as? String, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
51
packages/usb_camera_android/ios/Classes/UsbCameraView.swift
Normal file
51
packages/usb_camera_android/ios/Classes/UsbCameraView.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// UsbCameraView.swift
|
||||
// usb_camera_android
|
||||
//
|
||||
// iOS 原生预览 PlatformView:用 AVCaptureVideoPreviewLayer 直接渲染 session。
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
/// 以 AVCaptureVideoPreviewLayer 作为 backing layer 的 UIView。
|
||||
final class UsbCameraPreviewUIView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||
return layer as! AVCaptureVideoPreviewLayer
|
||||
}
|
||||
}
|
||||
|
||||
final class UsbCameraView: NSObject, FlutterPlatformView {
|
||||
private let previewView: UsbCameraPreviewUIView
|
||||
|
||||
init(engine: UsbCameraEngine, frame: CGRect) {
|
||||
self.previewView = UsbCameraPreviewUIView(frame: frame)
|
||||
super.init()
|
||||
previewView.previewLayer.session = engine.captureSession
|
||||
previewView.previewLayer.videoGravity = .resizeAspect
|
||||
}
|
||||
|
||||
func view() -> UIView {
|
||||
return previewView
|
||||
}
|
||||
}
|
||||
|
||||
final class UsbCameraViewFactory: NSObject, FlutterPlatformViewFactory {
|
||||
private let engine: UsbCameraEngine
|
||||
|
||||
init(engine: UsbCameraEngine) {
|
||||
self.engine = engine
|
||||
super.init()
|
||||
}
|
||||
|
||||
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
|
||||
return FlutterStandardMessageCodec.sharedInstance()
|
||||
}
|
||||
|
||||
func create(withFrame frame: CGRect, viewIdentifier viewId: Int64,
|
||||
arguments args: Any?) -> FlutterPlatformView {
|
||||
return UsbCameraView(engine: engine, frame: frame)
|
||||
}
|
||||
}
|
||||
20
packages/usb_camera_android/ios/usb_camera_android.podspec
Normal file
20
packages/usb_camera_android/ios/usb_camera_android.podspec
Normal file
@@ -0,0 +1,20 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'usb_camera_android'
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'USB UVC camera preview plugin (iOS: iPadOS 17+ external cameras).'
|
||||
s.description = <<-DESC
|
||||
Local plugin for USB UVC camera preview. iOS side uses AVFoundation external
|
||||
camera APIs (iPadOS 17+, runtime-guarded). Android side uses UVCAndroid/libuvc.
|
||||
DESC
|
||||
s.homepage = 'http://1.95.137.212:59001/IOT/flutterApp'
|
||||
s.license = { :text => 'Copyright 2026 maibu. All rights reserved.' }
|
||||
s.author = { 'maibu' => 'dev@maibu.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '12.0'
|
||||
s.swift_version = '5.0'
|
||||
s.static_framework = true
|
||||
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
||||
end
|
||||
Reference in New Issue
Block a user