694 lines
20 KiB
Swift
694 lines
20 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 {
|
|
let nextZoom = self.clampedZoomRatio(ratio, 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 = 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 applyCurrentZoom()
|
|
try configureAudioInput(enabled: withAudio)
|
|
}
|
|
|
|
private func currentZoomCapabilitiesMap() -> [String: Any] {
|
|
guard let device = videoInput?.device else {
|
|
return [
|
|
"zoomRatio": Double(currentZoomRatio),
|
|
"minZoomRatio": 1.0,
|
|
"maxZoomRatio": 3.0,
|
|
]
|
|
}
|
|
|
|
let minZoom = device.minAvailableVideoZoomFactor
|
|
let maxZoom = device.maxAvailableVideoZoomFactor
|
|
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
|
|
currentZoomRatio = zoom
|
|
return [
|
|
"zoomRatio": Double(zoom),
|
|
"minZoomRatio": Double(minZoom),
|
|
"maxZoomRatio": Double(maxZoom),
|
|
]
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|