1.确定 APP 包名

2.录制结束增加弹窗提示
3.完成读取剪切板内容、新增粘贴剪切板内容
This commit is contained in:
2026-06-04 16:25:26 +08:00
parent 5ddcb95358
commit 77d9c35592
23 changed files with 652 additions and 383 deletions

View File

@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val appPackageName = "com.gdfw.fxjk" val appPackageName = "com.qxy.dronex"
android { android {
namespace = appPackageName namespace = appPackageName

View File

@@ -1,5 +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"> package="com.qxy.dronex">
<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" />
@@ -32,12 +32,12 @@
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
@@ -52,8 +52,8 @@
</application> </application>
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -1,7 +1,7 @@
package com.gdfw.fxjk package com.qxy.dronex
object AppConstants { 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 PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording" const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events" const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"

View File

@@ -1,10 +1,10 @@
package com.gdfw.fxjk package com.qxy.dronex
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.os.Build import android.os.Build
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import com.gdfw.fxjk.recording.RecordingPlatformHandler import com.qxy.dronex.recording.RecordingPlatformHandler
import com.gdfw.fxjk.recording.RecordingPreviewFactory import com.qxy.dronex.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@@ -18,33 +18,31 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine flutterEngine.platformViewsController.registry.registerViewFactory(
.platformViewsController
.registry
.registerViewFactory(
"recording-camera-preview", "recording-camera-preview",
RecordingPreviewFactory(this), RecordingPreviewFactory(this),
) )
platformInfoChannel = platformInfoChannel =
MethodChannel( MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
AppConstants.PLATFORM_INFO_CHANNEL, AppConstants.PLATFORM_INFO_CHANNEL,
).also { channel -> )
channel.setMethodCallHandler { call, result -> .also { channel ->
when (call.method) { channel.setMethodCallHandler { call, result ->
"packageInfo" -> result.success(packageInfoMap()) when (call.method) {
"deviceInfo" -> result.success(deviceInfoMap()) "packageInfo" -> result.success(packageInfoMap())
else -> result.notImplemented() "deviceInfo" -> result.success(deviceInfoMap())
} else -> result.notImplemented()
} }
} }
}
platformHandler = platformHandler =
RecordingPlatformHandler( RecordingPlatformHandler(
this, this,
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
) )
} }
fun attachRecordingPreview(previewView: PreviewView) { fun attachRecordingPreview(previewView: PreviewView) {
@@ -67,54 +65,51 @@ class MainActivity : FlutterActivity() {
private fun packageInfoMap(): Map<String, String> { private fun packageInfoMap(): Map<String, String> {
val packageInfo = val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo( packageManager.getPackageInfo(
packageName, packageName,
android.content.pm.PackageManager.PackageInfoFlags.of(0), android.content.pm.PackageManager.PackageInfoFlags.of(0),
) )
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
packageManager.getPackageInfo(packageName, 0) }
}
val appName = val appName = applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
val versionCode = val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toString() packageInfo.longVersionCode.toString()
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") packageInfo.versionCode.toString()
packageInfo.versionCode.toString() }
}
return mapOf( return mapOf(
"appName" to appName, "appName" to appName,
"packageName" to packageName, "packageName" to packageName,
"version" to packageInfo.versionName.orEmpty(), "version" to packageInfo.versionName.orEmpty(),
"buildNumber" to versionCode, "buildNumber" to versionCode,
) )
} }
private fun deviceInfoMap(): Map<String, Any> { private fun deviceInfoMap(): Map<String, Any> {
val flags = applicationInfo.flags val flags = applicationInfo.flags
val isEmulator = val isEmulator =
Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") || Build.FINGERPRINT.startsWith("unknown") ||
Build.MODEL.contains("google_sdk") || Build.MODEL.contains("google_sdk") ||
Build.MODEL.contains("Emulator") || Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") || Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") || Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") || Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
Build.PRODUCT == "google_sdk" || Build.PRODUCT == "google_sdk" ||
flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 && flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
Build.HARDWARE.contains("ranchu") Build.HARDWARE.contains("ranchu")
return mapOf( return mapOf(
"platform" to "android", "platform" to "android",
"brand" to Build.BRAND, "brand" to Build.BRAND,
"model" to Build.MODEL, "model" to Build.MODEL,
"systemVersion" to Build.VERSION.RELEASE, "systemVersion" to Build.VERSION.RELEASE,
"isPhysicalDevice" to !isEmulator, "isPhysicalDevice" to !isEmulator,
) )
} }
} }

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -18,10 +18,10 @@ object BatteryOptimizationHelper {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val intent = val intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}") data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
if (intent.resolveActivity(context.packageManager) != null) { if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent) context.startActivity(intent)
@@ -29,9 +29,9 @@ object BatteryOptimizationHelper {
} }
val fallback = val fallback =
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity(fallback) context.startActivity(fallback)
} }
} }

