import AVFoundation import Flutter import Photos import UIKit private enum RecordingState: String { case idle case previewing case recording case stopping case error } private struct RecordingStatus { let state: RecordingState let outputPath: String? let elapsedMillis: Int let message: String? init( state: RecordingState, outputPath: String? = nil, elapsedMillis: Int = 0, message: String? = nil ) { self.state = state self.outputPath = outputPath self.elapsedMillis = elapsedMillis self.message = message } func toMap() -> [String: Any] { var map: [String: Any] = [ "state": state.rawValue, "elapsedMillis": elapsedMillis, ] if let outputPath { map["outputPath"] = outputPath } if let message { map["message"] = message } return map } } private final class RecordingPreviewView: UIView { override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } override init(frame: CGRect) { super.init(frame: frame) previewLayer.videoGravity = .resizeAspectFill backgroundColor = .black } required init?(coder: NSCoder) { super.init(coder: coder) previewLayer.videoGravity = .resizeAspectFill backgroundColor = .black } } private final class RecordingPreviewPlatformView: NSObject, FlutterPlatformView { private let previewView: RecordingPreviewView init(frame: CGRect) { previewView = RecordingPreviewView(frame: frame) super.init() RecordingCameraController.shared.attach(previewView: previewView) } func view() -> UIView { previewView } deinit { RecordingCameraController.shared.detach(previewView: previewView) } } private final class RecordingPreviewFactory: NSObject, FlutterPlatformViewFactory { func create( withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any? ) -> FlutterPlatformView { RecordingPreviewPlatformView(frame: frame) } func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { FlutterStandardMessageCodec.sharedInstance() } } private final class RecordingCameraController: NSObject, AVCaptureFileOutputRecordingDelegate { static let shared = RecordingCameraController() private let session = AVCaptureSession() private let sessionQueue = DispatchQueue(label: "recording.camera.session") private let movieOutput = AVCaptureMovieFileOutput() private weak var previewView: RecordingPreviewView? private var videoInput: AVCaptureDeviceInput? private var audioInput: AVCaptureDeviceInput? private var configured = false private var latestOutputPath: String? private var latestGallerySaved = true private var latestGalleryErrorMessage: String? private var pendingDisplayName: String? private var recordingStartedAt: Date? private var elapsedTimer: Timer? private var pendingStopResult: FlutterResult? private(set) var status = RecordingStatus(state: .idle) { didSet { DispatchQueue.main.async { [weak self] in guard let self else { return } self.statusListener?(self.currentStatusMap()) } } } var statusListener: (([String: Any]) -> Void)? func attach(previewView: RecordingPreviewView) { let bindPreview = { [weak self, weak previewView] in guard let self, let previewView else { return } self.previewView = previewView previewView.previewLayer.session = self.session } if Thread.isMainThread { bindPreview() } else { DispatchQueue.main.async(execute: bindPreview) } } func detach(previewView: RecordingPreviewView) { let unbindPreview = { [weak self, weak previewView] in guard let self, let previewView else { return } if self.previewView === previewView { previewView.previewLayer.session = nil self.previewView = nil } } if Thread.isMainThread { unbindPreview() } else { DispatchQueue.main.async(execute: unbindPreview) } } func initializePreview(result: @escaping FlutterResult) { guard let previewView else { result( FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil)) return } previewView.previewLayer.session = session sessionQueue.async { [weak self] in guard let self else { return } do { try self.configureSession(withAudio: self.isMicrophoneAuthorized()) if !self.session.isRunning { self.session.startRunning() } self.updateStatus(RecordingStatus(state: .previewing)) DispatchQueue.main.async { result(self.currentStatusMap()) } } catch { self.updateStatus(RecordingStatus(state: .error, message: error.localizedDescription)) DispatchQueue.main.async { result( FlutterError( code: "PREVIEW_FAILED", message: error.localizedDescription, details: nil ) ) } } } } func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) { guard previewView != nil else { result( FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil)) return } sessionQueue.async { [weak self] in guard let self else { return } do { try self.configureSession(withAudio: withAudio && self.isMicrophoneAuthorized()) if !self.session.isRunning { self.session.startRunning() } guard !self.movieOutput.isRecording else { DispatchQueue.main.async { result(FlutterError(code: "START_FAILED", message: "Already recording", details: nil)) } return } self.pendingDisplayName = displayName self.latestGallerySaved = true self.latestGalleryErrorMessage = nil let outputURL = try self.createOutputURL(displayName: displayName) self.latestOutputPath = outputURL.lastPathComponent self.recordingStartedAt = Date() self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path)) self.movieOutput.startRecording(to: outputURL, recordingDelegate: self) DispatchQueue.main.async { self.startElapsedTimer() result([ "outputPath": outputURL.path, "status": self.currentStatusMap(), ]) } } catch { self.updateStatus(RecordingStatus(state: .error, message: error.localizedDescription)) DispatchQueue.main.async { result( FlutterError( code: "START_FAILED", message: error.localizedDescription, details: nil ) ) } } } } func stopRecording(result: @escaping FlutterResult) { sessionQueue.async { [weak self] in guard let self else { return } guard self.movieOutput.isRecording else { DispatchQueue.main.async { var payload: [String: Any] = [ "outputPath": self.latestOutputPath as Any, "status": self.currentStatusMap(), "gallerySaved": self.latestGallerySaved, ] if !self.latestGallerySaved { payload["galleryErrorMessage"] = self.latestGalleryErrorMessage ?? "保存到相册失败" } result(payload) } return } self.pendingStopResult = result self.updateStatus(RecordingStatus(state: .stopping, outputPath: self.latestOutputPath)) self.movieOutput.stopRecording() } } func disposePreview(result: @escaping FlutterResult) { sessionQueue.async { [weak self] in guard let self else { return } if self.movieOutput.isRecording { self.movieOutput.stopRecording() } if self.session.isRunning { self.session.stopRunning() } self.session.beginConfiguration() for input in self.session.inputs { self.session.removeInput(input) } for output in self.session.outputs { self.session.removeOutput(output) } self.session.commitConfiguration() self.videoInput = nil self.audioInput = nil self.configured = false self.updateStatus(RecordingStatus(state: .idle)) DispatchQueue.main.async { self.stopElapsedTimer() result(nil) } } } func currentStatusMap() -> [String: Any] { if status.state == .recording { return RecordingStatus( state: .recording, outputPath: latestOutputPath, elapsedMillis: elapsedMillis() ).toMap() } return status.toMap() } func fileOutput( _ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error? ) { let stopResult = pendingStopResult pendingStopResult = nil if let error { latestGallerySaved = false latestGalleryErrorMessage = error.localizedDescription updateStatus( RecordingStatus( state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) finishStopRecording(stopResult: stopResult) return } saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in guard let self else { return } self.latestGallerySaved = success self.latestGalleryErrorMessage = message if success { self.updateStatus( RecordingStatus( state: .previewing, outputPath: self.latestOutputPath, elapsedMillis: self.elapsedMillis() ) ) } else { self.updateStatus( RecordingStatus( state: .error, outputPath: self.latestOutputPath, message: message ?? "保存到相册失败" ) ) } self.finishStopRecording(stopResult: stopResult) } } private func finishStopRecording(stopResult: FlutterResult?) { DispatchQueue.main.async { [weak self] in guard let self else { return } self.stopElapsedTimer() var payload: [String: Any] = [ "outputPath": self.latestOutputPath as Any, "status": self.currentStatusMap(), "gallerySaved": self.latestGallerySaved, ] if !self.latestGallerySaved { payload["galleryErrorMessage"] = self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限" } stopResult?(payload) } } private func saveVideoToPhotoLibrary( fileURL: URL, completion: @escaping (Bool, String?) -> Void ) { let performSave = { PHPhotoLibrary.shared().performChanges({ PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil) }) { success, error in if success { try? FileManager.default.removeItem(at: fileURL) completion(true, nil) } else { completion(false, error?.localizedDescription ?? "保存到相册失败") } } } if #available(iOS 14, *) { let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) switch status { case .authorized, .limited: performSave() case .notDetermined: PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in if newStatus == .authorized || newStatus == .limited { performSave() } else { completion(false, "未授予相册权限") } } default: completion(false, "未授予相册权限") } } else { let status = PHPhotoLibrary.authorizationStatus() switch status { case .authorized: performSave() case .notDetermined: PHPhotoLibrary.requestAuthorization { newStatus in if newStatus == .authorized { performSave() } else { completion(false, "未授予相册权限") } } default: completion(false, "未授予相册权限") } } } private func configureSession(withAudio: Bool) throws { if configured { try configureAudioInput(enabled: withAudio) return } guard let videoDevice = AVCaptureDevice.default( .builtInWideAngleCamera, for: .video, position: .back) ?? AVCaptureDevice.default(for: .video) else { throw NSError( domain: "RecordingCamera", code: 1, userInfo: [NSLocalizedDescriptionKey: "No camera device available"]) } let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice) session.beginConfiguration() session.sessionPreset = .high guard session.canAddInput(nextVideoInput) else { session.commitConfiguration() throw NSError( domain: "RecordingCamera", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"]) } session.addInput(nextVideoInput) videoInput = nextVideoInput guard session.canAddOutput(movieOutput) else { session.commitConfiguration() throw NSError( domain: "RecordingCamera", code: 3, userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"]) } session.addOutput(movieOutput) session.commitConfiguration() configured = true try configureAudioInput(enabled: withAudio) } private func configureAudioInput(enabled: Bool) throws { session.beginConfiguration() defer { session.commitConfiguration() } if let audioInput { session.removeInput(audioInput) self.audioInput = nil } guard enabled else { return } guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return } let nextAudioInput = try AVCaptureDeviceInput(device: audioDevice) if session.canAddInput(nextAudioInput) { session.addInput(nextAudioInput) audioInput = nextAudioInput } } private func isMicrophoneAuthorized() -> Bool { AVCaptureDevice.authorizationStatus(for: .audio) == .authorized } private func createOutputURL(displayName: String?) throws -> URL { let baseURL = try FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true) try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true) let fileName = Self.resolveFileName(displayName: displayName) return recordingsURL.appendingPathComponent(fileName) } private static func resolveFileName(displayName: String?) -> String { let trimmed = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmed.isEmpty { let lower = trimmed.lowercased() if lower.hasSuffix(".mov") || lower.hasSuffix(".mp4") { return trimmed } return "\(trimmed).mov" } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyyMMdd_HHmmss" return "REC_\(formatter.string(from: Date())).mov" } private func updateStatus(_ next: RecordingStatus) { status = next } private func elapsedMillis() -> Int { guard let recordingStartedAt else { return 0 } return max(0, Int(Date().timeIntervalSince(recordingStartedAt) * 1000)) } private func startElapsedTimer() { stopElapsedTimer() elapsedTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self, self.status.state == .recording else { return } self.statusListener?(self.currentStatusMap()) } } private func stopElapsedTimer() { elapsedTimer?.invalidate() elapsedTimer = nil } } private enum RecordingChannelNames { static let packageName = "com.qxy.dronex" static let method = "\(packageName)/recording" static let events = "\(packageName)/recording_events" } final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { private let controller = RecordingCameraController.shared private var eventSink: FlutterEventSink? static func register(with registrar: FlutterPluginRegistrar) { let plugin = RecordingPlugin() let messenger = registrar.messenger() registrar.register(RecordingPreviewFactory(), withId: "recording-camera-preview") let methodChannel = FlutterMethodChannel( name: RecordingChannelNames.method, binaryMessenger: messenger ) registrar.addMethodCallDelegate(plugin, channel: methodChannel) let eventChannel = FlutterEventChannel( name: RecordingChannelNames.events, binaryMessenger: messenger ) eventChannel.setStreamHandler(plugin) plugin.controller.statusListener = { [weak plugin] status in plugin?.eventSink?(status) } } func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "initializePreview": controller.initializePreview(result: result) case "startRecording": let args = call.arguments as? [String: Any] let withAudio = args?["withAudio"] as? Bool ?? true let displayName = args?["displayName"] as? String controller.startRecording(withAudio: withAudio, displayName: displayName, result: result) case "stopRecording": controller.stopRecording(result: result) case "disposePreview": controller.disposePreview(result: result) case "getStatus": result(controller.currentStatusMap()) case "hasNotificationPolicyAccess": result(true) case "openNotificationPolicySettings": result(nil) case "enableDoNotDisturb": result(false) case "disableDoNotDisturb": result(nil) case "isIgnoringBatteryOptimizations": result(true) case "openBatteryOptimizationSettings": result(nil) case "setImmersiveMode": result(nil) case "isForegroundServiceRunning": result(false) default: result(FlutterMethodNotImplemented) } } func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { eventSink = events events(controller.currentStatusMap()) return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { eventSink = nil return nil } }