import AVFoundation import Flutter 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 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) { self.previewView = previewView previewView.previewLayer.session = session } func detach(previewView: RecordingPreviewView) { if self.previewView === previewView { self.previewView?.previewLayer.session = nil self.previewView = nil } } 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, 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 } let outputURL = try self.createOutputURL() self.latestOutputPath = outputURL.path 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 { result([ "outputPath": self.latestOutputPath, "status": self.currentStatusMap(), ]) } 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 { updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) } else { updateStatus( RecordingStatus( state: .previewing, outputPath: latestOutputPath, elapsedMillis: elapsedMillis() ) ) } DispatchQueue.main.async { [weak self] in guard let self else { return } self.stopElapsedTimer() stopResult?([ "outputPath": self.latestOutputPath, "status": self.currentStatusMap(), ]) } } 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() 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 formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyyMMdd_HHmmss" let filename = "REC_\(formatter.string(from: Date())).mov" return recordingsURL.appendingPathComponent(filename) } 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.gdfw.fxjk" 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 controller.startRecording(withAudio: withAudio, 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 } }