723 lines
22 KiB
Swift
723 lines
22 KiB
Swift
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 latestFileSaved = true
|
||
private var latestFileErrorMessage: String?
|
||
private var pendingDisplayName: String?
|
||
private var recordingStartedAt: Date?
|
||
private var elapsedTimer: Timer?
|
||
private var pendingStopResult: FlutterResult?
|
||
private var currentZoomRatio: CGFloat = 1.0
|
||
|
||
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.latestFileSaved = true
|
||
self.latestFileErrorMessage = nil
|
||
let outputURL = try self.createOutputURL(displayName: displayName)
|
||
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 {
|
||
var payload: [String: Any] = [
|
||
"outputPath": self.latestOutputPath as Any,
|
||
"status": self.currentStatusMap(),
|
||
"fileSaved": self.latestFileSaved,
|
||
]
|
||
if !self.latestFileSaved {
|
||
payload["fileErrorMessage"] =
|
||
self.latestFileErrorMessage ?? "保存到文件夹失败"
|
||
}
|
||
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.currentZoomRatio = 1.0
|
||
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 zoomCapabilities(result: @escaping FlutterResult) {
|
||
sessionQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let capabilities = self.currentZoomCapabilitiesMap()
|
||
DispatchQueue.main.async {
|
||
result(capabilities)
|
||
}
|
||
}
|
||
}
|
||
|
||
func setZoomRatio(_ ratio: CGFloat, result: @escaping FlutterResult) {
|
||
sessionQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
guard let device = self.videoInput?.device else {
|
||
self.currentZoomRatio = max(1.0, ratio)
|
||
let capabilities = self.currentZoomCapabilitiesMap()
|
||
DispatchQueue.main.async {
|
||
result(capabilities)
|
||
}
|
||
return
|
||
}
|
||
|
||
do {
|
||
// 入参是显示倍率(1.0x = 主摄),按 S 基准换算回设备 zoomFactor。
|
||
let baseline = self.mainBaselineFactor(for: device)
|
||
let nextZoom = self.clampedZoomRatio(ratio * baseline, for: device)
|
||
try device.lockForConfiguration()
|
||
device.videoZoomFactor = nextZoom
|
||
device.unlockForConfiguration()
|
||
self.currentZoomRatio = nextZoom
|
||
let capabilities = self.currentZoomCapabilitiesMap()
|
||
DispatchQueue.main.async {
|
||
result(capabilities)
|
||
}
|
||
} catch {
|
||
DispatchQueue.main.async {
|
||
result(
|
||
FlutterError(
|
||
code: "ZOOM_FAILED",
|
||
message: error.localizedDescription,
|
||
details: nil
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func fileOutput(
|
||
_ output: AVCaptureFileOutput,
|
||
didFinishRecordingTo outputFileURL: URL,
|
||
from connections: [AVCaptureConnection],
|
||
error: Error?
|
||
) {
|
||
let stopResult = pendingStopResult
|
||
pendingStopResult = nil
|
||
|
||
if let error {
|
||
latestFileSaved = false
|
||
latestFileErrorMessage = error.localizedDescription
|
||
updateStatus(
|
||
RecordingStatus(
|
||
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||
finishStopRecording(stopResult: stopResult)
|
||
return
|
||
}
|
||
|
||
latestFileSaved = true
|
||
latestFileErrorMessage = nil
|
||
latestOutputPath = outputFileURL.path
|
||
guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
|
||
latestFileSaved = false
|
||
latestFileErrorMessage = "录制文件未生成"
|
||
updateStatus(
|
||
RecordingStatus(
|
||
state: .error,
|
||
outputPath: latestOutputPath,
|
||
message: latestFileErrorMessage
|
||
)
|
||
)
|
||
finishStopRecording(stopResult: stopResult)
|
||
return
|
||
}
|
||
updateStatus(
|
||
RecordingStatus(
|
||
state: .previewing,
|
||
outputPath: latestOutputPath,
|
||
elapsedMillis: elapsedMillis()
|
||
)
|
||
)
|
||
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(),
|
||
"fileSaved": self.latestFileSaved,
|
||
]
|
||
if !self.latestFileSaved {
|
||
payload["fileErrorMessage"] =
|
||
self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
|
||
}
|
||
stopResult?(payload)
|
||
}
|
||
}
|
||
|
||
private func configureSession(withAudio: Bool) throws {
|
||
if configured {
|
||
try configureAudioInput(enabled: withAudio)
|
||
return
|
||
}
|
||
|
||
guard let videoDevice = Self.preferredVideoDevice() 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
|
||
// 默认以主摄(显示 1.0x)开场:虚拟多摄设备里主摄对应的 zoomFactor 是 S。
|
||
currentZoomRatio = mainBaselineFactor(for: videoDevice)
|
||
try applyCurrentZoom()
|
||
try configureAudioInput(enabled: withAudio)
|
||
}
|
||
|
||
/// 优先选用包含超广角的虚拟多摄设备,使 minAvailableVideoZoomFactor 能低于主摄(从而支持 0.6x)。
|
||
private static func preferredVideoDevice() -> AVCaptureDevice? {
|
||
let preferredTypes: [AVCaptureDevice.DeviceType] = [
|
||
.builtInTripleCamera,
|
||
.builtInDualWideCamera,
|
||
.builtInWideAngleCamera,
|
||
]
|
||
for type in preferredTypes {
|
||
if let device = AVCaptureDevice.default(type, for: .video, position: .back) {
|
||
return device
|
||
}
|
||
}
|
||
return AVCaptureDevice.default(for: .video)
|
||
}
|
||
|
||
/// 虚拟多摄设备中「主摄(显示 1.0x)」对应的设备 zoomFactor 基准 S。
|
||
/// 取 ultra-wide → wide 的切换点;非虚拟设备无切换点时返回 1.0(向后兼容)。
|
||
private func mainBaselineFactor(for device: AVCaptureDevice) -> CGFloat {
|
||
if let first = device.virtualDeviceSwitchOverVideoZoomFactors.first {
|
||
let value = CGFloat(truncating: first)
|
||
if value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
return 1.0
|
||
}
|
||
|
||
private func currentZoomCapabilitiesMap() -> [String: Any] {
|
||
guard let device = videoInput?.device else {
|
||
return [
|
||
"zoomRatio": Double(currentZoomRatio),
|
||
"minZoomRatio": 1.0,
|
||
"maxZoomRatio": 3.0,
|
||
]
|
||
}
|
||
|
||
// 设备 zoomFactor 以 S 为基准换算成 App 使用的「显示倍率」(1.0x = 主摄)。
|
||
let baseline = mainBaselineFactor(for: device)
|
||
let minZoom = device.minAvailableVideoZoomFactor
|
||
let maxZoom = device.maxAvailableVideoZoomFactor
|
||
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
|
||
currentZoomRatio = zoom
|
||
return [
|
||
"zoomRatio": Double(zoom / baseline),
|
||
"minZoomRatio": Double(minZoom / baseline),
|
||
"maxZoomRatio": Double(maxZoom / baseline),
|
||
]
|
||
}
|
||
|
||
private func applyCurrentZoom() throws {
|
||
guard let device = videoInput?.device else { return }
|
||
let nextZoom = clampedZoomRatio(currentZoomRatio, for: device)
|
||
try device.lockForConfiguration()
|
||
device.videoZoomFactor = nextZoom
|
||
device.unlockForConfiguration()
|
||
currentZoomRatio = nextZoom
|
||
}
|
||
|
||
private func clampedZoomRatio(_ ratio: CGFloat, for device: AVCaptureDevice) -> CGFloat {
|
||
min(max(ratio, device.minAvailableVideoZoomFactor), device.maxAvailableVideoZoomFactor)
|
||
}
|
||
|
||
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 uniqueOutputURL(in: recordingsURL, preferredFileName: fileName)
|
||
}
|
||
|
||
private func uniqueOutputURL(in directoryURL: URL, preferredFileName: String) -> URL {
|
||
let preferredURL = directoryURL.appendingPathComponent(preferredFileName)
|
||
guard !FileManager.default.fileExists(atPath: preferredURL.path) else {
|
||
let fileExtension = preferredURL.pathExtension
|
||
let baseName = preferredURL.deletingPathExtension().lastPathComponent
|
||
let timestamp = Self.fileNameDateFormatter.string(from: Date())
|
||
|
||
var index = 0
|
||
while true {
|
||
let suffix = index == 0 ? timestamp : "\(timestamp)_\(index)"
|
||
let nextName = fileExtension.isEmpty
|
||
? "\(baseName)_\(suffix)"
|
||
: "\(baseName)_\(suffix).\(fileExtension)"
|
||
let nextURL = directoryURL.appendingPathComponent(nextName)
|
||
if !FileManager.default.fileExists(atPath: nextURL.path) {
|
||
return nextURL
|
||
}
|
||
index += 1
|
||
}
|
||
}
|
||
return preferredURL
|
||
}
|
||
|
||
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 static let fileNameDateFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||
return formatter
|
||
}()
|
||
|
||
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.dronex.rec"
|
||
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 "getZoomCapabilities":
|
||
controller.zoomCapabilities(result: result)
|
||
case "setZoomRatio":
|
||
let args = call.arguments as? [String: Any]
|
||
let ratio = args?["zoomRatio"] as? Double ?? 1.0
|
||
controller.setZoomRatio(CGFloat(ratio), 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
|
||
}
|
||
}
|