View File

@@ -1,9 +1,8 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -16,9 +15,10 @@ object DoNotDisturbHelper {
} }
fun openAccessSettings(context: Context) { fun openAccessSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply { val intent =
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
} addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent) context.startActivity(intent)
} }

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
@@ -17,7 +17,7 @@ import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.Executor import java.util.concurrent.Executor
class RecordingCameraController( class RecordingCameraController(
private val appContext: Context, private val appContext: Context,
) { ) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext) private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
@@ -37,58 +37,58 @@ class RecordingCameraController(
private var pendingStopCallback: ((String?) -> Unit)? = null private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview( fun bindPreview(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
previewView: PreviewView, previewView: PreviewView,
onReady: (Boolean) -> Unit, onReady: (Boolean) -> Unit,
) { ) {
val future = ProcessCameraProvider.getInstance(appContext) val future = ProcessCameraProvider.getInstance(appContext)
future.addListener( future.addListener(
{ {
try { try {
val provider = future.get() val provider = future.get()
cameraProvider = provider cameraProvider = provider
boundLifecycleOwner = lifecycleOwner boundLifecycleOwner = lifecycleOwner
preview = preview =
Preview.Builder().build().also { Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider it.surfaceProvider = previewView.surfaceProvider
} }
val recorder = val recorder =
Recorder.Builder() Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HD)) .setQualitySelector(QualitySelector.from(Quality.HD))
.build() .build()
videoCapture = VideoCapture.withOutput(recorder) videoCapture = VideoCapture.withOutput(recorder)
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle( provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, CameraSelector.DEFAULT_BACK_CAMERA,
preview, preview,
videoCapture, videoCapture,
) )
updateStatus(RecordingStatus(RecordingState.PREVIEWING)) updateStatus(RecordingStatus(RecordingState.PREVIEWING))
onReady(true) onReady(true)
} catch (error: Exception) { } catch (error: Exception) {
Log.e(TAG, "bindPreview failed", error) Log.e(TAG, "bindPreview failed", error)
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.ERROR, RecordingState.ERROR,
message = error.message, message = error.message,
), ),
) )
onReady(false) onReady(false)
} }
}, },
mainExecutor, mainExecutor,
) )
} }
fun rebindForRecording( fun rebindForRecording(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
previewView: PreviewView, previewView: PreviewView,
onReady: (Boolean) -> Unit, onReady: (Boolean) -> Unit,
) { ) {
val provider = cameraProvider val provider = cameraProvider
if (provider == null) { if (provider == null) {
@@ -100,10 +100,10 @@ class RecordingCameraController(
boundLifecycleOwner = lifecycleOwner boundLifecycleOwner = lifecycleOwner
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle( provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, CameraSelector.DEFAULT_BACK_CAMERA,
preview, preview,
videoCapture, videoCapture,
) )
onReady(true) onReady(true)
} catch (error: Exception) { } catch (error: Exception) {
@@ -113,9 +113,9 @@ class RecordingCameraController(
} }
fun startRecording( fun startRecording(
withAudio: Boolean, withAudio: Boolean,
displayName: String?, displayName: String?,
onStarted: (Boolean, String?) -> Unit, onStarted: (Boolean, String?) -> Unit,
) { ) {
val capture = videoCapture val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) { if (capture == null || boundLifecycleOwner == null) {
@@ -129,63 +129,66 @@ class RecordingCameraController(
} }
val outputOptions = val outputOptions =
RecordingOutputFactory.buildMediaStoreOutputOptions( RecordingOutputFactory.buildMediaStoreOutputOptions(
appContext, appContext,
displayName, displayName,
) )
latestOutputPath = null latestOutputPath = null
val pending = val pending =
capture.output.prepareRecording(appContext, outputOptions).apply { capture.output.prepareRecording(appContext, outputOptions).apply {
if (withAudio) { if (withAudio) {
val granted = val granted =
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
appContext, appContext,
android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED ) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) { if (granted) {
withAudioEnabled() withAudioEnabled()
}
} }
} }
}
recordingStartedAt = System.currentTimeMillis() recordingStartedAt = System.currentTimeMillis()
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.RECORDING, RecordingState.RECORDING,
outputPath = latestOutputPath, outputPath = latestOutputPath,
), ),
) )
activeRecording = activeRecording =
pending.start(mainExecutor) { event -> pending.start(mainExecutor) { event ->
when (event) { when (event) {
is VideoRecordEvent.Start -> Unit is VideoRecordEvent.Start -> Unit
is VideoRecordEvent.Finalize -> { is VideoRecordEvent.Finalize -> {
activeRecording = null activeRecording = null
if (event.hasError()) { if (event.hasError()) {
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.ERROR, RecordingState.ERROR,
message = event.cause?.message ?: "Recording failed", message = event.cause?.message
), ?: "Recording failed",
) ),
} else { )
latestOutputPath = event.outputResults.outputUri.toString() } else {
updateStatus( latestOutputPath = event.outputResults.outputUri.toString()
RecordingStatus( updateStatus(
RecordingState.PREVIEWING, RecordingStatus(
outputPath = latestOutputPath, RecordingState.PREVIEWING,
elapsedMillis = System.currentTimeMillis() - recordingStartedAt, 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") onStarted(true, latestOutputPath ?: "recording")
} }
@@ -199,10 +202,10 @@ class RecordingCameraController(
pendingStopCallback = onStopped pendingStopCallback = onStopped
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.STOPPING, RecordingState.STOPPING,
outputPath = latestOutputPath, outputPath = latestOutputPath,
), ),
) )
recording.stop() recording.stop()

View File

@@ -1,22 +1,21 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import com.gdfw.fxjk.AppConstants import com.qxy.dronex.AppConstants
import com.gdfw.fxjk.MainActivity import com.qxy.dronex.MainActivity
class RecordingForegroundService : LifecycleService() { class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
@@ -35,9 +34,9 @@ class RecordingForegroundService : LifecycleService() {
val notification = buildNotification("正在录制") val notification = buildNotification("正在录制")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground( startForeground(
NOTIFICATION_ID, NOTIFICATION_ID,
notification, notification,
foregroundServiceTypes(), foregroundServiceTypes(),
) )
} else { } else {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
@@ -72,10 +71,10 @@ class RecordingForegroundService : LifecycleService() {
if (wakeLock?.isHeld == true) return if (wakeLock?.isHeld == true) return
val manager = getSystemService(PowerManager::class.java) ?: return val manager = getSystemService(PowerManager::class.java) ?: return
wakeLock = wakeLock =
manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
setReferenceCounted(false) setReferenceCounted(false)
acquire(4 * 60 * 60 * 1000L) acquire(4 * 60 * 60 * 1000L)
} }
} }
private fun releaseWakeLock() { private fun releaseWakeLock() {
@@ -90,14 +89,15 @@ class RecordingForegroundService : LifecycleService() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = val channel =
NotificationChannel( NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"录制服务", "录制服务",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { )
description = "保持相机录制在后台与息屏时继续运行" .apply {
setShowBadge(false) description = "保持相机录制在后台与息屏时继续运行"
} setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel) manager?.createNotificationChannel(channel)
} }
@@ -112,33 +112,33 @@ class RecordingForegroundService : LifecycleService() {
private fun hasRecordAudioPermission(): Boolean { private fun hasRecordAudioPermission(): Boolean {
return ContextCompat.checkSelfPermission( return ContextCompat.checkSelfPermission(
this, this,
android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
private fun buildNotification(content: String): Notification { private fun buildNotification(content: String): Notification {
val launchIntent = val launchIntent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
} }
val pendingIntent = val pendingIntent =
PendingIntent.getActivity( PendingIntent.getActivity(
this, this,
0, 0,
launchIntent, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("录制进行中") .setContentTitle("录制进行中")
.setContentText(content) .setContentText(content)
.setSmallIcon(android.R.drawable.presence_video_online) .setSmallIcon(android.R.drawable.presence_video_online)
.setOngoing(true) .setOngoing(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.build() .build()
} }
companion object { companion object {
@@ -146,25 +146,23 @@ class RecordingForegroundService : LifecycleService() {
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock" private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
@Volatile @Volatile var isRunning: Boolean = false
var isRunning: Boolean = false
@Volatile @Volatile var instance: RecordingForegroundService? = null
var instance: RecordingForegroundService? = null
fun start(context: Context) { fun start(context: Context) {
val intent = val intent =
Intent(context, RecordingForegroundService::class.java).apply { Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_START action = AppConstants.RECORDING_ACTION_START
} }
ContextCompatStart.startForegroundService(context, intent) ContextCompatStart.startForegroundService(context, intent)
} }
fun stop(context: Context) { fun stop(context: Context) {
val intent = val intent =
Intent(context, RecordingForegroundService::class.java).apply { Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_STOP action = AppConstants.RECORDING_ACTION_STOP
} }
context.startService(intent) context.startService(intent)
} }
} }

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
@@ -14,25 +14,25 @@ object RecordingOutputFactory {
private const val MIME_TYPE = "video/mp4" private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions( fun buildMediaStoreOutputOptions(
context: Context, context: Context,
displayName: String?, displayName: String?,
): MediaStoreOutputOptions { ): MediaStoreOutputOptions {
val fileName = resolveFileName(displayName) val fileName = resolveFileName(displayName)
val contentValues = val contentValues =
ContentValues().apply { ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE) put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH) put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
}
} }
}
return MediaStoreOutputOptions.Builder( return MediaStoreOutputOptions.Builder(
context.contentResolver, context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
) )
.setContentValues(contentValues) .setContentValues(contentValues)
.build() .build()
} }
fun resolveFileName(displayName: String?): String { fun resolveFileName(displayName: String?): String {
@@ -44,8 +44,7 @@ object RecordingOutputFactory {
"$trimmed.mp4" "$trimmed.mp4"
} }
} }
val timestamp = val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4" return "REC_$timestamp.mp4"
} }
} }

