升级 Gradle → 8.14、AGP → 8.11、Kotlin → 2.2.20 JVM 堆降到 -Xmx4G
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.gdfw.fxjk">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
@@ -8,6 +9,10 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.util.Log
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.Recorder
|
||||
@@ -15,10 +14,6 @@ import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class RecordingCameraController(
|
||||
@@ -39,6 +34,7 @@ class RecordingCameraController(
|
||||
|
||||
private var recordingStartedAt: Long = 0L
|
||||
private var latestOutputPath: String? = null
|
||||
private var pendingStopCallback: ((String?) -> Unit)? = null
|
||||
|
||||
fun bindPreview(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
@@ -118,6 +114,7 @@ class RecordingCameraController(
|
||||
|
||||
fun startRecording(
|
||||
withAudio: Boolean,
|
||||
displayName: String?,
|
||||
onStarted: (Boolean, String?) -> Unit,
|
||||
) {
|
||||
val capture = videoCapture
|
||||
@@ -131,9 +128,12 @@ class RecordingCameraController(
|
||||
return
|
||||
}
|
||||
|
||||
val outputFile = createOutputFile()
|
||||
latestOutputPath = outputFile.absolutePath
|
||||
val outputOptions = FileOutputOptions.Builder(outputFile).build()
|
||||
val outputOptions =
|
||||
RecordingOutputFactory.buildMediaStoreOutputOptions(
|
||||
appContext,
|
||||
displayName,
|
||||
)
|
||||
latestOutputPath = null
|
||||
|
||||
val pending =
|
||||
capture.output.prepareRecording(appContext, outputOptions).apply {
|
||||
@@ -171,6 +171,7 @@ class RecordingCameraController(
|
||||
),
|
||||
)
|
||||
} else {
|
||||
latestOutputPath = event.outputResults.outputUri.toString()
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
RecordingState.PREVIEWING,
|
||||
@@ -179,11 +180,14 @@ class RecordingCameraController(
|
||||
),
|
||||
)
|
||||
}
|
||||
val stopCallback = pendingStopCallback
|
||||
pendingStopCallback = null
|
||||
stopCallback?.invoke(latestOutputPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onStarted(true, latestOutputPath)
|
||||
onStarted(true, latestOutputPath ?: "recording")
|
||||
}
|
||||
|
||||
fun stopRecording(onStopped: (String?) -> Unit) {
|
||||
@@ -193,6 +197,7 @@ class RecordingCameraController(
|
||||
return
|
||||
}
|
||||
|
||||
pendingStopCallback = onStopped
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
RecordingState.STOPPING,
|
||||
@@ -202,7 +207,6 @@ class RecordingCameraController(
|
||||
|
||||
recording.stop()
|
||||
activeRecording = null
|
||||
onStopped(latestOutputPath)
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
@@ -221,16 +225,6 @@ class RecordingCameraController(
|
||||
return System.currentTimeMillis() - recordingStartedAt
|
||||
}
|
||||
|
||||
private fun createOutputFile(): File {
|
||||
val moviesDir = File(appContext.getExternalFilesDir(null), "recordings")
|
||||
if (!moviesDir.exists()) {
|
||||
moviesDir.mkdirs()
|
||||
}
|
||||
val timestamp =
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
return File(moviesDir, "REC_$timestamp.mp4")
|
||||
}
|
||||
|
||||
private fun updateStatus(next: RecordingStatus) {
|
||||
status = next
|
||||
statusListener?.invoke(next)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.gdfw.fxjk.recording
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.camera.video.MediaStoreOutputOptions
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object RecordingOutputFactory {
|
||||
private const val RELATIVE_PATH = "Movies/飞行极控"
|
||||
private const val MIME_TYPE = "video/mp4"
|
||||
|
||||
fun buildMediaStoreOutputOptions(
|
||||
context: Context,
|
||||
displayName: String?,
|
||||
): MediaStoreOutputOptions {
|
||||
val fileName = resolveFileName(displayName)
|
||||
val contentValues =
|
||||
ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
return MediaStoreOutputOptions.Builder(
|
||||
context.contentResolver,
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||
)
|
||||
.setContentValues(contentValues)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun resolveFileName(displayName: String?): String {
|
||||
val trimmed = displayName?.trim().orEmpty()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
return if (trimmed.lowercase(Locale.US).endsWith(".mp4")) {
|
||||
trimmed
|
||||
} else {
|
||||
"$trimmed.mp4"
|
||||
}
|
||||
}
|
||||
val timestamp =
|
||||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
return "REC_$timestamp.mp4"
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,8 @@ class RecordingPlatformHandler(
|
||||
"startRecording" -> {
|
||||
val withAudio = call.argument<Boolean>("withAudio") ?: true
|
||||
val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: true
|
||||
startRecording(withAudio, enableDnd, result)
|
||||
val displayName = call.argument<String>("displayName")
|
||||
startRecording(withAudio, enableDnd, displayName, result)
|
||||
}
|
||||
"stopRecording" -> stopRecording(result)
|
||||
"disposePreview" -> {
|
||||
@@ -110,6 +111,7 @@ class RecordingPlatformHandler(
|
||||
private fun startRecording(
|
||||
withAudio: Boolean,
|
||||
enableDnd: Boolean,
|
||||
displayName: String?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val previewView = activity.recordingPreviewView
|
||||
@@ -125,7 +127,7 @@ class RecordingPlatformHandler(
|
||||
DoNotDisturbHelper.enable(activity)
|
||||
}
|
||||
|
||||
controller.startRecording(withAudio) { started, message ->
|
||||
controller.startRecording(withAudio, displayName) { started, message ->
|
||||
mainHandler.post {
|
||||
if (started) {
|
||||
startElapsedTicker()
|
||||
@@ -170,12 +172,19 @@ class RecordingPlatformHandler(
|
||||
RecordingSession.stopForeground(activity)
|
||||
DoNotDisturbHelper.disable(activity)
|
||||
mainHandler.post {
|
||||
result.success(
|
||||
mapOf(
|
||||
"outputPath" to path,
|
||||
"status" to controller.status.toMap(),
|
||||
),
|
||||
val gallerySaved =
|
||||
path != null &&
|
||||
controller.status.state != RecordingState.ERROR
|
||||
val payload = mutableMapOf<String, Any?>(
|
||||
"outputPath" to path,
|
||||
"status" to controller.status.toMap(),
|
||||
"gallerySaved" to gallerySaved,
|
||||
)
|
||||
if (!gallerySaved) {
|
||||
payload["galleryErrorMessage"] =
|
||||
controller.status.message ?: "保存到相册失败"
|
||||
}
|
||||
result.success(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -45,6 +45,8 @@ post_install do |installer|
|
||||
'$(inherited)',
|
||||
'PERMISSION_CAMERA=1',
|
||||
'PERMISSION_MICROPHONE=1',
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_PHOTOS_ADD_ONLY=1',
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
<string>需要访问相机以显示预览并录制视频。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要将录制的视频保存到相册。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFoundation
|
||||
import Flutter
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
private enum RecordingState: String {
|
||||
@@ -109,6 +110,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
private var audioInput: AVCaptureDeviceInput?
|
||||
private var configured = false
|
||||
private var latestOutputPath: String?
|
||||
private var latestGallerySaved = true
|
||||
private var latestGalleryErrorMessage: String?
|
||||
private var pendingDisplayName: String?
|
||||
private var recordingStartedAt: Date?
|
||||
private var elapsedTimer: Timer?
|
||||
private var pendingStopResult: FlutterResult?
|
||||
@@ -170,7 +174,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
}
|
||||
}
|
||||
|
||||
func startRecording(withAudio: Bool, result: @escaping FlutterResult) {
|
||||
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
|
||||
@@ -192,8 +196,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
return
|
||||
}
|
||||
|
||||
let outputURL = try self.createOutputURL()
|
||||
self.latestOutputPath = outputURL.path
|
||||
self.pendingDisplayName = displayName
|
||||
self.latestGallerySaved = true
|
||||
self.latestGalleryErrorMessage = nil
|
||||
let outputURL = try self.createOutputURL(displayName: displayName)
|
||||
self.latestOutputPath = outputURL.lastPathComponent
|
||||
self.recordingStartedAt = Date()
|
||||
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
||||
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||
@@ -226,10 +233,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
|
||||
guard self.movieOutput.isRecording else {
|
||||
DispatchQueue.main.async {
|
||||
result([
|
||||
"outputPath": self.latestOutputPath,
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
])
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
]
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败"
|
||||
}
|
||||
result(payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -291,24 +304,104 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
pendingStopResult = nil
|
||||
|
||||
if let error {
|
||||
latestGallerySaved = false
|
||||
latestGalleryErrorMessage = error.localizedDescription
|
||||
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||
} else {
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
state: .previewing,
|
||||
outputPath: latestOutputPath,
|
||||
elapsedMillis: elapsedMillis()
|
||||
)
|
||||
)
|
||||
finishStopRecording(stopResult: stopResult)
|
||||
return
|
||||
}
|
||||
|
||||
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
|
||||
guard let self else { return }
|
||||
self.latestGallerySaved = success
|
||||
self.latestGalleryErrorMessage = message
|
||||
if success {
|
||||
self.updateStatus(
|
||||
RecordingStatus(
|
||||
state: .previewing,
|
||||
outputPath: self.latestOutputPath,
|
||||
elapsedMillis: self.elapsedMillis()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
self.updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error,
|
||||
outputPath: self.latestOutputPath,
|
||||
message: message ?? "保存到相册失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
self.finishStopRecording(stopResult: stopResult)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStopRecording(stopResult: FlutterResult?) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stopElapsedTimer()
|
||||
stopResult?([
|
||||
"outputPath": self.latestOutputPath,
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
])
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
]
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
|
||||
}
|
||||
stopResult?(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveVideoToPhotoLibrary(
|
||||
fileURL: URL,
|
||||
completion: @escaping (Bool, String?) -> Void
|
||||
) {
|
||||
let performSave = {
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
|
||||
}) { success, error in
|
||||
if success {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
completion(true, nil)
|
||||
} else {
|
||||
completion(false, error?.localizedDescription ?? "保存到相册失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 14, *) {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
|
||||
if newStatus == .authorized || newStatus == .limited {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
} else {
|
||||
let status = PHPhotoLibrary.authorizationStatus()
|
||||
switch status {
|
||||
case .authorized:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization { newStatus in
|
||||
if newStatus == .authorized {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +463,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
}
|
||||
|
||||
private func createOutputURL() throws -> URL {
|
||||
private func createOutputURL(displayName: String?) throws -> URL {
|
||||
let baseURL = try FileManager.default.url(
|
||||
for: .documentDirectory,
|
||||
in: .userDomainMask,
|
||||
@@ -380,11 +473,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
||||
|
||||
let fileName = Self.resolveFileName(displayName: displayName)
|
||||
return recordingsURL.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
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"
|
||||
let filename = "REC_\(formatter.string(from: Date())).mov"
|
||||
return recordingsURL.appendingPathComponent(filename)
|
||||
return "REC_\(formatter.string(from: Date())).mov"
|
||||
}
|
||||
|
||||
private func updateStatus(_ next: RecordingStatus) {
|
||||
@@ -450,7 +555,8 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
case "startRecording":
|
||||
let args = call.arguments as? [String: Any]
|
||||
let withAudio = args?["withAudio"] as? Bool ?? true
|
||||
controller.startRecording(withAudio: withAudio, result: result)
|
||||
let displayName = args?["displayName"] as? String
|
||||
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
|
||||
case "stopRecording":
|
||||
controller.stopRecording(result: result)
|
||||
case "disposePreview":
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/// 剪切板内容数据模型
|
||||
/// 小程序复制到剪切板的录制信息。
|
||||
class ClipboardRecordingModel {
|
||||
final String title;
|
||||
final int startTimestamp;
|
||||
final int endTimestamp;
|
||||
final String address;
|
||||
|
||||
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
|
||||
final String? filename;
|
||||
|
||||
ClipboardRecordingModel({
|
||||
required this.title,
|
||||
required this.startTimestamp,
|
||||
required this.endTimestamp,
|
||||
required this.address,
|
||||
this.filename,
|
||||
});
|
||||
|
||||
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
|
||||
@@ -18,6 +22,7 @@ class ClipboardRecordingModel {
|
||||
startTimestamp: _readInt(json, 'startTimestamp'),
|
||||
endTimestamp: _readInt(json, 'endTimestamp'),
|
||||
address: _readString(json, 'address'),
|
||||
filename: _readOptionalString(json, 'filename'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,9 +32,20 @@ class ClipboardRecordingModel {
|
||||
'startTimestamp': startTimestamp,
|
||||
'endTimestamp': endTimestamp,
|
||||
'address': address,
|
||||
if (filename != null) 'filename': filename,
|
||||
};
|
||||
}
|
||||
|
||||
static String? _readOptionalString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value == null) return null;
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
if (value is! String) {
|
||||
throw FormatException('Clipboard field "$key" must be a String.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _readString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String) return value;
|
||||
|
||||
@@ -4,7 +4,13 @@ class RecordingModel {
|
||||
/// 剪切板内容
|
||||
final ClipboardRecordingModel clipboardRecordingModel;
|
||||
|
||||
RecordingModel({required this.clipboardRecordingModel});
|
||||
/// 剪切板是否包含有效的小程序录制信息
|
||||
final bool hasValidClipboardInfo;
|
||||
|
||||
RecordingModel({
|
||||
required this.clipboardRecordingModel,
|
||||
this.hasValidClipboardInfo = false,
|
||||
});
|
||||
|
||||
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
||||
return RecordingModel(
|
||||
@@ -17,10 +23,15 @@ class RecordingModel {
|
||||
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
|
||||
}
|
||||
|
||||
RecordingModel copyWith({ClipboardRecordingModel? clipboardRecordingModel}) {
|
||||
RecordingModel copyWith({
|
||||
ClipboardRecordingModel? clipboardRecordingModel,
|
||||
bool? hasValidClipboardInfo,
|
||||
}) {
|
||||
return RecordingModel(
|
||||
clipboardRecordingModel:
|
||||
clipboardRecordingModel ?? this.clipboardRecordingModel,
|
||||
hasValidClipboardInfo:
|
||||
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
53
lib/features/recording/recording_display_name.dart
Normal file
53
lib/features/recording/recording_display_name.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:io';
|
||||
|
||||
/// 非法文件名字符(路径分隔符等)。
|
||||
final _invalidNameChars = RegExp(r'[/\\:*?"<>|]');
|
||||
|
||||
const _maxBaseNameLength = 120;
|
||||
|
||||
/// 清洗小程序复制的文件名基底(不含扩展名)。
|
||||
String? sanitizeRecordingBaseName(String raw) {
|
||||
var name = raw.replaceAll(_invalidNameChars, '_').trim();
|
||||
if (name.isEmpty) return null;
|
||||
if (name.length > _maxBaseNameLength) {
|
||||
name = name.substring(0, _maxBaseNameLength);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/// 解析录制展示名:优先剪切板 filename,否则 REC_时间戳。
|
||||
String resolveRecordingDisplayName(String? clipboardFilename) {
|
||||
final sanitized = clipboardFilename == null
|
||||
? null
|
||||
: sanitizeRecordingBaseName(clipboardFilename);
|
||||
if (sanitized != null) return sanitized;
|
||||
final now = DateTime.now();
|
||||
final stamp =
|
||||
'${now.year}'
|
||||
'${now.month.toString().padLeft(2, '0')}'
|
||||
'${now.day.toString().padLeft(2, '0')}_'
|
||||
'${now.hour.toString().padLeft(2, '0')}'
|
||||
'${now.minute.toString().padLeft(2, '0')}'
|
||||
'${now.second.toString().padLeft(2, '0')}';
|
||||
return 'REC_$stamp';
|
||||
}
|
||||
|
||||
/// 为展示名补全视频扩展名(已有 .mp4/.mov 则保留)。
|
||||
String withVideoExtension(String baseName, {bool? isIOS}) {
|
||||
final ios = isIOS ?? Platform.isIOS;
|
||||
final ext = ios ? '.mov' : '.mp4';
|
||||
final lower = baseName.toLowerCase();
|
||||
if (lower.endsWith('.mp4') || lower.endsWith('.mov')) {
|
||||
return baseName;
|
||||
}
|
||||
return '$baseName$ext';
|
||||
}
|
||||
|
||||
/// 传给原生的完整文件名(含扩展名)。
|
||||
String recordingFileNameForPlatform(
|
||||
String? clipboardFilename, {
|
||||
bool? isIOS,
|
||||
}) {
|
||||
final base = resolveRecordingDisplayName(clipboardFilename);
|
||||
return withVideoExtension(base, isIOS: isIOS);
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/features/recording/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/recording_session_controller.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class RecordingPage extends ConsumerStatefulWidget {
|
||||
const RecordingPage({super.key});
|
||||
@@ -28,7 +28,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
await ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
final clipboardResult = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
if (!mounted) return;
|
||||
if (clipboardResult == ClipboardReadResult.invalid) {
|
||||
AppToast.show('无选手信息');
|
||||
}
|
||||
await _enterRecordingMode();
|
||||
// Allow PlatformView to attach before binding CameraX preview.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
@@ -71,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(recordingSessionControllerProvider);
|
||||
final recordingInfo = ref.watch(recordingViewModelProvider);
|
||||
final controller = ref.read(recordingSessionControllerProvider.notifier);
|
||||
final clipboard = recordingInfo.clipboardRecordingModel;
|
||||
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
|
||||
|
||||
return PopScope(
|
||||
canPop: !state.isRecording,
|
||||
@@ -97,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
),
|
||||
_RecordingHud(
|
||||
state: state,
|
||||
eventTitle: showClipboardInfo ? clipboard.title : null,
|
||||
eventAddress: showClipboardInfo ? clipboard.address : null,
|
||||
onStart: () => controller.startRecording(),
|
||||
onStop: () => controller.stopRecording(),
|
||||
onStop: () async {
|
||||
await controller.stopRecording();
|
||||
if (!context.mounted) return;
|
||||
final latest = ref.read(recordingSessionControllerProvider);
|
||||
if (latest.gallerySaveFailed) {
|
||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
||||
}
|
||||
},
|
||||
onOpenDnd: () async {
|
||||
await controller.openDndSettings();
|
||||
await controller.refreshDndAccess();
|
||||
@@ -121,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
class _RecordingHud extends StatelessWidget {
|
||||
const _RecordingHud({
|
||||
required this.state,
|
||||
this.eventTitle,
|
||||
this.eventAddress,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onOpenDnd,
|
||||
@@ -129,117 +149,166 @@ class _RecordingHud extends StatelessWidget {
|
||||
});
|
||||
|
||||
final RecordingSessionState state;
|
||||
final String? eventTitle;
|
||||
final String? eventAddress;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onOpenDnd;
|
||||
final VoidCallback onOpenBattery;
|
||||
final VoidCallback onToggleTouchLock;
|
||||
|
||||
static const _overlayTextStyle = TextStyle(
|
||||
color: Colors.white,
|
||||
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
if (state.isRecording)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: eventTitle != null || state.isRecording ? 56 : 8,
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
state.errorMessage!,
|
||||
style: const TextStyle(color: Colors.amber),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (state.permissionWarning != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
state.permissionWarning!,
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'REC ${state.elapsedLabel}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
_SetupHints(
|
||||
hasDndAccess: state.hasDndAccess,
|
||||
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
||||
notificationsGranted: state.notificationsGranted,
|
||||
onOpenDnd: onOpenDnd,
|
||||
onOpenBattery: onOpenBattery,
|
||||
onOpenNotificationSettings: openAppSettings,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (state.isRecording)
|
||||
IconButton(
|
||||
onPressed: onToggleTouchLock,
|
||||
icon: Icon(
|
||||
state.isTouchLocked ? Icons.lock : Icons.lock_open,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: state.isRecording ? onStop : onStart,
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
color: state.isRecording ? Colors.white : Colors.red,
|
||||
),
|
||||
child: Icon(
|
||||
state.isRecording
|
||||
? Icons.stop
|
||||
: Icons.fiber_manual_record,
|
||||
color: state.isRecording ? Colors.red : Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
state.errorMessage!,
|
||||
style: const TextStyle(color: Colors.amber),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (state.permissionWarning != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
state.permissionWarning!,
|
||||
style: const TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 12,
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
_SetupHints(
|
||||
hasDndAccess: state.hasDndAccess,
|
||||
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
||||
notificationsGranted: state.notificationsGranted,
|
||||
onOpenDnd: onOpenDnd,
|
||||
onOpenBattery: onOpenBattery,
|
||||
onOpenNotificationSettings: openAppSettings,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (state.isRecording)
|
||||
IconButton(
|
||||
onPressed: onToggleTouchLock,
|
||||
icon: Icon(
|
||||
state.isTouchLocked ? Icons.lock : Icons.lock_open,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: state.isRecording ? onStop : onStart,
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
color: state.isRecording ? Colors.white : Colors.red,
|
||||
),
|
||||
child: Icon(
|
||||
state.isRecording
|
||||
? Icons.stop
|
||||
: Icons.fiber_manual_record,
|
||||
color: state.isRecording ? Colors.red : Colors.white,
|
||||
size: 36,
|
||||
),
|
||||
if (state.lastSavedDisplayName != null &&
|
||||
!state.isRecording &&
|
||||
!state.gallerySaveFailed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'已保存到相册:${state.lastSavedDisplayName}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (state.lastOutputPath != null && !state.isRecording)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
if (eventTitle != null)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: state.isRecording ? 96 : 0),
|
||||
child: Text(
|
||||
eventTitle!,
|
||||
style: _overlayTextStyle.copyWith(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.isRecording)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'REC ${state.elapsedLabel}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (eventAddress != null && eventAddress!.isNotEmpty)
|
||||
Positioned(
|
||||
left: 16,
|
||||
bottom: 108,
|
||||
right: 120,
|
||||
child: Text(
|
||||
'已保存:${state.lastOutputPath}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
eventAddress!,
|
||||
style: _overlayTextStyle.copyWith(
|
||||
fontSize: 13,
|
||||
color: Colors.white70,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -84,12 +84,14 @@ class RecordingPlatform {
|
||||
static Future<RecordingStartResult> startRecording({
|
||||
bool withAudio = true,
|
||||
bool enableDoNotDisturb = true,
|
||||
String? displayName,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'startRecording',
|
||||
<String, dynamic>{
|
||||
'withAudio': withAudio,
|
||||
'enableDoNotDisturb': enableDoNotDisturb,
|
||||
if (displayName != null) 'displayName': displayName,
|
||||
},
|
||||
);
|
||||
return RecordingStartResult(
|
||||
@@ -104,12 +106,7 @@ class RecordingPlatform {
|
||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||
'stopRecording',
|
||||
);
|
||||
return RecordingStopResult(
|
||||
outputPath: result?['outputPath'] as String?,
|
||||
status: RecordingStatus.fromMap(
|
||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
||||
),
|
||||
);
|
||||
return RecordingStopResult.fromMap(result);
|
||||
}
|
||||
|
||||
static Future<void> disposePreview() =>
|
||||
@@ -163,8 +160,26 @@ class RecordingStartResult {
|
||||
}
|
||||
|
||||
class RecordingStopResult {
|
||||
const RecordingStopResult({this.outputPath, required this.status});
|
||||
const RecordingStopResult({
|
||||
this.outputPath,
|
||||
required this.status,
|
||||
this.gallerySaved = true,
|
||||
this.galleryErrorMessage,
|
||||
});
|
||||
|
||||
final String? outputPath;
|
||||
final RecordingStatus status;
|
||||
final bool gallerySaved;
|
||||
final String? galleryErrorMessage;
|
||||
|
||||
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
|
||||
return RecordingStopResult(
|
||||
outputPath: result?['outputPath'] as String?,
|
||||
status: RecordingStatus.fromMap(
|
||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
||||
),
|
||||
gallerySaved: result?['gallerySaved'] as bool? ?? true,
|
||||
galleryErrorMessage: result?['galleryErrorMessage'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||
import 'package:recording_tool/features/recording/recording_display_name.dart';
|
||||
import 'package:recording_tool/features/recording/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class RecordingSessionState {
|
||||
@@ -17,8 +19,10 @@ class RecordingSessionState {
|
||||
this.notificationsGranted = true,
|
||||
this.isMicrophoneGranted = false,
|
||||
this.lastOutputPath,
|
||||
this.lastSavedDisplayName,
|
||||
this.errorMessage,
|
||||
this.permissionWarning,
|
||||
this.gallerySaveFailed = false,
|
||||
});
|
||||
|
||||
final RecordingStatus status;
|
||||
@@ -29,8 +33,10 @@ class RecordingSessionState {
|
||||
final bool notificationsGranted;
|
||||
final bool isMicrophoneGranted;
|
||||
final String? lastOutputPath;
|
||||
final String? lastSavedDisplayName;
|
||||
final String? errorMessage;
|
||||
final String? permissionWarning;
|
||||
final bool gallerySaveFailed;
|
||||
|
||||
bool get isRecording => status.isRecording;
|
||||
|
||||
@@ -50,9 +56,12 @@ class RecordingSessionState {
|
||||
bool? notificationsGranted,
|
||||
bool? isMicrophoneGranted,
|
||||
String? lastOutputPath,
|
||||
String? lastSavedDisplayName,
|
||||
String? errorMessage,
|
||||
String? permissionWarning,
|
||||
bool? gallerySaveFailed,
|
||||
bool clearPermissionWarning = false,
|
||||
bool clearLastSaved = false,
|
||||
}) {
|
||||
return RecordingSessionState(
|
||||
status: status ?? this.status,
|
||||
@@ -64,10 +73,14 @@ class RecordingSessionState {
|
||||
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
||||
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
||||
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
||||
lastSavedDisplayName: clearLastSaved
|
||||
? null
|
||||
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
|
||||
errorMessage: errorMessage,
|
||||
permissionWarning: clearPermissionWarning
|
||||
? null
|
||||
: (permissionWarning ?? this.permissionWarning),
|
||||
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +101,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
|
||||
Future<void> prepareSession() async {
|
||||
if (!RecordingPlatform.isSupported) {
|
||||
state = state.copyWith(errorMessage: '仅支持 Android 录制');
|
||||
state = state.copyWith(errorMessage: '当前设备不支持录制');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,6 +109,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
..._galleryPermissions(),
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
@@ -117,6 +131,9 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
if (!microphoneGranted) {
|
||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||
}
|
||||
if (!_isGalleryPermissionGranted(permissions)) {
|
||||
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
||||
}
|
||||
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
final batteryIgnored =
|
||||
@@ -167,18 +184,43 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
throw StateError('initializePreview retry exhausted');
|
||||
}
|
||||
|
||||
List<Permission> _galleryPermissions() {
|
||||
if (Platform.isIOS) {
|
||||
return [Permission.photosAddOnly, Permission.photos];
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return [Permission.videos, Permission.storage];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool _isGalleryPermissionGranted(Map<Permission, PermissionStatus> permissions) {
|
||||
for (final permission in _galleryPermissions()) {
|
||||
if (permissions[permission]?.isGranted ?? false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return _galleryPermissions().isEmpty;
|
||||
}
|
||||
|
||||
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
||||
if (!state.isPreviewReady || state.isRecording) return;
|
||||
|
||||
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
|
||||
final displayName = recordingFileNameForPlatform(clipboard.filename);
|
||||
|
||||
try {
|
||||
final result = await RecordingPlatform.startRecording(
|
||||
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
|
||||
displayName: displayName,
|
||||
);
|
||||
state = state.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath,
|
||||
isTouchLocked: true,
|
||||
errorMessage: null,
|
||||
gallerySaveFailed: false,
|
||||
clearLastSaved: true,
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
|
||||
@@ -190,10 +232,18 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
||||
|
||||
try {
|
||||
final result = await RecordingPlatform.stopRecording();
|
||||
final galleryFailed = !result.gallerySaved;
|
||||
final savedName = recordingFileNameForPlatform(
|
||||
ref.read(recordingViewModelProvider).clipboardRecordingModel.filename,
|
||||
);
|
||||
state = state.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath ?? state.lastOutputPath,
|
||||
errorMessage: null,
|
||||
lastSavedDisplayName: galleryFailed ? null : savedName,
|
||||
errorMessage: galleryFailed
|
||||
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
||||
: null,
|
||||
gallerySaveFailed: galleryFailed,
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');
|
||||
|
||||
@@ -12,6 +12,18 @@ final recordingViewModelProvider =
|
||||
return RecordingViewModel(ref);
|
||||
});
|
||||
|
||||
/// 剪切板读取结果,供 UI 决定是否提示用户。
|
||||
enum ClipboardReadResult {
|
||||
/// 剪切板为空,不提示
|
||||
empty,
|
||||
|
||||
/// 解析成功
|
||||
success,
|
||||
|
||||
/// 有内容但格式不符合小程序录制信息
|
||||
invalid,
|
||||
}
|
||||
|
||||
class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||
RecordingViewModel(this.ref)
|
||||
: super(
|
||||
@@ -26,8 +38,15 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||
);
|
||||
final Ref ref;
|
||||
|
||||
/// 从剪切板获取内容
|
||||
Future<void> getClipboardContent() async {
|
||||
static final _defaultClipboard = ClipboardRecordingModel(
|
||||
title: '',
|
||||
startTimestamp: 0,
|
||||
endTimestamp: 0,
|
||||
address: '',
|
||||
);
|
||||
|
||||
/// 从剪切板获取小程序复制的录制信息。
|
||||
Future<ClipboardReadResult> getClipboardContent() async {
|
||||
try {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text;
|
||||
@@ -35,22 +54,45 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
AppLogger.info('剪切板内容为空,跳过录制信息解析');
|
||||
return;
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.empty;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(text);
|
||||
final decoded = jsonDecode(text.trim());
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
|
||||
return;
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
}
|
||||
|
||||
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
|
||||
state = state.copyWith(clipboardRecordingModel: clipboardRecordingModel);
|
||||
if (clipboardRecordingModel.title.trim().isEmpty) {
|
||||
AppLogger.warning('剪切板录制信息缺少有效 title');
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
clipboardRecordingModel: clipboardRecordingModel,
|
||||
hasValidClipboardInfo: true,
|
||||
);
|
||||
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
|
||||
return ClipboardReadResult.success;
|
||||
} on FormatException catch (error) {
|
||||
AppLogger.warning('剪切板录制信息格式错误:$error');
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
} catch (error, stackTrace) {
|
||||
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace);
|
||||
_resetClipboardInfo();
|
||||
return ClipboardReadResult.invalid;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetClipboardInfo() {
|
||||
state = state.copyWith(
|
||||
clipboardRecordingModel: _defaultClipboard,
|
||||
hasValidClipboardInfo: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
56
test.html
56
test.html
@@ -1,56 +0,0 @@
|
||||
<!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>
|
||||
@@ -71,7 +71,7 @@ void main() {
|
||||
});
|
||||
|
||||
group('iOS permission configuration', () {
|
||||
test('Podfile enables camera and microphone permission macros', () {
|
||||
test('Podfile enables camera, microphone and photos permission macros', () {
|
||||
final podfile = File('ios/Podfile').readAsStringSync();
|
||||
|
||||
expect(
|
||||
@@ -80,6 +80,8 @@ void main() {
|
||||
);
|
||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
||||
expect(podfile, contains("'PERMISSION_PHOTOS=1'"));
|
||||
expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,9 +17,21 @@ void main() {
|
||||
expect(model.startTimestamp, 1717334400);
|
||||
expect(model.endTimestamp, 1717334400);
|
||||
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||
expect(model.filename, isNull);
|
||||
expect(model.toJson(), clipboardJson);
|
||||
});
|
||||
|
||||
test('parses optional filename from mini program JSON', () {
|
||||
final json = {
|
||||
...clipboardJson,
|
||||
'filename': '选手名称_选手ID_赛事名称_赛项',
|
||||
};
|
||||
final model = ClipboardRecordingModel.fromJson(json);
|
||||
|
||||
expect(model.filename, '选手名称_选手ID_赛事名称_赛项');
|
||||
expect(model.toJson(), json);
|
||||
});
|
||||
|
||||
test('throws FormatException when required field is missing', () {
|
||||
final json = Map<String, dynamic>.from(clipboardJson)..remove('title');
|
||||
|
||||
|
||||
69
test/features/recording/recording_display_name_test.dart
Normal file
69
test/features/recording/recording_display_name_test.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/recording_display_name.dart';
|
||||
|
||||
void main() {
|
||||
group('sanitizeRecordingBaseName', () {
|
||||
test('removes invalid path characters', () {
|
||||
expect(
|
||||
sanitizeRecordingBaseName(r'a/b:c*d?e"f<g>h|i'),
|
||||
'a_b_c_d_e_f_g_h_i',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null for blank input', () {
|
||||
expect(sanitizeRecordingBaseName(' '), isNull);
|
||||
});
|
||||
|
||||
test('truncates overly long names', () {
|
||||
final long = 'a' * 200;
|
||||
expect(sanitizeRecordingBaseName(long)!.length, 120);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveRecordingDisplayName', () {
|
||||
test('uses sanitized clipboard filename when present', () {
|
||||
expect(
|
||||
resolveRecordingDisplayName('选手名称_选手ID_赛事名称_赛项'),
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to REC_ prefix when clipboard filename is empty', () {
|
||||
expect(resolveRecordingDisplayName(null), startsWith('REC_'));
|
||||
expect(resolveRecordingDisplayName(''), startsWith('REC_'));
|
||||
});
|
||||
});
|
||||
|
||||
group('withVideoExtension', () {
|
||||
test('appends mp4 on Android', () {
|
||||
expect(
|
||||
withVideoExtension('match', isIOS: false),
|
||||
'match.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
test('appends mov on iOS', () {
|
||||
expect(
|
||||
withVideoExtension('match', isIOS: true),
|
||||
'match.mov',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps existing extension', () {
|
||||
expect(withVideoExtension('a.mp4', isIOS: false), 'a.mp4');
|
||||
expect(withVideoExtension('a.MOV', isIOS: true), 'a.MOV');
|
||||
});
|
||||
});
|
||||
|
||||
group('recordingFileNameForPlatform', () {
|
||||
test('combines clipboard name with platform extension', () {
|
||||
expect(
|
||||
recordingFileNameForPlatform(
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
isIOS: false,
|
||||
),
|
||||
'选手名称_选手ID_赛事名称_赛项.mp4',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ void main() {
|
||||
|
||||
const defaultClipboardTitle = '';
|
||||
const validClipboardText =
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
||||
|
||||
Future<void> setClipboardText(String? text) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
@@ -33,116 +33,128 @@ void main() {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
final clipboardModel = container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel;
|
||||
expect(clipboardModel.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(clipboardModel.startTimestamp, 1717334400);
|
||||
expect(clipboardModel.endTimestamp, 1717334400);
|
||||
expect(clipboardModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||
expect(result, ClipboardReadResult.success);
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isTrue);
|
||||
expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(model.clipboardRecordingModel.startTimestamp, 1717334400);
|
||||
expect(model.clipboardRecordingModel.endTimestamp, 1717334400);
|
||||
expect(
|
||||
model.clipboardRecordingModel.address,
|
||||
'广州市番禺区·粤港澳大湾区青年人才双创小镇',
|
||||
);
|
||||
expect(
|
||||
model.clipboardRecordingModel.filename,
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps default state when clipboard is empty', () async {
|
||||
test('returns empty when clipboard is empty', () async {
|
||||
await setClipboardText('');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
expect(result, ClipboardReadResult.empty);
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isFalse);
|
||||
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
|
||||
});
|
||||
|
||||
test('keeps default state when clipboard is not JSON', () async {
|
||||
test('returns invalid when clipboard is not JSON', () async {
|
||||
await setClipboardText('hello');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).hasValidClipboardInfo,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps default state when clipboard JSON is not an object', () async {
|
||||
test('returns invalid when clipboard JSON is not an object', () async {
|
||||
await setClipboardText('[1,2,3]');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps default state when clipboard JSON misses required fields',
|
||||
() async {
|
||||
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
test('returns invalid when clipboard JSON misses required fields', () async {
|
||||
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps default state when clipboard JSON has wrong field type',
|
||||
() async {
|
||||
await setClipboardText(
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
test('returns invalid when clipboard JSON has wrong field type', () async {
|
||||
await setClipboardText(
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns invalid when title is blank', () async {
|
||||
await setClipboardText(
|
||||
'{"title":" ","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).hasValidClipboardInfo,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user