兼容 IOS 端
This commit is contained in:
485
ios/Runner/RecordingPlugin.swift
Normal file
485
ios/Runner/RecordingPlugin.swift
Normal file
@@ -0,0 +1,485 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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: "com.example.flutter_template/recording",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
registrar.addMethodCallDelegate(plugin, channel: methodChannel)
|
||||
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.example.flutter_template/recording_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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user