612 lines
18 KiB
Swift
612 lines
18 KiB
Swift
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) {
|
|
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, 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
|
|
}
|
|
}
|