升级 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.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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.ACCESS_NOTIFICATION_POLICY" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<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
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.util.Log
|
|||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.Preview
|
import androidx.camera.core.Preview
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.camera.video.FileOutputOptions
|
|
||||||
import androidx.camera.video.Quality
|
import androidx.camera.video.Quality
|
||||||
import androidx.camera.video.QualitySelector
|
import androidx.camera.video.QualitySelector
|
||||||
import androidx.camera.video.Recorder
|
import androidx.camera.video.Recorder
|
||||||
@@ -15,10 +14,6 @@ import androidx.camera.video.VideoRecordEvent
|
|||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LifecycleOwner
|
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
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
class RecordingCameraController(
|
class RecordingCameraController(
|
||||||
@@ -39,6 +34,7 @@ class RecordingCameraController(
|
|||||||
|
|
||||||
private var recordingStartedAt: Long = 0L
|
private var recordingStartedAt: Long = 0L
|
||||||
private var latestOutputPath: String? = null
|
private var latestOutputPath: String? = null
|
||||||
|
private var pendingStopCallback: ((String?) -> Unit)? = null
|
||||||
|
|
||||||
fun bindPreview(
|
fun bindPreview(
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
@@ -118,6 +114,7 @@ class RecordingCameraController(
|
|||||||
|
|
||||||
fun startRecording(
|
fun startRecording(
|
||||||
withAudio: Boolean,
|
withAudio: Boolean,
|
||||||
|
displayName: String?,
|
||||||
onStarted: (Boolean, String?) -> Unit,
|
onStarted: (Boolean, String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val capture = videoCapture
|
val capture = videoCapture
|
||||||
@@ -131,9 +128,12 @@ class RecordingCameraController(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val outputFile = createOutputFile()
|
val outputOptions =
|
||||||
latestOutputPath = outputFile.absolutePath
|
RecordingOutputFactory.buildMediaStoreOutputOptions(
|
||||||
val outputOptions = FileOutputOptions.Builder(outputFile).build()
|
appContext,
|
||||||
|
displayName,
|
||||||
|
)
|
||||||
|
latestOutputPath = null
|
||||||
|
|
||||||
val pending =
|
val pending =
|
||||||
capture.output.prepareRecording(appContext, outputOptions).apply {
|
capture.output.prepareRecording(appContext, outputOptions).apply {
|
||||||
@@ -171,6 +171,7 @@ class RecordingCameraController(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
latestOutputPath = event.outputResults.outputUri.toString()
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.PREVIEWING,
|
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) {
|
fun stopRecording(onStopped: (String?) -> Unit) {
|
||||||
@@ -193,6 +197,7 @@ class RecordingCameraController(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingStopCallback = onStopped
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
RecordingState.STOPPING,
|
RecordingState.STOPPING,
|
||||||
@@ -202,7 +207,6 @@ class RecordingCameraController(
|
|||||||
|
|
||||||
recording.stop()
|
recording.stop()
|
||||||
activeRecording = null
|
activeRecording = null
|
||||||
onStopped(latestOutputPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unbind() {
|
fun unbind() {
|
||||||
@@ -221,16 +225,6 @@ class RecordingCameraController(
|
|||||||
return System.currentTimeMillis() - recordingStartedAt
|
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) {
|
private fun updateStatus(next: RecordingStatus) {
|
||||||
status = next
|
status = next
|
||||||
statusListener?.invoke(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" -> {
|
"startRecording" -> {
|
||||||
val withAudio = call.argument<Boolean>("withAudio") ?: true
|
val withAudio = call.argument<Boolean>("withAudio") ?: true
|
||||||
val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: 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)
|
"stopRecording" -> stopRecording(result)
|
||||||
"disposePreview" -> {
|
"disposePreview" -> {
|
||||||
@@ -110,6 +111,7 @@ class RecordingPlatformHandler(
|
|||||||
private fun startRecording(
|
private fun startRecording(
|
||||||
withAudio: Boolean,
|
withAudio: Boolean,
|
||||||
enableDnd: Boolean,
|
enableDnd: Boolean,
|
||||||
|
displayName: String?,
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
val previewView = activity.recordingPreviewView
|
val previewView = activity.recordingPreviewView
|
||||||
@@ -125,7 +127,7 @@ class RecordingPlatformHandler(
|
|||||||
DoNotDisturbHelper.enable(activity)
|
DoNotDisturbHelper.enable(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.startRecording(withAudio) { started, message ->
|
controller.startRecording(withAudio, displayName) { started, message ->
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
if (started) {
|
if (started) {
|
||||||
startElapsedTicker()
|
startElapsedTicker()
|
||||||
@@ -170,12 +172,19 @@ class RecordingPlatformHandler(
|
|||||||
RecordingSession.stopForeground(activity)
|
RecordingSession.stopForeground(activity)
|
||||||
DoNotDisturbHelper.disable(activity)
|
DoNotDisturbHelper.disable(activity)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
result.success(
|
val gallerySaved =
|
||||||
mapOf(
|
path != null &&
|
||||||
"outputPath" to path,
|
controller.status.state != RecordingState.ERROR
|
||||||
"status" to controller.status.toMap(),
|
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.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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 {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.9.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ post_install do |installer|
|
|||||||
'$(inherited)',
|
'$(inherited)',
|
||||||
'PERMISSION_CAMERA=1',
|
'PERMISSION_CAMERA=1',
|
||||||
'PERMISSION_MICROPHONE=1',
|
'PERMISSION_MICROPHONE=1',
|
||||||
|
'PERMISSION_PHOTOS=1',
|
||||||
|
'PERMISSION_PHOTOS_ADD_ONLY=1',
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
<string>需要访问相机以显示预览并录制视频。</string>
|
<string>需要访问相机以显示预览并录制视频。</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>需要将录制的视频保存到相册。</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import Photos
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private enum RecordingState: String {
|
private enum RecordingState: String {
|
||||||
@@ -109,6 +110,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
private var audioInput: AVCaptureDeviceInput?
|
private var audioInput: AVCaptureDeviceInput?
|
||||||
private var configured = false
|
private var configured = false
|
||||||
private var latestOutputPath: String?
|
private var latestOutputPath: String?
|
||||||
|
private var latestGallerySaved = true
|
||||||
|
private var latestGalleryErrorMessage: String?
|
||||||
|
private var pendingDisplayName: String?
|
||||||
private var recordingStartedAt: Date?
|
private var recordingStartedAt: Date?
|
||||||
private var elapsedTimer: Timer?
|
private var elapsedTimer: Timer?
|
||||||
private var pendingStopResult: FlutterResult?
|
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 {
|
guard previewView != nil else {
|
||||||
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
|
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
|
||||||
return
|
return
|
||||||
@@ -192,8 +196,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try self.createOutputURL()
|
self.pendingDisplayName = displayName
|
||||||
self.latestOutputPath = outputURL.path
|
self.latestGallerySaved = true
|
||||||
|
self.latestGalleryErrorMessage = nil
|
||||||
|
let outputURL = try self.createOutputURL(displayName: displayName)
|
||||||
|
self.latestOutputPath = outputURL.lastPathComponent
|
||||||
self.recordingStartedAt = Date()
|
self.recordingStartedAt = Date()
|
||||||
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
||||||
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||||
@@ -226,10 +233,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
|
|
||||||
guard self.movieOutput.isRecording else {
|
guard self.movieOutput.isRecording else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
result([
|
var payload: [String: Any] = [
|
||||||
"outputPath": self.latestOutputPath,
|
"outputPath": self.latestOutputPath as Any,
|
||||||
"status": self.currentStatusMap(),
|
"status": self.currentStatusMap(),
|
||||||
])
|
"gallerySaved": self.latestGallerySaved,
|
||||||
|
]
|
||||||
|
if !self.latestGallerySaved {
|
||||||
|
payload["galleryErrorMessage"] =
|
||||||
|
self.latestGalleryErrorMessage ?? "保存到相册失败"
|
||||||
|
}
|
||||||
|
result(payload)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -291,24 +304,104 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
pendingStopResult = nil
|
pendingStopResult = nil
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
|
latestGallerySaved = false
|
||||||
|
latestGalleryErrorMessage = error.localizedDescription
|
||||||
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||||
} else {
|
finishStopRecording(stopResult: stopResult)
|
||||||
updateStatus(
|
return
|
||||||
RecordingStatus(
|
|
||||||
state: .previewing,
|
|
||||||
outputPath: latestOutputPath,
|
|
||||||
elapsedMillis: elapsedMillis()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.stopElapsedTimer()
|
self.stopElapsedTimer()
|
||||||
stopResult?([
|
var payload: [String: Any] = [
|
||||||
"outputPath": self.latestOutputPath,
|
"outputPath": self.latestOutputPath as Any,
|
||||||
"status": self.currentStatusMap(),
|
"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
|
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createOutputURL() throws -> URL {
|
private func createOutputURL(displayName: String?) throws -> URL {
|
||||||
let baseURL = try FileManager.default.url(
|
let baseURL = try FileManager.default.url(
|
||||||
for: .documentDirectory,
|
for: .documentDirectory,
|
||||||
in: .userDomainMask,
|
in: .userDomainMask,
|
||||||
@@ -380,11 +473,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true)
|
let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: 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()
|
let formatter = DateFormatter()
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let filename = "REC_\(formatter.string(from: Date())).mov"
|
return "REC_\(formatter.string(from: Date())).mov"
|
||||||
return recordingsURL.appendingPathComponent(filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateStatus(_ next: RecordingStatus) {
|
private func updateStatus(_ next: RecordingStatus) {
|
||||||
@@ -450,7 +555,8 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
|||||||
case "startRecording":
|
case "startRecording":
|
||||||
let args = call.arguments as? [String: Any]
|
let args = call.arguments as? [String: Any]
|
||||||
let withAudio = args?["withAudio"] as? Bool ?? true
|
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":
|
case "stopRecording":
|
||||||
controller.stopRecording(result: result)
|
controller.stopRecording(result: result)
|
||||||
case "disposePreview":
|
case "disposePreview":
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
/// 剪切板内容数据模型
|
/// 小程序复制到剪切板的录制信息。
|
||||||
class ClipboardRecordingModel {
|
class ClipboardRecordingModel {
|
||||||
final String title;
|
final String title;
|
||||||
final int startTimestamp;
|
final int startTimestamp;
|
||||||
final int endTimestamp;
|
final int endTimestamp;
|
||||||
final String address;
|
final String address;
|
||||||
|
|
||||||
|
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
|
||||||
|
final String? filename;
|
||||||
|
|
||||||
ClipboardRecordingModel({
|
ClipboardRecordingModel({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.startTimestamp,
|
required this.startTimestamp,
|
||||||
required this.endTimestamp,
|
required this.endTimestamp,
|
||||||
required this.address,
|
required this.address,
|
||||||
|
this.filename,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
|
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -18,6 +22,7 @@ class ClipboardRecordingModel {
|
|||||||
startTimestamp: _readInt(json, 'startTimestamp'),
|
startTimestamp: _readInt(json, 'startTimestamp'),
|
||||||
endTimestamp: _readInt(json, 'endTimestamp'),
|
endTimestamp: _readInt(json, 'endTimestamp'),
|
||||||
address: _readString(json, 'address'),
|
address: _readString(json, 'address'),
|
||||||
|
filename: _readOptionalString(json, 'filename'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +32,20 @@ class ClipboardRecordingModel {
|
|||||||
'startTimestamp': startTimestamp,
|
'startTimestamp': startTimestamp,
|
||||||
'endTimestamp': endTimestamp,
|
'endTimestamp': endTimestamp,
|
||||||
'address': address,
|
'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) {
|
static String _readString(Map<String, dynamic> json, String key) {
|
||||||
final value = json[key];
|
final value = json[key];
|
||||||
if (value is String) return value;
|
if (value is String) return value;
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ class RecordingModel {
|
|||||||
/// 剪切板内容
|
/// 剪切板内容
|
||||||
final ClipboardRecordingModel clipboardRecordingModel;
|
final ClipboardRecordingModel clipboardRecordingModel;
|
||||||
|
|
||||||
RecordingModel({required this.clipboardRecordingModel});
|
/// 剪切板是否包含有效的小程序录制信息
|
||||||
|
final bool hasValidClipboardInfo;
|
||||||
|
|
||||||
|
RecordingModel({
|
||||||
|
required this.clipboardRecordingModel,
|
||||||
|
this.hasValidClipboardInfo = false,
|
||||||
|
});
|
||||||
|
|
||||||
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
||||||
return RecordingModel(
|
return RecordingModel(
|
||||||
@@ -17,10 +23,15 @@ class RecordingModel {
|
|||||||
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
|
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
|
||||||
}
|
}
|
||||||
|
|
||||||
RecordingModel copyWith({ClipboardRecordingModel? clipboardRecordingModel}) {
|
RecordingModel copyWith({
|
||||||
|
ClipboardRecordingModel? clipboardRecordingModel,
|
||||||
|
bool? hasValidClipboardInfo,
|
||||||
|
}) {
|
||||||
return RecordingModel(
|
return RecordingModel(
|
||||||
clipboardRecordingModel:
|
clipboardRecordingModel:
|
||||||
clipboardRecordingModel ?? this.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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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_platform.dart';
|
||||||
import 'package:recording_tool/features/recording/recording_session_controller.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/view-model/view_model_recording.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.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/features/recording/widgets/recording_touch_lock_overlay.dart';
|
||||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
|
|
||||||
class RecordingPage extends ConsumerStatefulWidget {
|
class RecordingPage extends ConsumerStatefulWidget {
|
||||||
const RecordingPage({super.key});
|
const RecordingPage({super.key});
|
||||||
@@ -28,7 +28,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _bootstrap() async {
|
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();
|
await _enterRecordingMode();
|
||||||
// Allow PlatformView to attach before binding CameraX preview.
|
// Allow PlatformView to attach before binding CameraX preview.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||||
@@ -71,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(recordingSessionControllerProvider);
|
final state = ref.watch(recordingSessionControllerProvider);
|
||||||
|
final recordingInfo = ref.watch(recordingViewModelProvider);
|
||||||
final controller = ref.read(recordingSessionControllerProvider.notifier);
|
final controller = ref.read(recordingSessionControllerProvider.notifier);
|
||||||
|
final clipboard = recordingInfo.clipboardRecordingModel;
|
||||||
|
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !state.isRecording,
|
canPop: !state.isRecording,
|
||||||
@@ -97,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
),
|
),
|
||||||
_RecordingHud(
|
_RecordingHud(
|
||||||
state: state,
|
state: state,
|
||||||
|
eventTitle: showClipboardInfo ? clipboard.title : null,
|
||||||
|
eventAddress: showClipboardInfo ? clipboard.address : null,
|
||||||
onStart: () => controller.startRecording(),
|
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 {
|
onOpenDnd: () async {
|
||||||
await controller.openDndSettings();
|
await controller.openDndSettings();
|
||||||
await controller.refreshDndAccess();
|
await controller.refreshDndAccess();
|
||||||
@@ -121,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
class _RecordingHud extends StatelessWidget {
|
class _RecordingHud extends StatelessWidget {
|
||||||
const _RecordingHud({
|
const _RecordingHud({
|
||||||
required this.state,
|
required this.state,
|
||||||
|
this.eventTitle,
|
||||||
|
this.eventAddress,
|
||||||
required this.onStart,
|
required this.onStart,
|
||||||
required this.onStop,
|
required this.onStop,
|
||||||
required this.onOpenDnd,
|
required this.onOpenDnd,
|
||||||
@@ -129,117 +149,166 @@ class _RecordingHud extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final RecordingSessionState state;
|
final RecordingSessionState state;
|
||||||
|
final String? eventTitle;
|
||||||
|
final String? eventAddress;
|
||||||
final VoidCallback onStart;
|
final VoidCallback onStart;
|
||||||
final VoidCallback onStop;
|
final VoidCallback onStop;
|
||||||
final VoidCallback onOpenDnd;
|
final VoidCallback onOpenDnd;
|
||||||
final VoidCallback onOpenBattery;
|
final VoidCallback onOpenBattery;
|
||||||
final VoidCallback onToggleTouchLock;
|
final VoidCallback onToggleTouchLock;
|
||||||
|
|
||||||
|
static const _overlayTextStyle = TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
children: [
|
||||||
child: Row(
|
SizedBox(
|
||||||
children: [
|
height: eventTitle != null || state.isRecording ? 56 : 8,
|
||||||
const Spacer(),
|
),
|
||||||
if (state.isRecording)
|
const Spacer(),
|
||||||
Container(
|
if (state.errorMessage != null)
|
||||||
padding: const EdgeInsets.symmetric(
|
Padding(
|
||||||
horizontal: 12,
|
padding: const EdgeInsets.all(12),
|
||||||
vertical: 6,
|
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(
|
textAlign: TextAlign.center,
|
||||||
color: Colors.red,
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
),
|
||||||
),
|
_SetupHints(
|
||||||
child: Text(
|
hasDndAccess: state.hasDndAccess,
|
||||||
'REC ${state.elapsedLabel}',
|
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
||||||
style: const TextStyle(
|
notificationsGranted: state.notificationsGranted,
|
||||||
color: Colors.white,
|
onOpenDnd: onOpenDnd,
|
||||||
fontWeight: FontWeight.bold,
|
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 SizedBox(width: 48),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
),
|
if (state.lastSavedDisplayName != null &&
|
||||||
_SetupHints(
|
!state.isRecording &&
|
||||||
hasDndAccess: state.hasDndAccess,
|
!state.gallerySaveFailed)
|
||||||
isBatteryIgnored: state.isBatteryOptimizedIgnored,
|
Padding(
|
||||||
notificationsGranted: state.notificationsGranted,
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
onOpenDnd: onOpenDnd,
|
child: Text(
|
||||||
onOpenBattery: onOpenBattery,
|
'已保存到相册:${state.lastSavedDisplayName}',
|
||||||
onOpenNotificationSettings: openAppSettings,
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
),
|
textAlign: TextAlign.center,
|
||||||
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 SizedBox(width: 48),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (state.lastOutputPath != null && !state.isRecording)
|
if (eventTitle != null)
|
||||||
Padding(
|
Positioned(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
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(
|
child: Text(
|
||||||
'已保存:${state.lastOutputPath}',
|
eventAddress!,
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
style: _overlayTextStyle.copyWith(
|
||||||
textAlign: TextAlign.center,
|
fontSize: 13,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -84,12 +84,14 @@ class RecordingPlatform {
|
|||||||
static Future<RecordingStartResult> startRecording({
|
static Future<RecordingStartResult> startRecording({
|
||||||
bool withAudio = true,
|
bool withAudio = true,
|
||||||
bool enableDoNotDisturb = true,
|
bool enableDoNotDisturb = true,
|
||||||
|
String? displayName,
|
||||||
}) async {
|
}) async {
|
||||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||||
'startRecording',
|
'startRecording',
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'withAudio': withAudio,
|
'withAudio': withAudio,
|
||||||
'enableDoNotDisturb': enableDoNotDisturb,
|
'enableDoNotDisturb': enableDoNotDisturb,
|
||||||
|
if (displayName != null) 'displayName': displayName,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return RecordingStartResult(
|
return RecordingStartResult(
|
||||||
@@ -104,12 +106,7 @@ class RecordingPlatform {
|
|||||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||||
'stopRecording',
|
'stopRecording',
|
||||||
);
|
);
|
||||||
return RecordingStopResult(
|
return RecordingStopResult.fromMap(result);
|
||||||
outputPath: result?['outputPath'] as String?,
|
|
||||||
status: RecordingStatus.fromMap(
|
|
||||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> disposePreview() =>
|
static Future<void> disposePreview() =>
|
||||||
@@ -163,8 +160,26 @@ class RecordingStartResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RecordingStopResult {
|
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 String? outputPath;
|
||||||
final RecordingStatus status;
|
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/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:recording_tool/core/permission/permission_service.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/recording_platform.dart';
|
||||||
|
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
class RecordingSessionState {
|
class RecordingSessionState {
|
||||||
@@ -17,8 +19,10 @@ class RecordingSessionState {
|
|||||||
this.notificationsGranted = true,
|
this.notificationsGranted = true,
|
||||||
this.isMicrophoneGranted = false,
|
this.isMicrophoneGranted = false,
|
||||||
this.lastOutputPath,
|
this.lastOutputPath,
|
||||||
|
this.lastSavedDisplayName,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.permissionWarning,
|
this.permissionWarning,
|
||||||
|
this.gallerySaveFailed = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordingStatus status;
|
final RecordingStatus status;
|
||||||
@@ -29,8 +33,10 @@ class RecordingSessionState {
|
|||||||
final bool notificationsGranted;
|
final bool notificationsGranted;
|
||||||
final bool isMicrophoneGranted;
|
final bool isMicrophoneGranted;
|
||||||
final String? lastOutputPath;
|
final String? lastOutputPath;
|
||||||
|
final String? lastSavedDisplayName;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? permissionWarning;
|
final String? permissionWarning;
|
||||||
|
final bool gallerySaveFailed;
|
||||||
|
|
||||||
bool get isRecording => status.isRecording;
|
bool get isRecording => status.isRecording;
|
||||||
|
|
||||||
@@ -50,9 +56,12 @@ class RecordingSessionState {
|
|||||||
bool? notificationsGranted,
|
bool? notificationsGranted,
|
||||||
bool? isMicrophoneGranted,
|
bool? isMicrophoneGranted,
|
||||||
String? lastOutputPath,
|
String? lastOutputPath,
|
||||||
|
String? lastSavedDisplayName,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? permissionWarning,
|
String? permissionWarning,
|
||||||
|
bool? gallerySaveFailed,
|
||||||
bool clearPermissionWarning = false,
|
bool clearPermissionWarning = false,
|
||||||
|
bool clearLastSaved = false,
|
||||||
}) {
|
}) {
|
||||||
return RecordingSessionState(
|
return RecordingSessionState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -64,10 +73,14 @@ class RecordingSessionState {
|
|||||||
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
||||||
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
||||||
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
||||||
|
lastSavedDisplayName: clearLastSaved
|
||||||
|
? null
|
||||||
|
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
permissionWarning: clearPermissionWarning
|
permissionWarning: clearPermissionWarning
|
||||||
? null
|
? null
|
||||||
: (permissionWarning ?? this.permissionWarning),
|
: (permissionWarning ?? this.permissionWarning),
|
||||||
|
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +101,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
|||||||
|
|
||||||
Future<void> prepareSession() async {
|
Future<void> prepareSession() async {
|
||||||
if (!RecordingPlatform.isSupported) {
|
if (!RecordingPlatform.isSupported) {
|
||||||
state = state.copyWith(errorMessage: '仅支持 Android 录制');
|
state = state.copyWith(errorMessage: '当前设备不支持录制');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +109,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
|||||||
Permission.camera,
|
Permission.camera,
|
||||||
Permission.microphone,
|
Permission.microphone,
|
||||||
if (Platform.isAndroid) Permission.notification,
|
if (Platform.isAndroid) Permission.notification,
|
||||||
|
..._galleryPermissions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||||
@@ -117,6 +131,9 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
|||||||
if (!microphoneGranted) {
|
if (!microphoneGranted) {
|
||||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||||
}
|
}
|
||||||
|
if (!_isGalleryPermissionGranted(permissions)) {
|
||||||
|
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
||||||
|
}
|
||||||
|
|
||||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||||
final batteryIgnored =
|
final batteryIgnored =
|
||||||
@@ -167,18 +184,43 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
|||||||
throw StateError('initializePreview retry exhausted');
|
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 {
|
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
||||||
if (!state.isPreviewReady || state.isRecording) return;
|
if (!state.isPreviewReady || state.isRecording) return;
|
||||||
|
|
||||||
|
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
|
||||||
|
final displayName = recordingFileNameForPlatform(clipboard.filename);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await RecordingPlatform.startRecording(
|
final result = await RecordingPlatform.startRecording(
|
||||||
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
|
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
|
||||||
|
displayName: displayName,
|
||||||
);
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: result.status,
|
status: result.status,
|
||||||
lastOutputPath: result.outputPath,
|
lastOutputPath: result.outputPath,
|
||||||
isTouchLocked: true,
|
isTouchLocked: true,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
gallerySaveFailed: false,
|
||||||
|
clearLastSaved: true,
|
||||||
);
|
);
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
|
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
|
||||||
@@ -190,10 +232,18 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await RecordingPlatform.stopRecording();
|
final result = await RecordingPlatform.stopRecording();
|
||||||
|
final galleryFailed = !result.gallerySaved;
|
||||||
|
final savedName = recordingFileNameForPlatform(
|
||||||
|
ref.read(recordingViewModelProvider).clipboardRecordingModel.filename,
|
||||||
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: result.status,
|
status: result.status,
|
||||||
lastOutputPath: result.outputPath ?? state.lastOutputPath,
|
lastOutputPath: result.outputPath ?? state.lastOutputPath,
|
||||||
errorMessage: null,
|
lastSavedDisplayName: galleryFailed ? null : savedName,
|
||||||
|
errorMessage: galleryFailed
|
||||||
|
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
||||||
|
: null,
|
||||||
|
gallerySaveFailed: galleryFailed,
|
||||||
);
|
);
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');
|
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ final recordingViewModelProvider =
|
|||||||
return RecordingViewModel(ref);
|
return RecordingViewModel(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 剪切板读取结果,供 UI 决定是否提示用户。
|
||||||
|
enum ClipboardReadResult {
|
||||||
|
/// 剪切板为空,不提示
|
||||||
|
empty,
|
||||||
|
|
||||||
|
/// 解析成功
|
||||||
|
success,
|
||||||
|
|
||||||
|
/// 有内容但格式不符合小程序录制信息
|
||||||
|
invalid,
|
||||||
|
}
|
||||||
|
|
||||||
class RecordingViewModel extends StateNotifier<RecordingModel> {
|
class RecordingViewModel extends StateNotifier<RecordingModel> {
|
||||||
RecordingViewModel(this.ref)
|
RecordingViewModel(this.ref)
|
||||||
: super(
|
: super(
|
||||||
@@ -26,8 +38,15 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
|
|||||||
);
|
);
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
/// 从剪切板获取内容
|
static final _defaultClipboard = ClipboardRecordingModel(
|
||||||
Future<void> getClipboardContent() async {
|
title: '',
|
||||||
|
startTimestamp: 0,
|
||||||
|
endTimestamp: 0,
|
||||||
|
address: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 从剪切板获取小程序复制的录制信息。
|
||||||
|
Future<ClipboardReadResult> getClipboardContent() async {
|
||||||
try {
|
try {
|
||||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
final text = clipboardData?.text;
|
final text = clipboardData?.text;
|
||||||
@@ -35,22 +54,45 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
|
|||||||
|
|
||||||
if (text == null || text.trim().isEmpty) {
|
if (text == null || text.trim().isEmpty) {
|
||||||
AppLogger.info('剪切板内容为空,跳过录制信息解析');
|
AppLogger.info('剪切板内容为空,跳过录制信息解析');
|
||||||
return;
|
_resetClipboardInfo();
|
||||||
|
return ClipboardReadResult.empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
final decoded = jsonDecode(text);
|
final decoded = jsonDecode(text.trim());
|
||||||
if (decoded is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic>) {
|
||||||
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
|
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
|
||||||
return;
|
_resetClipboardInfo();
|
||||||
|
return ClipboardReadResult.invalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
|
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()}');
|
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
|
||||||
|
return ClipboardReadResult.success;
|
||||||
} on FormatException catch (error) {
|
} on FormatException catch (error) {
|
||||||
AppLogger.warning('剪切板录制信息格式错误:$error');
|
AppLogger.warning('剪切板录制信息格式错误:$error');
|
||||||
|
_resetClipboardInfo();
|
||||||
|
return ClipboardReadResult.invalid;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: 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', () {
|
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();
|
final podfile = File('ios/Podfile').readAsStringSync();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -80,6 +80,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||||
expect(podfile, contains("'PERMISSION_MICROPHONE=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.startTimestamp, 1717334400);
|
||||||
expect(model.endTimestamp, 1717334400);
|
expect(model.endTimestamp, 1717334400);
|
||||||
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||||
|
expect(model.filename, isNull);
|
||||||
expect(model.toJson(), clipboardJson);
|
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', () {
|
test('throws FormatException when required field is missing', () {
|
||||||
final json = Map<String, dynamic>.from(clipboardJson)..remove('title');
|
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 defaultClipboardTitle = '';
|
||||||
const validClipboardText =
|
const validClipboardText =
|
||||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
||||||
|
|
||||||
Future<void> setClipboardText(String? text) async {
|
Future<void> setClipboardText(String? text) async {
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
@@ -33,116 +33,128 @@ void main() {
|
|||||||
final container = ProviderContainer();
|
final container = ProviderContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container
|
final result = await container
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.getClipboardContent();
|
.getClipboardContent();
|
||||||
|
|
||||||
final clipboardModel = container
|
expect(result, ClipboardReadResult.success);
|
||||||
.read(recordingViewModelProvider)
|
final model = container.read(recordingViewModelProvider);
|
||||||
.clipboardRecordingModel;
|
expect(model.hasValidClipboardInfo, isTrue);
|
||||||
expect(clipboardModel.title, '王东方 丨李想 空中格斗赛');
|
expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛');
|
||||||
expect(clipboardModel.startTimestamp, 1717334400);
|
expect(model.clipboardRecordingModel.startTimestamp, 1717334400);
|
||||||
expect(clipboardModel.endTimestamp, 1717334400);
|
expect(model.clipboardRecordingModel.endTimestamp, 1717334400);
|
||||||
expect(clipboardModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
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('');
|
await setClipboardText('');
|
||||||
final container = ProviderContainer();
|
final container = ProviderContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container
|
final result = await container
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.getClipboardContent();
|
.getClipboardContent();
|
||||||
|
|
||||||
expect(
|
expect(result, ClipboardReadResult.empty);
|
||||||
container
|
final model = container.read(recordingViewModelProvider);
|
||||||
.read(recordingViewModelProvider)
|
expect(model.hasValidClipboardInfo, isFalse);
|
||||||
.clipboardRecordingModel
|
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
|
||||||
.title,
|
|
||||||
defaultClipboardTitle,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps default state when clipboard is not JSON', () async {
|
test('returns invalid when clipboard is not JSON', () async {
|
||||||
await setClipboardText('hello');
|
await setClipboardText('hello');
|
||||||
final container = ProviderContainer();
|
final container = ProviderContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container
|
final result = await container
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.getClipboardContent();
|
.getClipboardContent();
|
||||||
|
|
||||||
|
expect(result, ClipboardReadResult.invalid);
|
||||||
expect(
|
expect(
|
||||||
container
|
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||||
.read(recordingViewModelProvider)
|
|
||||||
.clipboardRecordingModel
|
|
||||||
.title,
|
|
||||||
defaultClipboardTitle,
|
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]');
|
await setClipboardText('[1,2,3]');
|
||||||
final container = ProviderContainer();
|
final container = ProviderContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
await container
|
final result = await container
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.getClipboardContent();
|
.getClipboardContent();
|
||||||
|
|
||||||
|
expect(result, ClipboardReadResult.invalid);
|
||||||
expect(
|
expect(
|
||||||
container
|
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||||
.read(recordingViewModelProvider)
|
|
||||||
.clipboardRecordingModel
|
|
||||||
.title,
|
|
||||||
defaultClipboardTitle,
|
defaultClipboardTitle,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('returns invalid when clipboard JSON misses required fields', () async {
|
||||||
'keeps default state when clipboard JSON misses required fields',
|
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
|
||||||
() async {
|
final container = ProviderContainer();
|
||||||
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
|
addTearDown(container.dispose);
|
||||||
final container = ProviderContainer();
|
|
||||||
addTearDown(container.dispose);
|
|
||||||
|
|
||||||
await container
|
final result = await container
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.getClipboardContent();
|
.getClipboardContent();
|
||||||
|
|
||||||
expect(
|
expect(result, ClipboardReadResult.invalid);
|
||||||
container
|
expect(
|
||||||
.read(recordingViewModelProvider)
|
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||||
.clipboardRecordingModel
|
defaultClipboardTitle,
|
||||||
.title,
|
);
|
||||||
defaultClipboardTitle,
|
});
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
test('returns invalid when clipboard JSON has wrong field type', () async {
|
||||||
'keeps default state when clipboard JSON has wrong field type',
|
await setClipboardText(
|
||||||
() async {
|
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
|
||||||
await setClipboardText(
|
);
|
||||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
|
final container = ProviderContainer();
|
||||||
);
|
addTearDown(container.dispose);
|
||||||
final container = ProviderContainer();
|
|
||||||
addTearDown(container.dispose);
|
|
||||||
|
|
||||||
await container
|
final result = await container
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.getClipboardContent();
|
.getClipboardContent();
|
||||||
|
|
||||||
expect(
|
expect(result, ClipboardReadResult.invalid);
|
||||||
container
|
expect(
|
||||||
.read(recordingViewModelProvider)
|
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
|
||||||
.clipboardRecordingModel
|
defaultClipboardTitle,
|
||||||
.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