Files
record-tool/ios/Runner/RecordingPlugin.swift

598 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.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
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
}
}