From 77d9c355929cccbd5e98d7b90a8bd310137f7fc7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com>
Date: Thu, 4 Jun 2026 16:25:26 +0800
Subject: [PATCH] =?UTF-8?q?1.=E7=A1=AE=E5=AE=9A=20APP=20=E5=8C=85=E5=90=8D?=
=?UTF-8?q?=202.=E5=BD=95=E5=88=B6=E7=BB=93=E6=9D=9F=E5=A2=9E=E5=8A=A0?=
=?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=8F=90=E7=A4=BA=203.=E5=AE=8C=E6=88=90?=
=?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=89=AA=E5=88=87=E6=9D=BF=E5=86=85=E5=AE=B9?=
=?UTF-8?q?=E3=80=81=E6=96=B0=E5=A2=9E=E7=B2=98=E8=B4=B4=E5=89=AA=E5=88=87?=
=?UTF-8?q?=E6=9D=BF=E5=86=85=E5=AE=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
android/app/build.gradle.kts | 2 +-
android/app/src/main/AndroidManifest.xml | 18 +-
.../main/kotlin/com/gdfw/fxjk/AppConstants.kt | 4 +-
.../main/kotlin/com/gdfw/fxjk/MainActivity.kt | 115 +++++-----
.../recording/BatteryOptimizationHelper.kt | 16 +-
.../gdfw/fxjk/recording/DoNotDisturbHelper.kt | 10 +-
.../recording/RecordingCameraController.kt | 197 +++++++++---------
.../recording/RecordingForegroundService.kt | 96 +++++----
.../fxjk/recording/RecordingOutputFactory.kt | 31 ++-
.../recording/RecordingPlatformHandler.kt | 103 +++++----
.../fxjk/recording/RecordingPreviewFactory.kt | 16 +-
.../gdfw/fxjk/recording/RecordingSession.kt | 8 +-
.../com/gdfw/fxjk/recording/RecordingState.kt | 22 +-
ios/Runner/PlatformInfoPlugin.swift | 2 +-
ios/Runner/RecordingPlugin.swift | 34 ++-
lib/app/bootstrap.dart | 25 ++-
lib/core/platform/app_platform_info.dart | 2 +-
.../recording/model/model_clipboard.dart | 8 +-
.../recording/recording_channel_names.dart | 2 +-
lib/features/recording/recording_page.dart | 183 ++++++++++++----
.../recording_session_controller.dart | 17 +-
.../view-model/view_model_recording.dart | 4 +
.../widgets/recording_saved_dialog.dart | 120 +++++++++++
23 files changed, 652 insertions(+), 383 deletions(-)
create mode 100644 lib/features/recording/widgets/recording_saved_dialog.dart
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 0162c54..8641ba9 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
-val appPackageName = "com.gdfw.fxjk"
+val appPackageName = "com.qxy.dronex"
android {
namespace = appPackageName
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6abbcf5..267c67d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,5 +1,5 @@
+ package="com.qxy.dronex">
@@ -32,12 +32,12 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
+ android:name="io.flutter.embedding.android.NormalTheme"
+ android:resource="@style/NormalTheme"
+ />
-
-
+
+
@@ -52,8 +52,8 @@
-
-
+
+
-
+
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt
index 4e17c50..a87f01a 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/AppConstants.kt
@@ -1,7 +1,7 @@
-package com.gdfw.fxjk
+package com.qxy.dronex
object AppConstants {
- const val PACKAGE_NAME = "com.gdfw.fxjk"
+ const val PACKAGE_NAME = "com.qxy.dronex"
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt
index 302579d..f9f3e90 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/MainActivity.kt
@@ -1,10 +1,10 @@
-package com.gdfw.fxjk
+package com.qxy.dronex
import android.content.pm.ApplicationInfo
import android.os.Build
import androidx.camera.view.PreviewView
-import com.gdfw.fxjk.recording.RecordingPlatformHandler
-import com.gdfw.fxjk.recording.RecordingPreviewFactory
+import com.qxy.dronex.recording.RecordingPlatformHandler
+import com.qxy.dronex.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -18,33 +18,31 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
- flutterEngine
- .platformViewsController
- .registry
- .registerViewFactory(
+ flutterEngine.platformViewsController.registry.registerViewFactory(
"recording-camera-preview",
RecordingPreviewFactory(this),
- )
+ )
platformInfoChannel =
- MethodChannel(
- flutterEngine.dartExecutor.binaryMessenger,
- AppConstants.PLATFORM_INFO_CHANNEL,
- ).also { channel ->
- channel.setMethodCallHandler { call, result ->
- when (call.method) {
- "packageInfo" -> result.success(packageInfoMap())
- "deviceInfo" -> result.success(deviceInfoMap())
- else -> result.notImplemented()
- }
- }
- }
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ AppConstants.PLATFORM_INFO_CHANNEL,
+ )
+ .also { channel ->
+ channel.setMethodCallHandler { call, result ->
+ when (call.method) {
+ "packageInfo" -> result.success(packageInfoMap())
+ "deviceInfo" -> result.success(deviceInfoMap())
+ else -> result.notImplemented()
+ }
+ }
+ }
platformHandler =
- RecordingPlatformHandler(
- this,
- flutterEngine.dartExecutor.binaryMessenger,
- )
+ RecordingPlatformHandler(
+ this,
+ flutterEngine.dartExecutor.binaryMessenger,
+ )
}
fun attachRecordingPreview(previewView: PreviewView) {
@@ -67,54 +65,51 @@ class MainActivity : FlutterActivity() {
private fun packageInfoMap(): Map {
val packageInfo =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- packageManager.getPackageInfo(
- packageName,
- android.content.pm.PackageManager.PackageInfoFlags.of(0),
- )
- } else {
- @Suppress("DEPRECATION")
- packageManager.getPackageInfo(packageName, 0)
- }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getPackageInfo(
+ packageName,
+ android.content.pm.PackageManager.PackageInfoFlags.of(0),
+ )
+ } else {
+ @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
+ }
- val appName =
- applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
+ val appName = applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
val versionCode =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- packageInfo.longVersionCode.toString()
- } else {
- @Suppress("DEPRECATION")
- packageInfo.versionCode.toString()
- }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ packageInfo.longVersionCode.toString()
+ } else {
+ @Suppress("DEPRECATION") packageInfo.versionCode.toString()
+ }
return mapOf(
- "appName" to appName,
- "packageName" to packageName,
- "version" to packageInfo.versionName.orEmpty(),
- "buildNumber" to versionCode,
+ "appName" to appName,
+ "packageName" to packageName,
+ "version" to packageInfo.versionName.orEmpty(),
+ "buildNumber" to versionCode,
)
}
private fun deviceInfoMap(): Map {
val flags = applicationInfo.flags
val isEmulator =
- Build.FINGERPRINT.startsWith("generic") ||
- Build.FINGERPRINT.startsWith("unknown") ||
- Build.MODEL.contains("google_sdk") ||
- Build.MODEL.contains("Emulator") ||
- Build.MODEL.contains("Android SDK built for x86") ||
- Build.MANUFACTURER.contains("Genymotion") ||
- Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
- Build.PRODUCT == "google_sdk" ||
- flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
- Build.HARDWARE.contains("ranchu")
+ Build.FINGERPRINT.startsWith("generic") ||
+ Build.FINGERPRINT.startsWith("unknown") ||
+ Build.MODEL.contains("google_sdk") ||
+ Build.MODEL.contains("Emulator") ||
+ Build.MODEL.contains("Android SDK built for x86") ||
+ Build.MANUFACTURER.contains("Genymotion") ||
+ Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
+ Build.PRODUCT == "google_sdk" ||
+ flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
+ Build.HARDWARE.contains("ranchu")
return mapOf(
- "platform" to "android",
- "brand" to Build.BRAND,
- "model" to Build.MODEL,
- "systemVersion" to Build.VERSION.RELEASE,
- "isPhysicalDevice" to !isEmulator,
+ "platform" to "android",
+ "brand" to Build.BRAND,
+ "model" to Build.MODEL,
+ "systemVersion" to Build.VERSION.RELEASE,
+ "isPhysicalDevice" to !isEmulator,
)
}
}
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt
index 7f65f28..dc5a50b 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/BatteryOptimizationHelper.kt
@@ -1,4 +1,4 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
import android.content.Context
import android.content.Intent
@@ -18,10 +18,10 @@ object BatteryOptimizationHelper {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val intent =
- Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
- data = Uri.parse("package:${context.packageName}")
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
+ Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:${context.packageName}")
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
@@ -29,9 +29,9 @@ object BatteryOptimizationHelper {
}
val fallback =
- Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
+ Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
context.startActivity(fallback)
}
}
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt
index ec00e90..7949d8c 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/DoNotDisturbHelper.kt
@@ -1,9 +1,8 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
-import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
@@ -16,9 +15,10 @@ object DoNotDisturbHelper {
}
fun openAccessSettings(context: Context) {
- val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
+ val intent =
+ Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
context.startActivity(intent)
}
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt
index c4ba0c3..a225439 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingCameraController.kt
@@ -1,4 +1,4 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
import android.content.Context
import android.util.Log
@@ -17,7 +17,7 @@ import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.Executor
class RecordingCameraController(
- private val appContext: Context,
+ private val appContext: Context,
) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
@@ -37,58 +37,58 @@ class RecordingCameraController(
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview(
- lifecycleOwner: LifecycleOwner,
- previewView: PreviewView,
- onReady: (Boolean) -> Unit,
+ lifecycleOwner: LifecycleOwner,
+ previewView: PreviewView,
+ onReady: (Boolean) -> Unit,
) {
val future = ProcessCameraProvider.getInstance(appContext)
future.addListener(
- {
- try {
- val provider = future.get()
- cameraProvider = provider
- boundLifecycleOwner = lifecycleOwner
+ {
+ try {
+ val provider = future.get()
+ cameraProvider = provider
+ boundLifecycleOwner = lifecycleOwner
- preview =
- Preview.Builder().build().also {
- it.surfaceProvider = previewView.surfaceProvider
- }
+ preview =
+ Preview.Builder().build().also {
+ it.surfaceProvider = previewView.surfaceProvider
+ }
- val recorder =
- Recorder.Builder()
- .setQualitySelector(QualitySelector.from(Quality.HD))
- .build()
- videoCapture = VideoCapture.withOutput(recorder)
+ val recorder =
+ Recorder.Builder()
+ .setQualitySelector(QualitySelector.from(Quality.HD))
+ .build()
+ videoCapture = VideoCapture.withOutput(recorder)
- provider.unbindAll()
- provider.bindToLifecycle(
- lifecycleOwner,
- CameraSelector.DEFAULT_BACK_CAMERA,
- preview,
- videoCapture,
- )
+ provider.unbindAll()
+ provider.bindToLifecycle(
+ lifecycleOwner,
+ CameraSelector.DEFAULT_BACK_CAMERA,
+ preview,
+ videoCapture,
+ )
- updateStatus(RecordingStatus(RecordingState.PREVIEWING))
- onReady(true)
- } catch (error: Exception) {
- Log.e(TAG, "bindPreview failed", error)
- updateStatus(
- RecordingStatus(
- RecordingState.ERROR,
- message = error.message,
- ),
- )
- onReady(false)
- }
- },
- mainExecutor,
+ updateStatus(RecordingStatus(RecordingState.PREVIEWING))
+ onReady(true)
+ } catch (error: Exception) {
+ Log.e(TAG, "bindPreview failed", error)
+ updateStatus(
+ RecordingStatus(
+ RecordingState.ERROR,
+ message = error.message,
+ ),
+ )
+ onReady(false)
+ }
+ },
+ mainExecutor,
)
}
fun rebindForRecording(
- lifecycleOwner: LifecycleOwner,
- previewView: PreviewView,
- onReady: (Boolean) -> Unit,
+ lifecycleOwner: LifecycleOwner,
+ previewView: PreviewView,
+ onReady: (Boolean) -> Unit,
) {
val provider = cameraProvider
if (provider == null) {
@@ -100,10 +100,10 @@ class RecordingCameraController(
boundLifecycleOwner = lifecycleOwner
provider.unbindAll()
provider.bindToLifecycle(
- lifecycleOwner,
- CameraSelector.DEFAULT_BACK_CAMERA,
- preview,
- videoCapture,
+ lifecycleOwner,
+ CameraSelector.DEFAULT_BACK_CAMERA,
+ preview,
+ videoCapture,
)
onReady(true)
} catch (error: Exception) {
@@ -113,9 +113,9 @@ class RecordingCameraController(
}
fun startRecording(
- withAudio: Boolean,
- displayName: String?,
- onStarted: (Boolean, String?) -> Unit,
+ withAudio: Boolean,
+ displayName: String?,
+ onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
@@ -129,63 +129,66 @@ class RecordingCameraController(
}
val outputOptions =
- RecordingOutputFactory.buildMediaStoreOutputOptions(
- appContext,
- displayName,
- )
+ RecordingOutputFactory.buildMediaStoreOutputOptions(
+ appContext,
+ displayName,
+ )
latestOutputPath = null
val pending =
- capture.output.prepareRecording(appContext, outputOptions).apply {
- if (withAudio) {
- val granted =
- ContextCompat.checkSelfPermission(
- appContext,
- android.Manifest.permission.RECORD_AUDIO,
- ) == android.content.pm.PackageManager.PERMISSION_GRANTED
- if (granted) {
- withAudioEnabled()
+ capture.output.prepareRecording(appContext, outputOptions).apply {
+ if (withAudio) {
+ val granted =
+ ContextCompat.checkSelfPermission(
+ appContext,
+ android.Manifest.permission.RECORD_AUDIO,
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ if (granted) {
+ withAudioEnabled()
+ }
}
}
- }
recordingStartedAt = System.currentTimeMillis()
updateStatus(
- RecordingStatus(
- RecordingState.RECORDING,
- outputPath = latestOutputPath,
- ),
+ RecordingStatus(
+ RecordingState.RECORDING,
+ outputPath = latestOutputPath,
+ ),
)
activeRecording =
- pending.start(mainExecutor) { event ->
- when (event) {
- is VideoRecordEvent.Start -> Unit
- is VideoRecordEvent.Finalize -> {
- activeRecording = null
- if (event.hasError()) {
- updateStatus(
- RecordingStatus(
- RecordingState.ERROR,
- message = event.cause?.message ?: "Recording failed",
- ),
- )
- } else {
- latestOutputPath = event.outputResults.outputUri.toString()
- updateStatus(
- RecordingStatus(
- RecordingState.PREVIEWING,
- outputPath = latestOutputPath,
- elapsedMillis = System.currentTimeMillis() - recordingStartedAt,
- ),
- )
+ pending.start(mainExecutor) { event ->
+ when (event) {
+ is VideoRecordEvent.Start -> Unit
+ is VideoRecordEvent.Finalize -> {
+ activeRecording = null
+ if (event.hasError()) {
+ updateStatus(
+ RecordingStatus(
+ RecordingState.ERROR,
+ message = event.cause?.message
+ ?: "Recording failed",
+ ),
+ )
+ } else {
+ latestOutputPath = event.outputResults.outputUri.toString()
+ updateStatus(
+ RecordingStatus(
+ RecordingState.PREVIEWING,
+ outputPath = latestOutputPath,
+ elapsedMillis =
+ System.currentTimeMillis() -
+ recordingStartedAt,
+ ),
+ )
+ }
+ val stopCallback = pendingStopCallback
+ pendingStopCallback = null
+ stopCallback?.invoke(latestOutputPath)
}
- val stopCallback = pendingStopCallback
- pendingStopCallback = null
- stopCallback?.invoke(latestOutputPath)
}
}
- }
onStarted(true, latestOutputPath ?: "recording")
}
@@ -199,10 +202,10 @@ class RecordingCameraController(
pendingStopCallback = onStopped
updateStatus(
- RecordingStatus(
- RecordingState.STOPPING,
- outputPath = latestOutputPath,
- ),
+ RecordingStatus(
+ RecordingState.STOPPING,
+ outputPath = latestOutputPath,
+ ),
)
recording.stop()
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt
index 6de5d12..6f4543a 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingForegroundService.kt
@@ -1,22 +1,21 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
-import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
-import androidx.core.content.ContextCompat
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
-import com.gdfw.fxjk.AppConstants
-import com.gdfw.fxjk.MainActivity
+import com.qxy.dronex.AppConstants
+import com.qxy.dronex.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
@@ -35,9 +34,9 @@ class RecordingForegroundService : LifecycleService() {
val notification = buildNotification("正在录制")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
- NOTIFICATION_ID,
- notification,
- foregroundServiceTypes(),
+ NOTIFICATION_ID,
+ notification,
+ foregroundServiceTypes(),
)
} else {
startForeground(NOTIFICATION_ID, notification)
@@ -72,10 +71,10 @@ class RecordingForegroundService : LifecycleService() {
if (wakeLock?.isHeld == true) return
val manager = getSystemService(PowerManager::class.java) ?: return
wakeLock =
- manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
- setReferenceCounted(false)
- acquire(4 * 60 * 60 * 1000L)
- }
+ manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
+ setReferenceCounted(false)
+ acquire(4 * 60 * 60 * 1000L)
+ }
}
private fun releaseWakeLock() {
@@ -90,14 +89,15 @@ class RecordingForegroundService : LifecycleService() {
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel =
- NotificationChannel(
- CHANNEL_ID,
- "录制服务",
- NotificationManager.IMPORTANCE_LOW,
- ).apply {
- description = "保持相机录制在后台与息屏时继续运行"
- setShowBadge(false)
- }
+ NotificationChannel(
+ CHANNEL_ID,
+ "录制服务",
+ NotificationManager.IMPORTANCE_LOW,
+ )
+ .apply {
+ description = "保持相机录制在后台与息屏时继续运行"
+ setShowBadge(false)
+ }
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
@@ -112,33 +112,33 @@ class RecordingForegroundService : LifecycleService() {
private fun hasRecordAudioPermission(): Boolean {
return ContextCompat.checkSelfPermission(
- this,
- android.Manifest.permission.RECORD_AUDIO,
+ this,
+ android.Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
}
private fun buildNotification(content: String): Notification {
val launchIntent =
- Intent(this, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
+ Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
val pendingIntent =
- PendingIntent.getActivity(
- this,
- 0,
- launchIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
+ PendingIntent.getActivity(
+ this,
+ 0,
+ launchIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
return NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle("录制进行中")
- .setContentText(content)
- .setSmallIcon(android.R.drawable.presence_video_online)
- .setOngoing(true)
- .setContentIntent(pendingIntent)
- .setCategory(NotificationCompat.CATEGORY_SERVICE)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .build()
+ .setContentTitle("录制进行中")
+ .setContentText(content)
+ .setSmallIcon(android.R.drawable.presence_video_online)
+ .setOngoing(true)
+ .setContentIntent(pendingIntent)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
}
companion object {
@@ -146,25 +146,23 @@ class RecordingForegroundService : LifecycleService() {
const val NOTIFICATION_ID = 1001
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
- @Volatile
- var isRunning: Boolean = false
+ @Volatile var isRunning: Boolean = false
- @Volatile
- var instance: RecordingForegroundService? = null
+ @Volatile var instance: RecordingForegroundService? = null
fun start(context: Context) {
val intent =
- Intent(context, RecordingForegroundService::class.java).apply {
- action = AppConstants.RECORDING_ACTION_START
- }
+ Intent(context, RecordingForegroundService::class.java).apply {
+ action = AppConstants.RECORDING_ACTION_START
+ }
ContextCompatStart.startForegroundService(context, intent)
}
fun stop(context: Context) {
val intent =
- Intent(context, RecordingForegroundService::class.java).apply {
- action = AppConstants.RECORDING_ACTION_STOP
- }
+ Intent(context, RecordingForegroundService::class.java).apply {
+ action = AppConstants.RECORDING_ACTION_STOP
+ }
context.startService(intent)
}
}
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt
index 0d04bab..e453b3a 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingOutputFactory.kt
@@ -1,4 +1,4 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
import android.content.ContentValues
import android.content.Context
@@ -14,25 +14,25 @@ object RecordingOutputFactory {
private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions(
- context: Context,
- displayName: String?,
+ 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)
+ 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()
+ context.contentResolver,
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ )
+ .setContentValues(contentValues)
+ .build()
}
fun resolveFileName(displayName: String?): String {
@@ -44,8 +44,7 @@ object RecordingOutputFactory {
"$trimmed.mp4"
}
}
- val timestamp =
- SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4"
}
}
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt
index e9ee8c7..8ce8657 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPlatformHandler.kt
@@ -1,27 +1,23 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
-import android.app.Activity
-import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
-import com.gdfw.fxjk.AppConstants
-import com.gdfw.fxjk.MainActivity
+import com.qxy.dronex.AppConstants
+import com.qxy.dronex.MainActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class RecordingPlatformHandler(
- private val activity: MainActivity,
- messenger: BinaryMessenger,
+ private val activity: MainActivity,
+ messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
- private val methodChannel =
- MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
- private val eventChannel =
- EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
+ private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
+ private val eventChannel = EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
@@ -33,9 +29,7 @@ class RecordingPlatformHandler(
methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this)
controller.statusListener = { status ->
- mainHandler.post {
- eventSink?.success(status.toMap())
- }
+ mainHandler.post { eventSink?.success(status.toMap()) }
}
}
@@ -60,20 +54,18 @@ class RecordingPlatformHandler(
controller.unbind()
result.success(null)
}
- "hasNotificationPolicyAccess" ->
- result.success(DoNotDisturbHelper.hasAccess(activity))
+ "hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity)
result.success(null)
}
- "enableDoNotDisturb" ->
- result.success(DoNotDisturbHelper.enable(activity))
+ "enableDoNotDisturb" -> result.success(DoNotDisturbHelper.enable(activity))
"disableDoNotDisturb" -> {
DoNotDisturbHelper.disable(activity)
result.success(null)
}
"isIgnoringBatteryOptimizations" ->
- result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
+ result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
"openBatteryOptimizationSettings" -> {
BatteryOptimizationHelper.openSettings(activity)
result.success(null)
@@ -84,8 +76,7 @@ class RecordingPlatformHandler(
result.success(null)
}
"getStatus" -> result.success(controller.status.toMap())
- "isForegroundServiceRunning" ->
- result.success(RecordingForegroundService.isRunning)
+ "isForegroundServiceRunning" -> result.success(RecordingForegroundService.isRunning)
else -> result.notImplemented()
}
}
@@ -109,10 +100,10 @@ class RecordingPlatformHandler(
}
private fun startRecording(
- withAudio: Boolean,
- enableDnd: Boolean,
- displayName: String?,
- result: MethodChannel.Result,
+ withAudio: Boolean,
+ enableDnd: Boolean,
+ displayName: String?,
+ result: MethodChannel.Result,
) {
val previewView = activity.recordingPreviewView
if (previewView == null) {
@@ -132,10 +123,10 @@ class RecordingPlatformHandler(
if (started) {
startElapsedTicker()
result.success(
- mapOf(
- "outputPath" to message,
- "status" to controller.status.toMap(),
- ),
+ mapOf(
+ "outputPath" to message,
+ "status" to controller.status.toMap(),
+ ),
)
} else {
RecordingSession.stopForeground(activity)
@@ -147,8 +138,7 @@ class RecordingPlatformHandler(
}
fun rebindAndCapture() {
- val lifecycleOwner =
- RecordingForegroundService.instance ?: activity
+ val lifecycleOwner = RecordingForegroundService.instance ?: activity
controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
if (ready) {
beginCapture()
@@ -172,17 +162,15 @@ class RecordingPlatformHandler(
RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity)
mainHandler.post {
- val gallerySaved =
- path != null &&
- controller.status.state != RecordingState.ERROR
- val payload = mutableMapOf(
- "outputPath" to path,
- "status" to controller.status.toMap(),
- "gallerySaved" to gallerySaved,
- )
+ val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
+ val payload =
+ mutableMapOf(
+ "outputPath" to path,
+ "status" to controller.status.toMap(),
+ "gallerySaved" to gallerySaved,
+ )
if (!gallerySaved) {
- payload["galleryErrorMessage"] =
- controller.status.message ?: "保存到相册失败"
+ payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
}
result.success(payload)
}
@@ -196,7 +184,7 @@ class RecordingPlatformHandler(
if (enabled) {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
- WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
@@ -205,20 +193,23 @@ class RecordingPlatformHandler(
private fun startElapsedTicker() {
stopElapsedTicker()
elapsedTicker =
- object : Runnable {
- override fun run() {
- if (controller.status.state == RecordingState.RECORDING) {
- eventSink?.success(
- controller.status.copy(
- elapsedMillis = controller.elapsedMillis(),
- ).toMap(),
- )
- mainHandler.postDelayed(this, 1000L)
- }
- }
- }.also {
- mainHandler.post(it)
- }
+ object : Runnable {
+ override fun run() {
+ if (controller.status.state == RecordingState.RECORDING) {
+ eventSink?.success(
+ controller
+ .status
+ .copy(
+ elapsedMillis =
+ controller.elapsedMillis(),
+ )
+ .toMap(),
+ )
+ mainHandler.postDelayed(this, 1000L)
+ }
+ }
+ }
+ .also { mainHandler.post(it) }
}
private fun stopElapsedTicker() {
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt
index efbc8fa..21e00c2 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingPreviewFactory.kt
@@ -1,15 +1,15 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
-import com.gdfw.fxjk.MainActivity
+import com.qxy.dronex.MainActivity
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class RecordingPreviewFactory(
- private val activity: MainActivity,
+ private val activity: MainActivity,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return RecordingPreviewPlatformView(activity)
@@ -17,13 +17,13 @@ class RecordingPreviewFactory(
}
class RecordingPreviewPlatformView(
- private val activity: MainActivity,
+ private val activity: MainActivity,
) : PlatformView {
val previewView: PreviewView =
- PreviewView(activity).apply {
- implementationMode = PreviewView.ImplementationMode.COMPATIBLE
- scaleType = PreviewView.ScaleType.FILL_CENTER
- }
+ PreviewView(activity).apply {
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ }
init {
activity.attachRecordingPreview(previewView)
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt
index ccd3189..4eba227 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingSession.kt
@@ -1,4 +1,4 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
import android.content.Context
import androidx.lifecycle.LifecycleService
@@ -8,9 +8,9 @@ object RecordingSession {
fun controller(context: Context): RecordingCameraController {
return cameraController
- ?: RecordingCameraController(context.applicationContext).also {
- cameraController = it
- }
+ ?: RecordingCameraController(context.applicationContext).also {
+ cameraController = it
+ }
}
fun release() {
diff --git a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt
index fa6aef4..6cf5043 100644
--- a/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt
+++ b/android/app/src/main/kotlin/com/gdfw/fxjk/recording/RecordingState.kt
@@ -1,4 +1,4 @@
-package com.gdfw.fxjk.recording
+package com.qxy.dronex.recording
enum class RecordingState {
IDLE,
@@ -9,16 +9,16 @@ enum class RecordingState {
}
data class RecordingStatus(
- val state: RecordingState,
- val outputPath: String? = null,
- val elapsedMillis: Long = 0L,
- val message: String? = null,
+ val state: RecordingState,
+ val outputPath: String? = null,
+ val elapsedMillis: Long = 0L,
+ val message: String? = null,
) {
fun toMap(): Map =
- mapOf(
- "state" to state.name.lowercase(),
- "outputPath" to outputPath,
- "elapsedMillis" to elapsedMillis,
- "message" to message,
- )
+ mapOf(
+ "state" to state.name.lowercase(),
+ "outputPath" to outputPath,
+ "elapsedMillis" to elapsedMillis,
+ "message" to message,
+ )
}
diff --git a/ios/Runner/PlatformInfoPlugin.swift b/ios/Runner/PlatformInfoPlugin.swift
index ee64159..87329f4 100644
--- a/ios/Runner/PlatformInfoPlugin.swift
+++ b/ios/Runner/PlatformInfoPlugin.swift
@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
- name: "com.gdfw.fxjk/platform_info",
+ name: "com.qxy.dronex/platform_info",
binaryMessenger: registrar.messenger()
)
let plugin = PlatformInfoPlugin()
diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift
index 3f0ee1b..388e7f4 100644
--- a/ios/Runner/RecordingPlugin.swift
+++ b/ios/Runner/RecordingPlugin.swift
@@ -142,7 +142,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func initializePreview(result: @escaping FlutterResult) {
guard let previewView 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
}
@@ -176,7 +177,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
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))
+ result(
+ FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return
}
@@ -306,7 +308,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
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))
finishStopRecording(stopResult: stopResult)
return
}
@@ -411,10 +415,14 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return
}
- guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
- ?? AVCaptureDevice.default(for: .video)
+ guard
+ let videoDevice = AVCaptureDevice.default(
+ .builtInWideAngleCamera, for: .video, position: .back)
+ ?? AVCaptureDevice.default(for: .video)
else {
- throw NSError(domain: "RecordingCamera", code: 1, userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
+ throw NSError(
+ domain: "RecordingCamera", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
}
let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice)
@@ -424,14 +432,18 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard session.canAddInput(nextVideoInput) else {
session.commitConfiguration()
- throw NSError(domain: "RecordingCamera", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"])
+ throw NSError(
+ domain: "RecordingCamera", code: 2,
+ userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"])
}
session.addInput(nextVideoInput)
videoInput = nextVideoInput
guard session.canAddOutput(movieOutput) else {
session.commitConfiguration()
- throw NSError(domain: "RecordingCamera", code: 3, userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"])
+ throw NSError(
+ domain: "RecordingCamera", code: 3,
+ userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"])
}
session.addOutput(movieOutput)
session.commitConfiguration()
@@ -516,7 +528,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
private enum RecordingChannelNames {
- static let packageName = "com.gdfw.fxjk"
+ static let packageName = "com.qxy.dronex"
static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events"
}
@@ -584,7 +596,9 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
}
}
- func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
+ -> FlutterError?
+ {
eventSink = events
events(controller.currentStatusMap())
return nil
diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart
index 84b6589..b412b10 100644
--- a/lib/app/bootstrap.dart
+++ b/lib/app/bootstrap.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -18,12 +20,31 @@ class AppBootstrapper {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await AppStorage.init();
- final packageInfo = await AppPlatformInfo.packageInfo();
- AppConfig.configure(environment: environment, packageInfo: packageInfo);
+ AppConfig.configure(environment: environment);
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
runApp(const ProviderScope(child: FlutterTemplateApp()));
+
+ // Load native package metadata after the first frame can render.
+ // Awaiting MethodChannel calls before runApp() can stall the Android
+ // splash screen on some devices.
+ unawaited(_loadPackageInfo(environment));
+ }
+
+ static Future _loadPackageInfo(AppEnvironment environment) async {
+ try {
+ final packageInfo = await AppPlatformInfo.packageInfo().timeout(
+ const Duration(seconds: 8),
+ );
+ AppConfig.configure(environment: environment, packageInfo: packageInfo);
+ } catch (error, stackTrace) {
+ AppLogger.debug(
+ 'Native packageInfo unavailable',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ }
}
}
diff --git a/lib/core/platform/app_platform_info.dart b/lib/core/platform/app_platform_info.dart
index 3aba846..7e3bcde 100644
--- a/lib/core/platform/app_platform_info.dart
+++ b/lib/core/platform/app_platform_info.dart
@@ -59,7 +59,7 @@ class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
- 'com.gdfw.fxjk/platform_info',
+ 'com.qxy.dronex/platform_info',
);
static Future packageInfo() async {
diff --git a/lib/features/recording/model/model_clipboard.dart b/lib/features/recording/model/model_clipboard.dart
index aa5db7b..915c349 100644
--- a/lib/features/recording/model/model_clipboard.dart
+++ b/lib/features/recording/model/model_clipboard.dart
@@ -1,8 +1,8 @@
/// 小程序复制到剪切板的录制信息。
class ClipboardRecordingModel {
final String title;
- final int startTimestamp;
- final int endTimestamp;
+ int? startTimestamp;
+ int? endTimestamp;
final String address;
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
@@ -10,8 +10,8 @@ class ClipboardRecordingModel {
ClipboardRecordingModel({
required this.title,
- required this.startTimestamp,
- required this.endTimestamp,
+ this.startTimestamp,
+ this.endTimestamp,
required this.address,
this.filename,
});
diff --git a/lib/features/recording/recording_channel_names.dart b/lib/features/recording/recording_channel_names.dart
index ae2f4e0..faa0112 100644
--- a/lib/features/recording/recording_channel_names.dart
+++ b/lib/features/recording/recording_channel_names.dart
@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames {
- static const packageName = 'com.gdfw.fxjk';
+ static const packageName = 'com.qxy.dronex';
static const method = '$packageName/recording';
static const events = '$packageName/recording_events';
}
diff --git a/lib/features/recording/recording_page.dart b/lib/features/recording/recording_page.dart
index 1f449ed..2c949a1 100644
--- a/lib/features/recording/recording_page.dart
+++ b/lib/features/recording/recording_page.dart
@@ -2,13 +2,17 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:permission_handler/permission_handler.dart';
+import 'package:recording_tool/core/utils/date_time_formatter.dart';
+import 'package:recording_tool/features/recording/model/model_recording.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_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_saved_dialog.dart';
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
@@ -52,6 +56,69 @@ class _RecordingPageState extends ConsumerState {
_immersiveApplied = true;
}
+ String _clipboardHintLabel(RecordingModel recordingInfo) {
+ if (!recordingInfo.hasValidClipboardInfo) return '';
+ final clip = recordingInfo.clipboardRecordingModel;
+ final lines = [];
+ final address = clip.address.trim();
+ if (address.isNotEmpty) {
+ lines.add(address);
+ }
+ if (clip.startTimestamp > 0) {
+ final startTime = DateTime.fromMillisecondsSinceEpoch(
+ clip.startTimestamp * 1000,
+ ).toLocal();
+ lines.add(
+ DateTimeFormatter.format(startTime, pattern: 'yyyy-M-d-H:mm:ss'),
+ );
+ }
+ return lines.join('\n');
+ }
+
+ String _savedDialogSessionTitle(
+ RecordingModel recordingInfo,
+ String? savedName,
+ ) {
+ final clipboard = recordingInfo.clipboardRecordingModel;
+ if (recordingInfo.hasValidClipboardInfo &&
+ clipboard.title.trim().isNotEmpty) {
+ return clipboard.title.trim();
+ }
+ if (savedName != null && savedName.isNotEmpty) {
+ return resolveRecordingDisplayName(savedName);
+ }
+ return '录制完成';
+ }
+
+ Future _showRecordingSavedDialogIfNeeded() async {
+ final session = ref.read(recordingSessionControllerProvider);
+ if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
+ return;
+ }
+
+ final recordingInfo = ref.read(recordingViewModelProvider);
+ final sessionTitle = _savedDialogSessionTitle(
+ recordingInfo,
+ session.lastSavedDisplayName,
+ );
+
+ await showRecordingSavedDialog(
+ context,
+ sessionTitle: sessionTitle,
+ onContinueRound: () {
+ ref
+ .read(recordingSessionControllerProvider.notifier)
+ .clearSavedRecordingResult();
+ },
+ onRecordNewRound: () {
+ ref.read(recordingViewModelProvider.notifier).resetClipboardInfo();
+ ref
+ .read(recordingSessionControllerProvider.notifier)
+ .clearSavedRecordingResult();
+ },
+ );
+ }
+
Future _exitRecordingMode() async {
if (!_immersiveApplied) return;
await ref.read(recordingSessionControllerProvider.notifier).teardown();
@@ -100,6 +167,8 @@ class _RecordingPageState extends ConsumerState {
fit: StackFit.expand,
children: [
const CameraPreviewWidget(),
+ if (!state.isPreviewReady && state.errorMessage == null)
+ const _RecordingLoadingOverlay(message: '正在启动相机…'),
if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlay(
enabled: true,
@@ -109,6 +178,7 @@ class _RecordingPageState extends ConsumerState {
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
+ clipboardHintLabel: _clipboardHintLabel(recordingInfo),
onPasteEventInfo: () async {
final result = await ref
.read(recordingViewModelProvider.notifier)
@@ -125,7 +195,9 @@ class _RecordingPageState extends ConsumerState {
final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
+ return;
}
+ await _showRecordingSavedDialogIfNeeded();
},
onOpenDnd: () async {
await controller.openDndSettings();
@@ -139,6 +211,40 @@ class _RecordingPageState extends ConsumerState {
controller.setTouchLocked(!state.isTouchLocked);
},
),
+ if (state.isStartingRecording)
+ const _RecordingLoadingOverlay(message: '正在开始录制…'),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _RecordingLoadingOverlay extends StatelessWidget {
+ const _RecordingLoadingOverlay({required this.message});
+
+ final String message;
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ color: Colors.black,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox.square(
+ dimension: 32.r,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.5.r,
+ color: Colors.white70,
+ ),
+ ),
+ SizedBox(height: 14.h),
+ Text(
+ message,
+ style: TextStyle(color: Colors.white70, fontSize: 14.sp),
+ ),
],
),
),
@@ -151,6 +257,7 @@ class _RecordingHud extends StatelessWidget {
required this.state,
this.eventTitle,
this.eventAddress,
+ this.clipboardHintLabel,
required this.onPasteEventInfo,
required this.onStart,
required this.onStop,
@@ -162,6 +269,7 @@ class _RecordingHud extends StatelessWidget {
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
+ final String? clipboardHintLabel;
final Future Function() onPasteEventInfo;
final VoidCallback onStart;
final VoidCallback onStop;
@@ -222,6 +330,7 @@ class _RecordingHud extends StatelessWidget {
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
+ clipboardHintLabel: clipboardHintLabel,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
@@ -249,13 +358,18 @@ class _RecordingHud extends StatelessWidget {
Expanded(
child: Center(
child: GestureDetector(
- onTap: state.isRecording ? onStop : onStart,
+ onTap: state.isStartingRecording
+ ? null
+ : (state.isRecording ? onStop : onStart),
child: Container(
width: 76.w,
height: 76.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
- border: Border.all(color: Colors.white, width: 4.r),
+ border: Border.all(
+ color: Colors.white,
+ width: 4.r,
+ ),
color: state.isRecording
? Colors.white
: Colors.red,
@@ -280,17 +394,6 @@ class _RecordingHud extends StatelessWidget {
],
),
),
- if (state.lastSavedDisplayName != null &&
- !state.isRecording &&
- !state.gallerySaveFailed)
- Padding(
- padding: EdgeInsets.only(bottom: 16.r),
- child: Text(
- '已保存到相册:${state.lastSavedDisplayName}',
- style: TextStyle(color: Colors.white70, fontSize: 12.sp),
- textAlign: TextAlign.center,
- ),
- ),
],
),
if (showPasteEventInfo)
@@ -324,9 +427,7 @@ class _RecordingHud extends StatelessWidget {
left: 12.w,
right: 12.w,
child: Padding(
- padding: EdgeInsets.only(
- right: state.isRecording ? 96.w : 0,
- ),
+ padding: EdgeInsets.only(right: state.isRecording ? 96.w : 0),
child: Text(
eventTitle!,
style: _overlayTextStyle.copyWith(
@@ -344,10 +445,7 @@ class _RecordingHud extends StatelessWidget {
top: 8.r,
right: 12.w,
child: Container(
- padding: EdgeInsets.symmetric(
- horizontal: 12.r,
- vertical: 6.r,
- ),
+ padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 6.r),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20.r),
@@ -361,21 +459,21 @@ class _RecordingHud extends StatelessWidget {
),
),
),
- if (eventAddress != null && eventAddress!.isNotEmpty)
- Positioned(
- left: 16.w,
- bottom: 108.r,
- right: 120.w,
- child: Text(
- eventAddress!,
- style: _overlayTextStyle.copyWith(
- fontSize: 13.sp,
- color: Colors.white70,
- ),
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- ),
- ),
+ // if (eventAddress != null && eventAddress!.isNotEmpty)
+ // Positioned(
+ // left: 16.w,
+ // bottom: 108.r,
+ // right: 120.w,
+ // child: Text(
+ // eventAddress!,
+ // style: _overlayTextStyle.copyWith(
+ // fontSize: 13.sp,
+ // color: Colors.white70,
+ // ),
+ // maxLines: 2,
+ // overflow: TextOverflow.ellipsis,
+ // ),
+ // ),
],
),
);
@@ -387,6 +485,7 @@ class _SetupHints extends StatelessWidget {
required this.hasDndAccess,
required this.isBatteryIgnored,
required this.notificationsGranted,
+ this.clipboardHintLabel,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onOpenNotificationSettings,
@@ -395,13 +494,18 @@ class _SetupHints extends StatelessWidget {
final bool hasDndAccess;
final bool isBatteryIgnored;
final bool notificationsGranted;
+ final String? clipboardHintLabel;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onOpenNotificationSettings;
@override
Widget build(BuildContext context) {
- if (hasDndAccess && isBatteryIgnored && notificationsGranted) {
+ final showPermissionHints =
+ !hasDndAccess || !isBatteryIgnored || !notificationsGranted;
+ final showClipboardHint =
+ clipboardHintLabel != null && clipboardHintLabel!.isNotEmpty;
+ if (!showPermissionHints && !showClipboardHint) {
return const SizedBox.shrink();
}
@@ -422,6 +526,11 @@ class _SetupHints extends StatelessWidget {
SizedBox(height: 8.h),
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
],
+ if (clipboardHintLabel != null &&
+ clipboardHintLabel!.isNotEmpty) ...[
+ SizedBox(height: 8.h),
+ _HintChip(label: clipboardHintLabel!, onTap: () {}),
+ ],
],
),
);
diff --git a/lib/features/recording/recording_session_controller.dart b/lib/features/recording/recording_session_controller.dart
index 87a53fb..0f30b31 100644
--- a/lib/features/recording/recording_session_controller.dart
+++ b/lib/features/recording/recording_session_controller.dart
@@ -14,6 +14,7 @@ class RecordingSessionState {
this.status = const RecordingStatus(state: RecordingState.idle),
this.isTouchLocked = true,
this.isPreviewReady = false,
+ this.isStartingRecording = false,
this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
@@ -28,6 +29,7 @@ class RecordingSessionState {
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
+ final bool isStartingRecording;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
@@ -51,6 +53,7 @@ class RecordingSessionState {
RecordingStatus? status,
bool? isTouchLocked,
bool? isPreviewReady,
+ bool? isStartingRecording,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
@@ -67,6 +70,7 @@ class RecordingSessionState {
status: status ?? this.status,
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
+ isStartingRecording: isStartingRecording ?? this.isStartingRecording,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -204,11 +208,16 @@ class RecordingSessionController extends Notifier {
}
Future startRecording({bool enableDoNotDisturb = true}) async {
- if (!state.isPreviewReady || state.isRecording) return;
+ if (!state.isPreviewReady ||
+ state.isRecording ||
+ state.isStartingRecording) {
+ return;
+ }
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename);
+ state = state.copyWith(isStartingRecording: true, errorMessage: null);
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
@@ -224,6 +233,8 @@ class RecordingSessionController extends Notifier {
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
+ } finally {
+ state = state.copyWith(isStartingRecording: false);
}
}
@@ -254,6 +265,10 @@ class RecordingSessionController extends Notifier {
state = state.copyWith(isTouchLocked: locked);
}
+ void clearSavedRecordingResult() {
+ state = state.copyWith(clearLastSaved: true);
+ }
+
Future openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();
diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart
index 1b2ec4a..66debce 100644
--- a/lib/features/recording/view-model/view_model_recording.dart
+++ b/lib/features/recording/view-model/view_model_recording.dart
@@ -89,6 +89,10 @@ class RecordingViewModel extends StateNotifier {
}
}
+ void resetClipboardInfo() {
+ _resetClipboardInfo();
+ }
+
void _resetClipboardInfo() {
state = state.copyWith(
clipboardRecordingModel: _defaultClipboard,
diff --git a/lib/features/recording/widgets/recording_saved_dialog.dart b/lib/features/recording/widgets/recording_saved_dialog.dart
new file mode 100644
index 0000000..19792ce
--- /dev/null
+++ b/lib/features/recording/widgets/recording_saved_dialog.dart
@@ -0,0 +1,120 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+
+/// 录制结束并保存到相册后的后续操作弹窗。
+Future showRecordingSavedDialog(
+ BuildContext context, {
+ required String sessionTitle,
+ required VoidCallback onContinueRound,
+ required VoidCallback onRecordNewRound,
+}) {
+ return showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (dialogContext) {
+ return _RecordingSavedDialog(
+ sessionTitle: sessionTitle,
+ onContinueRound: () {
+ Navigator.of(dialogContext).pop();
+ onContinueRound();
+ },
+ onRecordNewRound: () {
+ Navigator.of(dialogContext).pop();
+ onRecordNewRound();
+ },
+ );
+ },
+ );
+}
+
+class _RecordingSavedDialog extends StatelessWidget {
+ const _RecordingSavedDialog({
+ required this.sessionTitle,
+ required this.onContinueRound,
+ required this.onRecordNewRound,
+ });
+
+ final String sessionTitle;
+ final VoidCallback onContinueRound;
+ final VoidCallback onRecordNewRound;
+
+ @override
+ Widget build(BuildContext context) {
+ return Dialog(
+ backgroundColor: Colors.white,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(4.r),
+ side: const BorderSide(color: Colors.black, width: 1),
+ ),
+ insetPadding: EdgeInsets.symmetric(horizontal: 32.w),
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 16.h),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ sessionTitle,
+ style: TextStyle(
+ fontSize: 15.sp,
+ fontWeight: FontWeight.w600,
+ color: Colors.black87,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ SizedBox(height: 16.h),
+ Text(
+ '本轮比赛视频已保存到相册',
+ style: TextStyle(fontSize: 14.sp, color: Colors.black87),
+ textAlign: TextAlign.center,
+ ),
+ SizedBox(height: 8.h),
+ // Text(
+ // '请选择后续录制信息',
+ // style: TextStyle(fontSize: 14.sp, color: Colors.black87),
+ // textAlign: TextAlign.center,
+ // ),
+ SizedBox(height: 20.h),
+ Row(
+ children: [
+ Expanded(
+ child: _DialogActionButton(
+ label: '继续本轮',
+ onPressed: onContinueRound,
+ ),
+ ),
+ SizedBox(width: 12.w),
+ Expanded(
+ child: _DialogActionButton(
+ label: '录制新轮',
+ onPressed: onRecordNewRound,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _DialogActionButton extends StatelessWidget {
+ const _DialogActionButton({required this.label, required this.onPressed});
+
+ final String label;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ return TextButton(
+ onPressed: onPressed,
+ style: TextButton.styleFrom(
+ backgroundColor: const Color(0xFFE8E8E8),
+ foregroundColor: Colors.black87,
+ padding: EdgeInsets.symmetric(vertical: 10.h),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6.r)),
+ ),
+ child: Text(label, style: TextStyle(fontSize: 14.sp)),
+ );
+ }
+}