diff --git a/.gitignore b/.gitignore
index 3820a95..b479a87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@
.svn/
.swiftpm/
migrate_working_dir/
+.vscode
# IntelliJ related
*.iml
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf7..391a902 100644
--- a/ios/Flutter/AppFrameworkInfo.plist
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -20,7 +20,5 @@
????
CFBundleVersion
1.0
- MinimumOSVersion
- 13.0
diff --git a/ios/Podfile b/ios/Podfile
index 620e46e..e637465 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -39,5 +39,13 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
+
+ target.build_configurations.each do |config|
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
+ '$(inherited)',
+ 'PERMISSION_CAMERA=1',
+ 'PERMISSION_MICROPHONE=1',
+ ]
+ end
end
end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index d06d55a..5c99568 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1,67 +1,22 @@
PODS:
- - connectivity_plus (0.0.1):
- - Flutter
- - device_info_plus (0.0.1):
- - Flutter
- Flutter (1.0.0)
- - package_info_plus (0.4.5):
- - Flutter
- - path_provider_foundation (0.0.1):
- - Flutter
- - FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- - shared_preferences_foundation (0.0.1):
- - Flutter
- - FlutterMacOS
- - sqflite_darwin (0.0.4):
- - Flutter
- - FlutterMacOS
- - url_launcher_ios (0.0.1):
- - Flutter
DEPENDENCIES:
- - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES:
- connectivity_plus:
- :path: ".symlinks/plugins/connectivity_plus/ios"
- device_info_plus:
- :path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
- package_info_plus:
- :path: ".symlinks/plugins/package_info_plus/ios"
- path_provider_foundation:
- :path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
- shared_preferences_foundation:
- :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
- sqflite_darwin:
- :path: ".symlinks/plugins/sqflite_darwin/darwin"
- url_launcher_ios:
- :path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
- connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
- device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
- package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
- path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
- shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
- sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
- url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
-PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+PODFILE CHECKSUM: 6f58d96c1fb09d1d57dbcbf38a5d856d7302054a
COCOAPODS: 1.16.2
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 9d676a7..5e5dbfe 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -12,6 +12,8 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ 8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -51,6 +53,8 @@
69561E7442E8CC939DB2B482 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
+ 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = ""; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
95C6AC1536EA3DBA2D369C55 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
@@ -80,6 +84,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -121,6 +126,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -160,6 +166,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -198,7 +205,6 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
- CD126DA202245CE8BB749787 /* [CP] Embed Pods Frameworks */,
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */,
);
buildRules = (
@@ -206,6 +212,9 @@
dependencies = (
);
name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -239,6 +248,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+ );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -363,23 +375,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
- CD126DA202245CE8BB749787 /* [CP] Embed Pods Frameworks */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
- );
- name = "[CP] Embed Pods Frameworks";
- outputFileListPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
- showEnvVarsInLog = 0;
- };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -396,6 +391,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -744,6 +740,20 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index e3773d4..c3fedb2 100644
--- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -5,6 +5,24 @@
+
+
+
+
+
+
+
+
+
+
Bool {
- GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "RecordingPlugin") {
+ RecordingPlugin.register(with: registrar)
+ }
+ }
}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 6443a21..89f9e5c 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +26,33 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ NSCameraUsageDescription
+ 需要访问相机以显示预览并录制视频。
+ NSMicrophoneUsageDescription
+ 需要访问麦克风以录制视频声音;未授权时将静音录制。
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -41,9 +70,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift
new file mode 100644
index 0000000..7722915
--- /dev/null
+++ b/ios/Runner/RecordingPlugin.swift
@@ -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
+ }
+}
diff --git a/lib/app/app.dart b/lib/app/app.dart
index b5c2b7c..c6332fb 100644
--- a/lib/app/app.dart
+++ b/lib/app/app.dart
@@ -5,7 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_template/app/config/app_config.dart';
import 'package:flutter_template/app/router/app_navigator.dart';
import 'package:flutter_template/app/theme/app_theme.dart';
-import 'package:flutter_template/features/demo/demo_page.dart';
+import 'package:flutter_template/features/recording/recording_page.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class FlutterTemplateApp extends StatelessWidget {
@@ -44,7 +44,7 @@ class FlutterTemplateApp extends StatelessWidget {
home: RefreshConfiguration(
enableLoadingWhenNoData: false,
headerTriggerDistance: 80,
- child: const DemoPage(),
+ child: const RecordingPage(),
),
);
},
diff --git a/lib/core/permission/permission_service.dart b/lib/core/permission/permission_service.dart
index c5b5f37..b425da9 100644
--- a/lib/core/permission/permission_service.dart
+++ b/lib/core/permission/permission_service.dart
@@ -17,6 +17,24 @@ class PermissionService {
return permissions.toList().request();
}
+ /// 仅对尚未授予的权限发起系统授权弹窗,已授予则直接返回当前状态。
+ static Future