View File

@@ -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.Handler
import android.os.Looper import android.os.Looper
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.gdfw.fxjk.AppConstants import com.qxy.dronex.AppConstants
import com.gdfw.fxjk.MainActivity import com.qxy.dronex.MainActivity
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class RecordingPlatformHandler( class RecordingPlatformHandler(
private val activity: MainActivity, private val activity: MainActivity,
messenger: BinaryMessenger, messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler { ) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private val methodChannel = private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL) private val eventChannel = EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val eventChannel =
EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null private var eventSink: EventChannel.EventSink? = null
@@ -33,9 +29,7 @@ class RecordingPlatformHandler(
methodChannel.setMethodCallHandler(this) methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this) eventChannel.setStreamHandler(this)
controller.statusListener = { status -> controller.statusListener = { status ->
mainHandler.post { mainHandler.post { eventSink?.success(status.toMap()) }
eventSink?.success(status.toMap())
}
} }
} }
@@ -60,20 +54,18 @@ class RecordingPlatformHandler(
controller.unbind() controller.unbind()
result.success(null) result.success(null)
} }
"hasNotificationPolicyAccess" -> "hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> { "openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity) DoNotDisturbHelper.openAccessSettings(activity)
result.success(null) result.success(null)
} }
"enableDoNotDisturb" -> "enableDoNotDisturb" -> result.success(DoNotDisturbHelper.enable(activity))
result.success(DoNotDisturbHelper.enable(activity))
"disableDoNotDisturb" -> { "disableDoNotDisturb" -> {
DoNotDisturbHelper.disable(activity) DoNotDisturbHelper.disable(activity)
result.success(null) result.success(null)
} }
"isIgnoringBatteryOptimizations" -> "isIgnoringBatteryOptimizations" ->
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity)) result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
"openBatteryOptimizationSettings" -> { "openBatteryOptimizationSettings" -> {
BatteryOptimizationHelper.openSettings(activity) BatteryOptimizationHelper.openSettings(activity)
result.success(null) result.success(null)
@@ -84,8 +76,7 @@ class RecordingPlatformHandler(
result.success(null) result.success(null)
} }
"getStatus" -> result.success(controller.status.toMap()) "getStatus" -> result.success(controller.status.toMap())
"isForegroundServiceRunning" -> "isForegroundServiceRunning" -> result.success(RecordingForegroundService.isRunning)
result.success(RecordingForegroundService.isRunning)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -109,10 +100,10 @@ class RecordingPlatformHandler(
} }
private fun startRecording( private fun startRecording(
withAudio: Boolean, withAudio: Boolean,
enableDnd: Boolean, enableDnd: Boolean,
displayName: String?, displayName: String?,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
val previewView = activity.recordingPreviewView val previewView = activity.recordingPreviewView
if (previewView == null) { if (previewView == null) {
@@ -132,10 +123,10 @@ class RecordingPlatformHandler(
if (started) { if (started) {
startElapsedTicker() startElapsedTicker()
result.success( result.success(
mapOf( mapOf(
"outputPath" to message, "outputPath" to message,
"status" to controller.status.toMap(), "status" to controller.status.toMap(),
), ),
) )
} else { } else {
RecordingSession.stopForeground(activity) RecordingSession.stopForeground(activity)
@@ -147,8 +138,7 @@ class RecordingPlatformHandler(
} }
fun rebindAndCapture() { fun rebindAndCapture() {
val lifecycleOwner = val lifecycleOwner = RecordingForegroundService.instance ?: activity
RecordingForegroundService.instance ?: activity
controller.rebindForRecording(lifecycleOwner, previewView) { ready -> controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
if (ready) { if (ready) {
beginCapture() beginCapture()
@@ -172,17 +162,15 @@ class RecordingPlatformHandler(
RecordingSession.stopForeground(activity) RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity) DoNotDisturbHelper.disable(activity)
mainHandler.post { mainHandler.post {
val gallerySaved = val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
path != null && val payload =
controller.status.state != RecordingState.ERROR mutableMapOf<String, Any?>(
val payload = mutableMapOf<String, Any?>( "outputPath" to path,
"outputPath" to path, "status" to controller.status.toMap(),
"status" to controller.status.toMap(), "gallerySaved" to gallerySaved,
"gallerySaved" to gallerySaved, )
)
if (!gallerySaved) { if (!gallerySaved) {
payload["galleryErrorMessage"] = payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
controller.status.message ?: "保存到相册失败"
} }
result.success(payload) result.success(payload)
} }
@@ -196,7 +184,7 @@ class RecordingPlatformHandler(
if (enabled) { if (enabled) {
insetsController.hide(WindowInsetsCompat.Type.systemBars()) insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior = insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else { } else {
insetsController.show(WindowInsetsCompat.Type.systemBars()) insetsController.show(WindowInsetsCompat.Type.systemBars())
} }
@@ -205,20 +193,23 @@ class RecordingPlatformHandler(
private fun startElapsedTicker() { private fun startElapsedTicker() {
stopElapsedTicker() stopElapsedTicker()
elapsedTicker = elapsedTicker =
object : Runnable { object : Runnable {
override fun run() { override fun run() {
if (controller.status.state == RecordingState.RECORDING) { if (controller.status.state == RecordingState.RECORDING) {
eventSink?.success( eventSink?.success(
controller.status.copy( controller
elapsedMillis = controller.elapsedMillis(), .status
).toMap(), .copy(
) elapsedMillis =
mainHandler.postDelayed(this, 1000L) controller.elapsedMillis(),
} )
} .toMap(),
}.also { )
mainHandler.post(it) mainHandler.postDelayed(this, 1000L)
} }
}
}
.also { mainHandler.post(it) }
} }
private fun stopElapsedTicker() { private fun stopElapsedTicker() {

View File

@@ -1,15 +1,15 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import androidx.camera.view.PreviewView 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.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory import io.flutter.plugin.platform.PlatformViewFactory
class RecordingPreviewFactory( class RecordingPreviewFactory(
private val activity: MainActivity, private val activity: MainActivity,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) { ) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView { override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return RecordingPreviewPlatformView(activity) return RecordingPreviewPlatformView(activity)
@@ -17,13 +17,13 @@ class RecordingPreviewFactory(
} }
class RecordingPreviewPlatformView( class RecordingPreviewPlatformView(
private val activity: MainActivity, private val activity: MainActivity,
) : PlatformView { ) : PlatformView {
val previewView: PreviewView = val previewView: PreviewView =
PreviewView(activity).apply { PreviewView(activity).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER scaleType = PreviewView.ScaleType.FILL_CENTER
} }
init { init {
activity.attachRecordingPreview(previewView) activity.attachRecordingPreview(previewView)

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
import android.content.Context import android.content.Context
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
@@ -8,9 +8,9 @@ object RecordingSession {
fun controller(context: Context): RecordingCameraController { fun controller(context: Context): RecordingCameraController {
return cameraController return cameraController
?: RecordingCameraController(context.applicationContext).also { ?: RecordingCameraController(context.applicationContext).also {
cameraController = it cameraController = it
} }
} }
fun release() { fun release() {

View File

@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording package com.qxy.dronex.recording
enum class RecordingState { enum class RecordingState {
IDLE, IDLE,
@@ -9,16 +9,16 @@ enum class RecordingState {
} }
data class RecordingStatus( data class RecordingStatus(
val state: RecordingState, val state: RecordingState,
val outputPath: String? = null, val outputPath: String? = null,
val elapsedMillis: Long = 0L, val elapsedMillis: Long = 0L,
val message: String? = null, val message: String? = null,
) { ) {
fun toMap(): Map<String, Any?> = fun toMap(): Map<String, Any?> =
mapOf( mapOf(
"state" to state.name.lowercase(), "state" to state.name.lowercase(),
"outputPath" to outputPath, "outputPath" to outputPath,
"elapsedMillis" to elapsedMillis, "elapsedMillis" to elapsedMillis,
"message" to message, "message" to message,
) )
} }

View File

@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin { final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) { static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
name: "com.gdfw.fxjk/platform_info", name: "com.qxy.dronex/platform_info",
binaryMessenger: registrar.messenger() binaryMessenger: registrar.messenger()
) )
let plugin = PlatformInfoPlugin() let plugin = PlatformInfoPlugin()

View File

@@ -142,7 +142,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func initializePreview(result: @escaping FlutterResult) { func initializePreview(result: @escaping FlutterResult) {
guard let previewView else { 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 return
} }
@@ -176,7 +177,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func startRecording(withAudio: Bool, displayName: String?, 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
} }
@@ -306,7 +308,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
if let error { if let error {
latestGallerySaved = false latestGallerySaved = false
latestGalleryErrorMessage = error.localizedDescription latestGalleryErrorMessage = error.localizedDescription
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) updateStatus(
RecordingStatus(
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
finishStopRecording(stopResult: stopResult) finishStopRecording(stopResult: stopResult)
return return
} }
@@ -411,10 +415,14 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return return
} }
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) guard
?? AVCaptureDevice.default(for: .video) let videoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
else { 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) let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice)
@@ -424,14 +432,18 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard session.canAddInput(nextVideoInput) else { guard session.canAddInput(nextVideoInput) else {
session.commitConfiguration() 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) session.addInput(nextVideoInput)
videoInput = nextVideoInput videoInput = nextVideoInput
guard session.canAddOutput(movieOutput) else { guard session.canAddOutput(movieOutput) else {
session.commitConfiguration() 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.addOutput(movieOutput)
session.commitConfiguration() session.commitConfiguration()
@@ -516,7 +528,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
private enum RecordingChannelNames { private enum RecordingChannelNames {
static let packageName = "com.gdfw.fxjk" static let packageName = "com.qxy.dronex"
static let method = "\(packageName)/recording" static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events" 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 eventSink = events
events(controller.currentStatusMap()) events(controller.currentStatusMap())
return nil return nil

View File

@@ -1,3 +1,5 @@
import 'dart:async';
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';
@@ -18,12 +20,31 @@ class AppBootstrapper {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await AppStorage.init(); 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}'); AppLogger.debug('App started in ${AppConfig.current.environment.name}');
runApp(const ProviderScope(child: FlutterTemplateApp())); 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<void> _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,
);
}
} }
} }

View File

@@ -59,7 +59,7 @@ class AppPlatformInfo {
AppPlatformInfo._(); AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel( static const MethodChannel _channel = MethodChannel(
'com.gdfw.fxjk/platform_info', 'com.qxy.dronex/platform_info',
); );
static Future<AppPackageInfo> packageInfo() async { static Future<AppPackageInfo> packageInfo() async {

View File

@@ -1,8 +1,8 @@
/// 小程序复制到剪切板的录制信息。 /// 小程序复制到剪切板的录制信息。
class ClipboardRecordingModel { class ClipboardRecordingModel {
final String title; final String title;
final int startTimestamp; int? startTimestamp;
final int endTimestamp; int? endTimestamp;
final String address; final String address;
/// 录制文件名模板如「选手名称_选手ID_赛事名称_赛项」。 /// 录制文件名模板如「选手名称_选手ID_赛事名称_赛项」。
@@ -10,8 +10,8 @@ class ClipboardRecordingModel {
ClipboardRecordingModel({ ClipboardRecordingModel({
required this.title, required this.title,
required this.startTimestamp, this.startTimestamp,
required this.endTimestamp, this.endTimestamp,
required this.address, required this.address,
this.filename, this.filename,
}); });

View File

@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames { abstract final class RecordingChannelNames {
static const packageName = 'com.gdfw.fxjk'; static const packageName = 'com.qxy.dronex';
static const method = '$packageName/recording'; static const method = '$packageName/recording';
static const events = '$packageName/recording_events'; static const events = '$packageName/recording_events';
} }

View File

@@ -2,13 +2,17 @@ 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_screenutil/flutter_screenutil.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:permission_handler/permission_handler.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_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_saved_dialog.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';
@@ -52,6 +56,69 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
_immersiveApplied = true; _immersiveApplied = true;
} }
String _clipboardHintLabel(RecordingModel recordingInfo) {
if (!recordingInfo.hasValidClipboardInfo) return '';
final clip = recordingInfo.clipboardRecordingModel;
final lines = <String>[];
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<void> _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<void> _exitRecordingMode() async { Future<void> _exitRecordingMode() async {
if (!_immersiveApplied) return; if (!_immersiveApplied) return;
await ref.read(recordingSessionControllerProvider.notifier).teardown(); await ref.read(recordingSessionControllerProvider.notifier).teardown();
@@ -100,6 +167,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
const CameraPreviewWidget(), const CameraPreviewWidget(),
if (!state.isPreviewReady && state.errorMessage == null)
const _RecordingLoadingOverlay(message: '正在启动相机…'),
if (state.isTouchLocked && state.isRecording) if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlay( RecordingTouchLockOverlay(
enabled: true, enabled: true,
@@ -109,6 +178,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
state: state, state: state,
eventTitle: showClipboardInfo ? clipboard.title : null, eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null, eventAddress: showClipboardInfo ? clipboard.address : null,
clipboardHintLabel: _clipboardHintLabel(recordingInfo),
onPasteEventInfo: () async { onPasteEventInfo: () async {
final result = await ref final result = await ref
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
@@ -125,7 +195,9 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
final latest = ref.read(recordingSessionControllerProvider); final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) { if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限'); AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
return;
} }
await _showRecordingSavedDialogIfNeeded();
}, },
onOpenDnd: () async { onOpenDnd: () async {
await controller.openDndSettings(); await controller.openDndSettings();
@@ -139,6 +211,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
controller.setTouchLocked(!state.isTouchLocked); 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, required this.state,
this.eventTitle, this.eventTitle,
this.eventAddress, this.eventAddress,
this.clipboardHintLabel,
required this.onPasteEventInfo, required this.onPasteEventInfo,
required this.onStart, required this.onStart,
required this.onStop, required this.onStop,
@@ -162,6 +269,7 @@ class _RecordingHud extends StatelessWidget {
final RecordingSessionState state; final RecordingSessionState state;
final String? eventTitle; final String? eventTitle;
final String? eventAddress; final String? eventAddress;
final String? clipboardHintLabel;
final Future<void> Function() onPasteEventInfo; final Future<void> Function() onPasteEventInfo;
final VoidCallback onStart; final VoidCallback onStart;
final VoidCallback onStop; final VoidCallback onStop;
@@ -222,6 +330,7 @@ class _RecordingHud extends StatelessWidget {
hasDndAccess: state.hasDndAccess, hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored, isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted, notificationsGranted: state.notificationsGranted,
clipboardHintLabel: clipboardHintLabel,
onOpenDnd: onOpenDnd, onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery, onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings, onOpenNotificationSettings: openAppSettings,
@@ -249,13 +358,18 @@ class _RecordingHud extends StatelessWidget {
Expanded( Expanded(
child: Center( child: Center(
child: GestureDetector( child: GestureDetector(
onTap: state.isRecording ? onStop : onStart, onTap: state.isStartingRecording
? null
: (state.isRecording ? onStop : onStart),
child: Container( child: Container(
width: 76.w, width: 76.w,
height: 76.h, height: 76.h,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4.r), border: Border.all(
color: Colors.white,
width: 4.r,
),
color: state.isRecording color: state.isRecording
? Colors.white ? Colors.white
: Colors.red, : 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) if (showPasteEventInfo)
@@ -324,9 +427,7 @@ class _RecordingHud extends StatelessWidget {
left: 12.w, left: 12.w,
right: 12.w, right: 12.w,
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(right: state.isRecording ? 96.w : 0),
right: state.isRecording ? 96.w : 0,
),
child: Text( child: Text(
eventTitle!, eventTitle!,
style: _overlayTextStyle.copyWith( style: _overlayTextStyle.copyWith(
@@ -344,10 +445,7 @@ class _RecordingHud extends StatelessWidget {
top: 8.r, top: 8.r,
right: 12.w, right: 12.w,
child: Container( child: Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 6.r),
horizontal: 12.r,
vertical: 6.r,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: Colors.red,
borderRadius: BorderRadius.circular(20.r), borderRadius: BorderRadius.circular(20.r),
@@ -361,21 +459,21 @@ class _RecordingHud extends StatelessWidget {
), ),
), ),
), ),
if (eventAddress != null && eventAddress!.isNotEmpty) // if (eventAddress != null && eventAddress!.isNotEmpty)
Positioned( // Positioned(
left: 16.w, // left: 16.w,
bottom: 108.r, // bottom: 108.r,
right: 120.w, // right: 120.w,
child: Text( // child: Text(
eventAddress!, // eventAddress!,
style: _overlayTextStyle.copyWith( // style: _overlayTextStyle.copyWith(
fontSize: 13.sp, // fontSize: 13.sp,
color: Colors.white70, // color: Colors.white70,
), // ),
maxLines: 2, // maxLines: 2,
overflow: TextOverflow.ellipsis, // overflow: TextOverflow.ellipsis,
), // ),
), // ),
], ],
), ),
); );
@@ -387,6 +485,7 @@ class _SetupHints extends StatelessWidget {
required this.hasDndAccess, required this.hasDndAccess,
required this.isBatteryIgnored, required this.isBatteryIgnored,
required this.notificationsGranted, required this.notificationsGranted,
this.clipboardHintLabel,
required this.onOpenDnd, required this.onOpenDnd,
required this.onOpenBattery, required this.onOpenBattery,
required this.onOpenNotificationSettings, required this.onOpenNotificationSettings,
@@ -395,13 +494,18 @@ class _SetupHints extends StatelessWidget {
final bool hasDndAccess; final bool hasDndAccess;
final bool isBatteryIgnored; final bool isBatteryIgnored;
final bool notificationsGranted; final bool notificationsGranted;
final String? clipboardHintLabel;
final VoidCallback onOpenDnd; final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery; final VoidCallback onOpenBattery;
final VoidCallback onOpenNotificationSettings; final VoidCallback onOpenNotificationSettings;
@override @override
Widget build(BuildContext context) { 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(); return const SizedBox.shrink();
} }
@@ -422,6 +526,11 @@ class _SetupHints extends StatelessWidget {
SizedBox(height: 8.h), SizedBox(height: 8.h),
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery), _HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
], ],
if (clipboardHintLabel != null &&
clipboardHintLabel!.isNotEmpty) ...[
SizedBox(height: 8.h),
_HintChip(label: clipboardHintLabel!, onTap: () {}),
],
], ],
), ),
); );

View File

@@ -14,6 +14,7 @@ class RecordingSessionState {
this.status = const RecordingStatus(state: RecordingState.idle), this.status = const RecordingStatus(state: RecordingState.idle),
this.isTouchLocked = true, this.isTouchLocked = true,
this.isPreviewReady = false, this.isPreviewReady = false,
this.isStartingRecording = false,
this.hasDndAccess = false, this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true, this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true, this.notificationsGranted = true,
@@ -28,6 +29,7 @@ class RecordingSessionState {
final RecordingStatus status; final RecordingStatus status;
final bool isTouchLocked; final bool isTouchLocked;
final bool isPreviewReady; final bool isPreviewReady;
final bool isStartingRecording;
final bool hasDndAccess; final bool hasDndAccess;
final bool isBatteryOptimizedIgnored; final bool isBatteryOptimizedIgnored;
final bool notificationsGranted; final bool notificationsGranted;
@@ -51,6 +53,7 @@ class RecordingSessionState {
RecordingStatus? status, RecordingStatus? status,
bool? isTouchLocked, bool? isTouchLocked,
bool? isPreviewReady, bool? isPreviewReady,
bool? isStartingRecording,
bool? hasDndAccess, bool? hasDndAccess,
bool? isBatteryOptimizedIgnored, bool? isBatteryOptimizedIgnored,
bool? notificationsGranted, bool? notificationsGranted,
@@ -67,6 +70,7 @@ class RecordingSessionState {
status: status ?? this.status, status: status ?? this.status,
isTouchLocked: isTouchLocked ?? this.isTouchLocked, isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady, isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
hasDndAccess: hasDndAccess ?? this.hasDndAccess, hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored: isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -204,11 +208,16 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
} }
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 ||
state.isStartingRecording) {
return;
}
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel; final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename); final displayName = recordingFileNameForPlatform(clipboard.filename);
state = state.copyWith(isStartingRecording: true, errorMessage: null);
try { try {
final result = await RecordingPlatform.startRecording( final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess, enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
@@ -224,6 +233,8 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败'); state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
} finally {
state = state.copyWith(isStartingRecording: false);
} }
} }
@@ -254,6 +265,10 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
state = state.copyWith(isTouchLocked: locked); state = state.copyWith(isTouchLocked: locked);
} }
void clearSavedRecordingResult() {
state = state.copyWith(clearLastSaved: true);
}
Future<void> openDndSettings() => Future<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings(); RecordingPlatform.openNotificationPolicySettings();

View File

@@ -89,6 +89,10 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
} }
} }
void resetClipboardInfo() {
_resetClipboardInfo();
}
void _resetClipboardInfo() { void _resetClipboardInfo() {
state = state.copyWith( state = state.copyWith(
clipboardRecordingModel: _defaultClipboard, clipboardRecordingModel: _defaultClipboard,

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 录制结束并保存到相册后的后续操作弹窗。
Future<void> showRecordingSavedDialog(
BuildContext context, {
required String sessionTitle,
required VoidCallback onContinueRound,
required VoidCallback onRecordNewRound,
}) {
return showDialog<void>(
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)),
);
}
}