From 615d76a8ce5974c5a6b348ebb7a16b69984df6fd Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Thu, 22 Oct 2026 12:54:08 +0800 Subject: [PATCH] =?UTF-8?q?USB=20=E6=91=84=E5=83=8F=E5=A4=B4=E9=80=82?= =?UTF-8?q?=E9=85=8DiPadOS17+ios=E7=AB=AF=E7=9A=84=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ios/Classes/UsbCameraAndroidPlugin.swift | 74 +++++ .../ios/Classes/UsbCameraEngine.swift | 256 ++++++++++++++++++ .../ios/Classes/UsbCameraView.swift | 51 ++++ .../ios/usb_camera_android.podspec | 20 ++ 4 files changed, 401 insertions(+) create mode 100644 packages/usb_camera_android/ios/Classes/UsbCameraAndroidPlugin.swift create mode 100644 packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift create mode 100644 packages/usb_camera_android/ios/Classes/UsbCameraView.swift create mode 100644 packages/usb_camera_android/ios/usb_camera_android.podspec diff --git a/packages/usb_camera_android/ios/Classes/UsbCameraAndroidPlugin.swift b/packages/usb_camera_android/ios/Classes/UsbCameraAndroidPlugin.swift new file mode 100644 index 00000000..626aa4dd --- /dev/null +++ b/packages/usb_camera_android/ios/Classes/UsbCameraAndroidPlugin.swift @@ -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) + } + } +} diff --git a/packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift b/packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift new file mode 100644 index 00000000..6b4cd6f9 --- /dev/null +++ b/packages/usb_camera_android/ios/Classes/UsbCameraEngine.swift @@ -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() + 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 + } +} diff --git a/packages/usb_camera_android/ios/Classes/UsbCameraView.swift b/packages/usb_camera_android/ios/Classes/UsbCameraView.swift new file mode 100644 index 00000000..0fc7016e --- /dev/null +++ b/packages/usb_camera_android/ios/Classes/UsbCameraView.swift @@ -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) + } +} diff --git a/packages/usb_camera_android/ios/usb_camera_android.podspec b/packages/usb_camera_android/ios/usb_camera_android.podspec new file mode 100644 index 00000000..63791857 --- /dev/null +++ b/packages/usb_camera_android/ios/usb_camera_android.podspec @@ -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