兼容 IOS 端
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
.vscode
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -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 */;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -2,12 +2,18 @@ import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -24,6 +26,33 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要访问相机以显示预览并录制视频。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -41,9 +70,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -17,6 +17,24 @@ class PermissionService {
|
||||
return permissions.toList().request();
|
||||
}
|
||||
|
||||
/// 仅对尚未授予的权限发起系统授权弹窗,已授予则直接返回当前状态。
|
||||
static Future<Map<Permission, PermissionStatus>> requestMissing(
|
||||
Iterable<Permission> permissions,
|
||||
) async {
|
||||
final result = <Permission, PermissionStatus>{};
|
||||
for (final permission in permissions) {
|
||||
final current = await permission.status;
|
||||
if (current.isGranted ||
|
||||
current.isLimited ||
|
||||
current.isPermanentlyDenied) {
|
||||
result[permission] = current;
|
||||
continue;
|
||||
}
|
||||
result[permission] = await permission.request();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<bool> ensure(
|
||||
Permission permission, {
|
||||
bool openSettingsWhenPermanentlyDenied = true,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DemoState {
|
||||
const DemoState({this.count = 0, this.query = ''});
|
||||
|
||||
final int count;
|
||||
final String query;
|
||||
|
||||
DemoState copyWith({int? count, String? query}) {
|
||||
return DemoState(count: count ?? this.count, query: query ?? this.query);
|
||||
}
|
||||
}
|
||||
|
||||
class DemoController extends Notifier<DemoState> {
|
||||
@override
|
||||
DemoState build() => const DemoState();
|
||||
|
||||
void increment() {
|
||||
state = state.copyWith(count: state.count + 1);
|
||||
}
|
||||
|
||||
void updateQuery(String query) {
|
||||
state = state.copyWith(query: query);
|
||||
}
|
||||
}
|
||||
|
||||
final demoControllerProvider = NotifierProvider<DemoController, DemoState>(
|
||||
DemoController.new,
|
||||
);
|
||||
@@ -1,130 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/app/config/app_config.dart';
|
||||
import 'package:flutter_template/app/theme/app_theme.dart';
|
||||
import 'package:flutter_template/features/demo/demo_controller.dart';
|
||||
import 'package:flutter_template/features/recording/recording_page.dart';
|
||||
import 'package:flutter_template/shared/widgets/widgets.dart';
|
||||
|
||||
class DemoPage extends ConsumerWidget {
|
||||
const DemoPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(demoControllerProvider);
|
||||
final controller = ref.read(demoControllerProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text(AppConfig.appName)),
|
||||
body: SafeAreaWrapper(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
AppSearchBar(hint: '搜索模板组件', onChanged: controller.updateQuery),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const AppAvatar(initials: 'T', size: 48),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'通用 Flutter 快速开发模板',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'已内置网络、缓存、路由、主题、权限、日志和常用 UI 组件。',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
AppTag(label: 'Riverpod', tone: AppTagTone.info),
|
||||
AppTag(label: 'Dio', tone: AppTagTone.success),
|
||||
AppTag(label: '缓存', tone: AppTagTone.warning),
|
||||
AppTag(label: '无业务代码'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'状态管理示例',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('当前计数:${state.count}'),
|
||||
if (state.query.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text('搜索关键字:${state.query}'),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
AppButton(
|
||||
label: '增加计数',
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
onPressed: controller.increment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppButton(
|
||||
label: '打开录制',
|
||||
icon: const Icon(Icons.videocam, size: 18),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const RecordingPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppStatusView(
|
||||
status: AppViewStatus.empty,
|
||||
empty: AppEmptyView(
|
||||
title: '空状态组件',
|
||||
message: '业务项目可替换图标、文案和操作按钮。',
|
||||
action: AppButton(
|
||||
label: '显示确认弹窗',
|
||||
variant: AppButtonVariant.outline,
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
onPressed: () async {
|
||||
final confirmed = await AppDialog.confirm(
|
||||
context,
|
||||
title: '模板弹窗',
|
||||
message: '这是可复用的确认弹窗示例。',
|
||||
);
|
||||
if (confirmed == true) {
|
||||
AppToast.show('已确认');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
// Allow PlatformView to attach before binding CameraX preview.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingSessionControllerProvider.notifier).prepareSession();
|
||||
await ref
|
||||
.read(recordingSessionControllerProvider.notifier)
|
||||
.prepareSession();
|
||||
}
|
||||
|
||||
Future<void> _enterRecordingMode() async {
|
||||
@@ -140,12 +142,6 @@ class _RecordingHud extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: state.isRecording
|
||||
? null
|
||||
: () => Navigator.of(context).maybePop(),
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.isRecording)
|
||||
Container(
|
||||
@@ -183,7 +179,10 @@ class _RecordingHud extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
state.permissionWarning!,
|
||||
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -220,7 +219,9 @@ class _RecordingHud extends StatelessWidget {
|
||||
color: state.isRecording ? Colors.white : Colors.red,
|
||||
),
|
||||
child: Icon(
|
||||
state.isRecording ? Icons.stop : Icons.fiber_manual_record,
|
||||
state.isRecording
|
||||
? Icons.stop
|
||||
: Icons.fiber_manual_record,
|
||||
color: state.isRecording ? Colors.red : Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
@@ -280,16 +281,10 @@ class _SetupHints extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (!hasDndAccess)
|
||||
_HintChip(
|
||||
label: '开启勿扰权限可减少录制中断',
|
||||
onTap: onOpenDnd,
|
||||
),
|
||||
_HintChip(label: '开启勿扰权限可减少录制中断', onTap: onOpenDnd),
|
||||
if (!isBatteryIgnored) ...[
|
||||
const SizedBox(height: 8),
|
||||
_HintChip(
|
||||
label: '关闭电池优化可提升息屏续录稳定性',
|
||||
onTap: onOpenBattery,
|
||||
),
|
||||
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -53,7 +53,12 @@ class RecordingPlatform {
|
||||
'com.example.flutter_template/recording_events',
|
||||
);
|
||||
|
||||
static bool get isSupported => Platform.isAndroid;
|
||||
static bool get isSupported =>
|
||||
supportsHost(isAndroid: Platform.isAndroid, isIOS: Platform.isIOS);
|
||||
|
||||
static bool supportsHost({required bool isAndroid, required bool isIOS}) {
|
||||
return isAndroid || isIOS;
|
||||
}
|
||||
|
||||
static Stream<RecordingStatus>? _statusStream;
|
||||
|
||||
@@ -61,9 +66,10 @@ class RecordingPlatform {
|
||||
if (!isSupported) {
|
||||
return const Stream.empty();
|
||||
}
|
||||
_statusStream ??= _events
|
||||
.receiveBroadcastStream()
|
||||
.map((event) => RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)));
|
||||
_statusStream ??= _events.receiveBroadcastStream().map(
|
||||
(event) =>
|
||||
RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)),
|
||||
);
|
||||
return _statusStream!;
|
||||
}
|
||||
|
||||
@@ -105,7 +111,8 @@ class RecordingPlatform {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> disposePreview() => _channel.invokeMethod('disposePreview');
|
||||
static Future<void> disposePreview() =>
|
||||
_channel.invokeMethod('disposePreview');
|
||||
|
||||
static Future<bool> hasNotificationPolicyAccess() async {
|
||||
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
|
||||
@@ -136,10 +143,9 @@ class RecordingPlatform {
|
||||
}
|
||||
|
||||
static Future<void> setImmersiveMode({required bool enabled}) {
|
||||
return _channel.invokeMethod(
|
||||
'setImmersiveMode',
|
||||
<String, dynamic>{'enabled': enabled},
|
||||
);
|
||||
return _channel.invokeMethod('setImmersiveMode', <String, dynamic>{
|
||||
'enabled': enabled,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<RecordingStatus> getStatus() async {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_template/core/permission/permission_service.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
@@ -91,11 +92,11 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final permissions = await <Permission>[
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
].request();
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
if (!cameraGranted) {
|
||||
|
||||
@@ -8,13 +8,7 @@ class CameraPreviewWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Platform.isAndroid) {
|
||||
return const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: Text('仅 Android 支持相机预览')),
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
return AndroidView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
@@ -22,4 +16,19 @@ class CameraPreviewWidget extends StatelessWidget {
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
return UiKitView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
}
|
||||
|
||||
return const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: Text('当前平台不支持相机预览')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
56
lib/features/webview/test.html
Normal file
56
lib/features/webview/test.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>复制赛事录制码</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button onclick="copyEventInfo()">复制赛事录制码</button>
|
||||
|
||||
<textarea id="copyText" style="position: fixed; left: -9999px;"></textarea>
|
||||
|
||||
<script>
|
||||
function copyEventInfo() {
|
||||
const data = {
|
||||
title: '王东方 丨李想 空中格斗赛',
|
||||
startTimestamp: 1717334400,
|
||||
endTimestamp: 1717334400,
|
||||
address: '广州市番禺区·粤港澳大湾区青年人才双创小镇',
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(data)
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(jsonStr)
|
||||
.then(() => {
|
||||
alert('赛事录制码已复制')
|
||||
})
|
||||
.catch(() => {
|
||||
fallbackCopy(jsonStr)
|
||||
})
|
||||
} else {
|
||||
fallbackCopy(jsonStr)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textarea = document.getElementById('copyText')
|
||||
textarea.value = text
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
alert('赛事录制码已复制')
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
alert('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
124
test/core/permission/permission_service_test.dart
Normal file
124
test/core/permission/permission_service_test.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart';
|
||||
|
||||
import 'package:flutter_template/core/permission/permission_service.dart';
|
||||
|
||||
void main() {
|
||||
group('PermissionService.requestMissing', () {
|
||||
late PermissionHandlerPlatform originalPlatform;
|
||||
|
||||
setUp(() {
|
||||
originalPlatform = PermissionHandlerPlatform.instance;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PermissionHandlerPlatform.instance = originalPlatform;
|
||||
});
|
||||
|
||||
test('requests only missing permissions and skips granted ones', () async {
|
||||
final platform = FakePermissionHandlerPlatform(
|
||||
statuses: <Permission, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.granted,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[Permission.microphone],
|
||||
]);
|
||||
expect(result[Permission.camera], PermissionStatus.granted);
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
});
|
||||
|
||||
test('preserves permanently denied permissions without requesting them',
|
||||
() async {
|
||||
final platform = FakePermissionHandlerPlatform(
|
||||
statuses: <Permission, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.permanentlyDenied,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[Permission.microphone],
|
||||
]);
|
||||
expect(result[Permission.camera], PermissionStatus.permanentlyDenied);
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
});
|
||||
});
|
||||
|
||||
group('iOS permission configuration', () {
|
||||
test('Podfile enables camera and microphone permission macros', () {
|
||||
final podfile = File('ios/Podfile').readAsStringSync();
|
||||
|
||||
expect(podfile, contains("flutter_additional_ios_build_settings(target)"));
|
||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class FakePermissionHandlerPlatform extends PermissionHandlerPlatform {
|
||||
FakePermissionHandlerPlatform({
|
||||
required this.statuses,
|
||||
required this.requestResults,
|
||||
});
|
||||
|
||||
final Map<Permission, PermissionStatus> statuses;
|
||||
final Map<Permission, PermissionStatus> requestResults;
|
||||
final List<List<Permission>> requestCalls = <List<Permission>>[];
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> checkPermissionStatus(Permission permission) async {
|
||||
return statuses[permission] ?? PermissionStatus.denied;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ServiceStatus> checkServiceStatus(Permission permission) async {
|
||||
return ServiceStatus.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openAppSettings() async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<Permission, PermissionStatus>> requestPermissions(
|
||||
List<Permission> permissions,
|
||||
) async {
|
||||
requestCalls.add(List<Permission>.unmodifiable(permissions));
|
||||
return <Permission, PermissionStatus>{
|
||||
for (final permission in permissions)
|
||||
permission: requestResults[permission] ?? PermissionStatus.granted,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldShowRequestPermissionRationale(
|
||||
Permission permission,
|
||||
) async {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
21
test/features/recording/recording_platform_test.dart
Normal file
21
test/features/recording/recording_platform_test.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_template/features/recording/recording_platform.dart';
|
||||
|
||||
void main() {
|
||||
group('RecordingPlatform support', () {
|
||||
test('supports Android and iOS hosts only', () {
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: true, isIOS: false),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: false, isIOS: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: false, isIOS: false),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user