完成录制功能

This commit is contained in:
2026-06-03 16:04:52 +08:00
parent 9eb8d1cc37
commit fb61e28e2f
20 changed files with 1788 additions and 17 deletions

View File

@@ -39,6 +39,17 @@ android {
} }
} }
dependencies {
val cameraxVersion = "1.4.1"
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-video:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.core:core-ktx:1.15.0")
}
flutter { flutter {
source = "../.." source = "../.."
} }

View File

@@ -1,6 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<application <application
android:label="flutter_template" android:label="飞行极控"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
@@ -12,10 +26,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<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"
@@ -25,17 +35,16 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> <service
android:name=".recording.RecordingForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>

View File

@@ -1,5 +1,47 @@
package com.example.flutter_template package com.example.flutter_template
import androidx.camera.view.PreviewView
import com.example.flutter_template.recording.RecordingPlatformHandler
import com.example.flutter_template.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
private var platformHandler: RecordingPlatformHandler? = null
var recordingPreviewView: PreviewView? = null
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine
.platformViewsController
.registry
.registerViewFactory(
"recording-camera-preview",
RecordingPreviewFactory(this),
)
platformHandler =
RecordingPlatformHandler(
this,
flutterEngine.dartExecutor.binaryMessenger,
)
}
fun attachRecordingPreview(previewView: PreviewView) {
recordingPreviewView = previewView
}
fun detachRecordingPreview(previewView: PreviewView? = null) {
if (previewView == null || recordingPreviewView === previewView) {
recordingPreviewView = null
}
}
override fun onDestroy() {
platformHandler?.dispose()
platformHandler = null
super.onDestroy()
}
}

View File

@@ -0,0 +1,37 @@
package com.example.flutter_template.recording
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
object BatteryOptimizationHelper {
fun isIgnoringOptimizations(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val manager = context.getSystemService(PowerManager::class.java) ?: return true
return manager.isIgnoringBatteryOptimizations(context.packageName)
}
fun openSettings(context: Context) {
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)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
return
}
val fallback =
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(fallback)
}
}

View File

@@ -0,0 +1,48 @@
package com.example.flutter_template.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
object DoNotDisturbHelper {
private var savedInterruptionFilter: Int? = null
fun hasAccess(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java)
return manager?.isNotificationPolicyAccessGranted == true
}
fun openAccessSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
fun enable(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
if (!manager.isNotificationPolicyAccessGranted) return false
if (savedInterruptionFilter == null) {
savedInterruptionFilter = manager.currentInterruptionFilter
}
manager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_NONE)
return true
}
fun disable(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (!manager.isNotificationPolicyAccessGranted) return
val previous = savedInterruptionFilter ?: NotificationManager.INTERRUPTION_FILTER_ALL
manager.setInterruptionFilter(previous)
savedInterruptionFilter = null
}
fun areNotificationsEnabled(context: Context): Boolean {
return NotificationManagerCompat.from(context).areNotificationsEnabled()
}
}

View File

@@ -0,0 +1,242 @@
package com.example.flutter_template.recording
import android.content.Context
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executor
class RecordingCameraController(
private val appContext: Context,
) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
private var cameraProvider: ProcessCameraProvider? = null
private var preview: Preview? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
private set
var statusListener: ((RecordingStatus) -> Unit)? = null
private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null
fun bindPreview(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val future = ProcessCameraProvider.getInstance(appContext)
future.addListener(
{
try {
val provider = future.get()
cameraProvider = provider
boundLifecycleOwner = lifecycleOwner
preview =
Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
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,
)
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,
) {
val provider = cameraProvider
if (provider == null) {
bindPreview(lifecycleOwner, previewView, onReady)
return
}
try {
boundLifecycleOwner = lifecycleOwner
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
onReady(true)
} catch (error: Exception) {
Log.e(TAG, "rebindForRecording failed", error)
onReady(false)
}
}
fun startRecording(
withAudio: Boolean,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
onStarted(false, "Camera not ready")
return
}
if (activeRecording != null) {
onStarted(false, "Already recording")
return
}
val outputFile = createOutputFile()
latestOutputPath = outputFile.absolutePath
val outputOptions = FileOutputOptions.Builder(outputFile).build()
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()
}
}
}
recordingStartedAt = System.currentTimeMillis()
updateStatus(
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 {
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis = System.currentTimeMillis() - recordingStartedAt,
),
)
}
}
}
}
onStarted(true, latestOutputPath)
}
fun stopRecording(onStopped: (String?) -> Unit) {
val recording = activeRecording
if (recording == null) {
onStopped(latestOutputPath)
return
}
updateStatus(
RecordingStatus(
RecordingState.STOPPING,
outputPath = latestOutputPath,
),
)
recording.stop()
activeRecording = null
onStopped(latestOutputPath)
}
fun unbind() {
activeRecording?.stop()
activeRecording = null
cameraProvider?.unbindAll()
cameraProvider = null
preview = null
videoCapture = null
boundLifecycleOwner = null
updateStatus(RecordingStatus(RecordingState.IDLE))
}
fun elapsedMillis(): Long {
if (status.state != RecordingState.RECORDING) return 0L
return System.currentTimeMillis() - recordingStartedAt
}
private fun createOutputFile(): File {
val moviesDir = File(appContext.getExternalFilesDir(null), "recordings")
if (!moviesDir.exists()) {
moviesDir.mkdirs()
}
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return File(moviesDir, "REC_$timestamp.mp4")
}
private fun updateStatus(next: RecordingStatus) {
status = next
statusListener?.invoke(next)
}
companion object {
private const val TAG = "RecordingCamera"
}
}

View File

@@ -0,0 +1,182 @@
package com.example.flutter_template.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.lifecycle.LifecycleService
import com.example.flutter_template.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
instance = this
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_START -> {
acquireWakeLock()
val notification = buildNotification("正在录制")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
foregroundServiceTypes(),
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
isRunning = true
}
ACTION_STOP -> {
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
isRunning = false
stopSelf()
}
}
return START_STICKY
}
override fun onDestroy() {
releaseWakeLock()
isRunning = false
if (instance === this) {
instance = null
}
super.onDestroy()
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return null
}
private fun acquireWakeLock() {
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)
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
}
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)
}
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
private fun foregroundServiceTypes(): Int {
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
if (hasRecordAudioPermission()) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
return types
}
private fun hasRecordAudioPermission(): Boolean {
return ContextCompat.checkSelfPermission(
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
}
val pendingIntent =
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()
}
companion object {
const val CHANNEL_ID = "recording_foreground"
const val NOTIFICATION_ID = 1001
const val ACTION_START = "com.example.flutter_template.recording.START"
const val ACTION_STOP = "com.example.flutter_template.recording.STOP"
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
@Volatile
var isRunning: Boolean = false
@Volatile
var instance: RecordingForegroundService? = null
fun start(context: Context) {
val intent =
Intent(context, RecordingForegroundService::class.java).apply {
action = ACTION_START
}
ContextCompatStart.startForegroundService(context, intent)
}
fun stop(context: Context) {
val intent =
Intent(context, RecordingForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
}
private object ContextCompatStart {
fun startForegroundService(context: Context, intent: Intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}

View File

@@ -0,0 +1,228 @@
package com.example.flutter_template.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.example.flutter_template.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,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private val methodChannel =
MethodChannel(messenger, "com.example.flutter_template/recording")
private val eventChannel =
EventChannel(messenger, "com.example.flutter_template/recording_events")
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
private var elapsedTicker: Runnable? = null
private val controller by lazy { RecordingSession.controller(activity.applicationContext) }
init {
methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this)
controller.statusListener = { status ->
mainHandler.post {
eventSink?.success(status.toMap())
}
}
}
fun dispose() {
stopElapsedTicker()
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
controller.statusListener = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initializePreview" -> initializePreview(result)
"startRecording" -> {
val withAudio = call.argument<Boolean>("withAudio") ?: true
val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: true
startRecording(withAudio, enableDnd, result)
}
"stopRecording" -> stopRecording(result)
"disposePreview" -> {
controller.unbind()
result.success(null)
}
"hasNotificationPolicyAccess" ->
result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity)
result.success(null)
}
"enableDoNotDisturb" ->
result.success(DoNotDisturbHelper.enable(activity))
"disableDoNotDisturb" -> {
DoNotDisturbHelper.disable(activity)
result.success(null)
}
"isIgnoringBatteryOptimizations" ->
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
"openBatteryOptimizationSettings" -> {
BatteryOptimizationHelper.openSettings(activity)
result.success(null)
}
"setImmersiveMode" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
setImmersiveMode(enabled)
result.success(null)
}
"getStatus" -> result.success(controller.status.toMap())
"isForegroundServiceRunning" ->
result.success(RecordingForegroundService.isRunning)
else -> result.notImplemented()
}
}
private fun initializePreview(result: MethodChannel.Result) {
val previewView = activity.recordingPreviewView
if (previewView == null) {
result.error("NO_PREVIEW", "Camera preview is not attached", null)
return
}
controller.bindPreview(activity, previewView) { ready ->
mainHandler.post {
if (ready) {
result.success(controller.status.toMap())
} else {
result.error("PREVIEW_FAILED", "Failed to bind camera preview", null)
}
}
}
}
private fun startRecording(
withAudio: Boolean,
enableDnd: Boolean,
result: MethodChannel.Result,
) {
val previewView = activity.recordingPreviewView
if (previewView == null) {
result.error("NO_PREVIEW", "Camera preview is not attached", null)
return
}
RecordingSession.startForeground(activity)
fun beginCapture() {
if (enableDnd && DoNotDisturbHelper.hasAccess(activity)) {
DoNotDisturbHelper.enable(activity)
}
controller.startRecording(withAudio) { started, message ->
mainHandler.post {
if (started) {
startElapsedTicker()
result.success(
mapOf(
"outputPath" to message,
"status" to controller.status.toMap(),
),
)
} else {
RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity)
result.error("START_FAILED", message, null)
}
}
}
}
fun rebindAndCapture() {
val lifecycleOwner =
RecordingForegroundService.instance ?: activity
controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
if (ready) {
beginCapture()
} else {
RecordingSession.stopForeground(activity)
result.error("REBIND_FAILED", "Failed to bind camera for recording", null)
}
}
}
if (RecordingForegroundService.instance != null) {
rebindAndCapture()
} else {
mainHandler.post { rebindAndCapture() }
}
}
private fun stopRecording(result: MethodChannel.Result) {
stopElapsedTicker()
controller.stopRecording { path ->
RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity)
mainHandler.post {
result.success(
mapOf(
"outputPath" to path,
"status" to controller.status.toMap(),
),
)
}
}
}
private fun setImmersiveMode(enabled: Boolean) {
val window = activity.window
WindowCompat.setDecorFitsSystemWindows(window, !enabled)
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (enabled) {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
}
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)
}
}
private fun stopElapsedTicker() {
elapsedTicker?.let { mainHandler.removeCallbacks(it) }
elapsedTicker = null
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
events?.success(controller.status.toMap())
}
override fun onCancel(arguments: Any?) {
eventSink = null
stopElapsedTicker()
}
}

View File

@@ -0,0 +1,37 @@
package com.example.flutter_template.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.example.flutter_template.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,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return RecordingPreviewPlatformView(activity)
}
}
class RecordingPreviewPlatformView(
private val activity: MainActivity,
) : PlatformView {
val previewView: PreviewView =
PreviewView(activity).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
init {
activity.attachRecordingPreview(previewView)
}
override fun getView(): View = previewView
override fun dispose() {
activity.detachRecordingPreview(previewView)
}
}

View File

@@ -0,0 +1,30 @@
package com.example.flutter_template.recording
import android.content.Context
import androidx.lifecycle.LifecycleService
object RecordingSession {
private var cameraController: RecordingCameraController? = null
fun controller(context: Context): RecordingCameraController {
return cameraController
?: RecordingCameraController(context.applicationContext).also {
cameraController = it
}
}
fun release() {
cameraController?.unbind()
cameraController = null
}
fun startForeground(context: Context) {
RecordingForegroundService.start(context)
}
fun stopForeground(context: Context) {
RecordingForegroundService.stop(context)
}
fun recordingLifecycleOwner(): LifecycleService? = RecordingForegroundService.instance
}

View File

@@ -0,0 +1,24 @@
package com.example.flutter_template.recording
enum class RecordingState {
IDLE,
PREVIEWING,
RECORDING,
STOPPING,
ERROR,
}
data class RecordingStatus(
val state: RecordingState,
val outputPath: String? = null,
val elapsedMillis: Long = 0L,
val message: String? = null,
) {
fun toMap(): Map<String, Any?> =
mapOf(
"state" to state.name.lowercase(),
"outputPath" to outputPath,
"elapsedMillis" to elapsedMillis,
"message" to message,
)
}

View File

@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Flutter Template</string> <string>飞行极控</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>flutter_template</string> <string>飞行极控</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@@ -20,7 +20,7 @@ class AppConfig {
static late EnvironmentValues current; static late EnvironmentValues current;
static PackageInfo? packageInfo; static PackageInfo? packageInfo;
static const appName = 'Flutter Template'; static const appName = '飞行极控';
static void configure({ static void configure({
required AppEnvironment environment, required AppEnvironment environment,

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/app/config/app_config.dart';
import 'package:flutter_template/app/theme/app_theme.dart'; import 'package:flutter_template/app/theme/app_theme.dart';
import 'package:flutter_template/features/demo/demo_controller.dart'; import 'package:flutter_template/features/demo/demo_controller.dart';
import 'package:flutter_template/features/recording/recording_page.dart';
import 'package:flutter_template/shared/widgets/widgets.dart'; import 'package:flutter_template/shared/widgets/widgets.dart';
class DemoPage extends ConsumerWidget { class DemoPage extends ConsumerWidget {
@@ -13,7 +15,7 @@ class DemoPage extends ConsumerWidget {
final controller = ref.read(demoControllerProvider.notifier); final controller = ref.read(demoControllerProvider.notifier);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Flutter Template')), appBar: AppBar(title: const Text(AppConfig.appName)),
body: SafeAreaWrapper( body: SafeAreaWrapper(
child: ListView( child: ListView(
padding: const EdgeInsets.all(AppSpacing.lg), padding: const EdgeInsets.all(AppSpacing.lg),
@@ -85,6 +87,18 @@ class DemoPage extends ConsumerWidget {
), ),
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
AppButton(
label: '打开录制',
icon: const Icon(Icons.videocam, size: 18),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const RecordingPage(),
),
);
},
),
const SizedBox(height: AppSpacing.lg),
AppStatusView( AppStatusView(
status: AppViewStatus.empty, status: AppViewStatus.empty,
empty: AppEmptyView( empty: AppEmptyView(

View File

@@ -0,0 +1,332 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/features/recording/recording_platform.dart';
import 'package:flutter_template/features/recording/recording_session_controller.dart';
import 'package:flutter_template/features/recording/widgets/camera_preview_widget.dart';
import 'package:flutter_template/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:flutter_template/shared/widgets/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key});
@override
ConsumerState<RecordingPage> createState() => _RecordingPageState();
}
class _RecordingPageState extends ConsumerState<RecordingPage> {
var _immersiveApplied = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
}
Future<void> _bootstrap() async {
await _enterRecordingMode();
// Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
await ref.read(recordingSessionControllerProvider.notifier).prepareSession();
}
Future<void> _enterRecordingMode() async {
if (!Platform.isAndroid) return;
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
await RecordingPlatform.setImmersiveMode(enabled: true);
_immersiveApplied = true;
}
Future<void> _exitRecordingMode() async {
if (!_immersiveApplied) return;
await ref.read(recordingSessionControllerProvider.notifier).teardown();
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
await RecordingPlatform.setImmersiveMode(enabled: false);
_immersiveApplied = false;
}
@override
void dispose() {
if (_immersiveApplied) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
RecordingPlatform.setImmersiveMode(enabled: false);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(recordingSessionControllerProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier);
return PopScope(
canPop: !state.isRecording,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
await _exitRecordingMode();
return;
}
if (state.isRecording) {
AppToast.show('录制中无法返回,请先停止录制');
}
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
const CameraPreviewWidget(),
if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlay(
enabled: true,
onUnlocked: () => controller.setTouchLocked(false),
),
_RecordingHud(
state: state,
onStart: () => controller.startRecording(),
onStop: () => controller.stopRecording(),
onOpenDnd: () async {
await controller.openDndSettings();
await controller.refreshDndAccess();
},
onOpenBattery: () async {
await controller.openBatterySettings();
await controller.refreshBatteryOptimization();
},
onToggleTouchLock: () {
controller.setTouchLocked(!state.isTouchLocked);
},
),
],
),
),
);
}
}
class _RecordingHud extends StatelessWidget {
const _RecordingHud({
required this.state,
required this.onStart,
required this.onStop,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onToggleTouchLock,
});
final RecordingSessionState state;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
IconButton(
onPressed: state.isRecording
? null
: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.close, color: Colors.white),
),
const Spacer(),
if (state.isRecording)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'REC ${state.elapsedLabel}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
const Spacer(),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
state.permissionWarning!,
style: const TextStyle(color: Colors.orangeAccent, fontSize: 12),
textAlign: TextAlign.center,
),
),
_SetupHints(
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (state.isRecording)
IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: state.isRecording ? onStop : onStart,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
color: state.isRecording ? Colors.white : Colors.red,
),
child: Icon(
state.isRecording ? Icons.stop : Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white,
size: 36,
),
),
),
const SizedBox(width: 48),
],
),
),
if (state.lastOutputPath != null && !state.isRecording)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'已保存:${state.lastOutputPath}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
);
}
}
class _SetupHints extends StatelessWidget {
const _SetupHints({
required this.hasDndAccess,
required this.isBatteryIgnored,
required this.notificationsGranted,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onOpenNotificationSettings,
});
final bool hasDndAccess;
final bool isBatteryIgnored;
final bool notificationsGranted;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onOpenNotificationSettings;
@override
Widget build(BuildContext context) {
if (hasDndAccess && isBatteryIgnored && notificationsGranted) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
if (!notificationsGranted) ...[
_HintChip(
label: '开启通知权限以显示录制前台服务',
onTap: onOpenNotificationSettings,
),
const SizedBox(height: 8),
],
if (!hasDndAccess)
_HintChip(
label: '开启勿扰权限可减少录制中断',
onTap: onOpenDnd,
),
if (!isBatteryIgnored) ...[
const SizedBox(height: 8),
_HintChip(
label: '关闭电池优化可提升息屏续录稳定性',
onTap: onOpenBattery,
),
],
],
),
);
}
}
class _HintChip extends StatelessWidget {
const _HintChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
const Icon(Icons.chevron_right, color: Colors.white54, size: 18),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
enum RecordingState {
idle,
previewing,
recording,
stopping,
error;
static RecordingState fromRaw(String? raw) {
return RecordingState.values.firstWhere(
(value) => value.name == raw,
orElse: () => RecordingState.idle,
);
}
}
class RecordingStatus {
const RecordingStatus({
required this.state,
this.outputPath,
this.elapsedMillis = 0,
this.message,
});
final RecordingState state;
final String? outputPath;
final int elapsedMillis;
final String? message;
factory RecordingStatus.fromMap(Map<dynamic, dynamic> map) {
return RecordingStatus(
state: RecordingState.fromRaw(map['state'] as String?),
outputPath: map['outputPath'] as String?,
elapsedMillis: (map['elapsedMillis'] as num?)?.toInt() ?? 0,
message: map['message'] as String?,
);
}
bool get isRecording => state == RecordingState.recording;
}
class RecordingPlatform {
RecordingPlatform._();
static const MethodChannel _channel = MethodChannel(
'com.example.flutter_template/recording',
);
static const EventChannel _events = EventChannel(
'com.example.flutter_template/recording_events',
);
static bool get isSupported => Platform.isAndroid;
static Stream<RecordingStatus>? _statusStream;
static Stream<RecordingStatus> statusStream() {
if (!isSupported) {
return const Stream.empty();
}
_statusStream ??= _events
.receiveBroadcastStream()
.map((event) => RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)));
return _statusStream!;
}
static Future<RecordingStatus> initializePreview() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'initializePreview',
);
return RecordingStatus.fromMap(result ?? const {});
}
static Future<RecordingStartResult> startRecording({
bool withAudio = true,
bool enableDoNotDisturb = true,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'startRecording',
<String, dynamic>{
'withAudio': withAudio,
'enableDoNotDisturb': enableDoNotDisturb,
},
);
return RecordingStartResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
}
static Future<RecordingStopResult> stopRecording() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'stopRecording',
);
return RecordingStopResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
}
static Future<void> disposePreview() => _channel.invokeMethod('disposePreview');
static Future<bool> hasNotificationPolicyAccess() async {
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
false;
}
static Future<void> openNotificationPolicySettings() {
return _channel.invokeMethod('openNotificationPolicySettings');
}
static Future<bool> enableDoNotDisturb() async {
return await _channel.invokeMethod<bool>('enableDoNotDisturb') ?? false;
}
static Future<void> disableDoNotDisturb() {
return _channel.invokeMethod('disableDoNotDisturb');
}
static Future<bool> isIgnoringBatteryOptimizations() async {
return await _channel.invokeMethod<bool>(
'isIgnoringBatteryOptimizations',
) ??
true;
}
static Future<void> openBatteryOptimizationSettings() {
return _channel.invokeMethod('openBatteryOptimizationSettings');
}
static Future<void> setImmersiveMode({required bool enabled}) {
return _channel.invokeMethod(
'setImmersiveMode',
<String, dynamic>{'enabled': enabled},
);
}
static Future<RecordingStatus> getStatus() async {
final result = await _channel.invokeMapMethod<String, dynamic>('getStatus');
return RecordingStatus.fromMap(result ?? const {});
}
}
class RecordingStartResult {
const RecordingStartResult({this.outputPath, required this.status});
final String? outputPath;
final RecordingStatus status;
}
class RecordingStopResult {
const RecordingStopResult({this.outputPath, required this.status});
final String? outputPath;
final RecordingStatus status;
}

View File

@@ -0,0 +1,243 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/features/recording/recording_platform.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingSessionState {
const RecordingSessionState({
this.status = const RecordingStatus(state: RecordingState.idle),
this.isTouchLocked = true,
this.isPreviewReady = false,
this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
this.isMicrophoneGranted = false,
this.lastOutputPath,
this.errorMessage,
this.permissionWarning,
});
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
final bool isMicrophoneGranted;
final String? lastOutputPath;
final String? errorMessage;
final String? permissionWarning;
bool get isRecording => status.isRecording;
String get elapsedLabel {
final totalSeconds = status.elapsedMillis ~/ 1000;
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
RecordingSessionState copyWith({
RecordingStatus? status,
bool? isTouchLocked,
bool? isPreviewReady,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
bool? isMicrophoneGranted,
String? lastOutputPath,
String? errorMessage,
String? permissionWarning,
bool clearPermissionWarning = false,
}) {
return RecordingSessionState(
status: status ?? this.status,
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
errorMessage: errorMessage,
permissionWarning: clearPermissionWarning
? null
: (permissionWarning ?? this.permissionWarning),
);
}
}
final recordingSessionControllerProvider =
NotifierProvider<RecordingSessionController, RecordingSessionState>(
RecordingSessionController.new,
);
class RecordingSessionController extends Notifier<RecordingSessionState> {
StreamSubscription<RecordingStatus>? _statusSubscription;
@override
RecordingSessionState build() {
ref.onDispose(_dispose);
return const RecordingSessionState();
}
Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) {
state = state.copyWith(errorMessage: '仅支持 Android 录制');
return;
}
final permissions = await <Permission>[
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
].request();
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
if (!cameraGranted) {
state = state.copyWith(errorMessage: '需要相机权限才能录制');
return;
}
final microphoneGranted =
permissions[Permission.microphone]?.isGranted ?? false;
final notificationsGranted = Platform.isAndroid
? (permissions[Permission.notification]?.isGranted ?? false)
: true;
final warnings = <String>[];
if (Platform.isAndroid && !notificationsGranted) {
warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制');
}
if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
await RecordingPlatform.isIgnoringBatteryOptimizations();
state = state.copyWith(
hasDndAccess: hasDnd,
isBatteryOptimizedIgnored: batteryIgnored,
isMicrophoneGranted: microphoneGranted,
notificationsGranted: notificationsGranted,
permissionWarning: warnings.isEmpty ? null : warnings.join('\n'),
errorMessage: null,
clearPermissionWarning: warnings.isEmpty,
);
await _listenStatus();
try {
final status = await _initializePreviewWithRetry();
state = state.copyWith(
status: status,
isPreviewReady: status.state == RecordingState.previewing,
errorMessage: status.state == RecordingState.previewing
? null
: (status.message ?? '相机预览初始化失败'),
);
} on PlatformException catch (error) {
state = state.copyWith(
isPreviewReady: false,
errorMessage: error.message ?? '相机预览初始化失败',
);
}
}
Future<RecordingStatus> _initializePreviewWithRetry() async {
const maxAttempts = 8;
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await RecordingPlatform.initializePreview();
} on PlatformException catch (error) {
final shouldRetry =
error.code == 'NO_PREVIEW' && attempt < maxAttempts - 1;
if (!shouldRetry) {
rethrow;
}
await Future<void>.delayed(
Duration(milliseconds: 150 * (attempt + 1)),
);
}
}
throw StateError('initializePreview retry exhausted');
}
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!state.isPreviewReady || state.isRecording) return;
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
);
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath,
isTouchLocked: true,
errorMessage: null,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
}
}
Future<void> stopRecording() async {
if (!state.isRecording) return;
try {
final result = await RecordingPlatform.stopRecording();
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath ?? state.lastOutputPath,
errorMessage: null,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');
}
}
void setTouchLocked(bool locked) {
state = state.copyWith(isTouchLocked: locked);
}
Future<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();
Future<void> refreshDndAccess() async {
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
state = state.copyWith(hasDndAccess: hasDnd);
}
Future<void> openBatterySettings() =>
RecordingPlatform.openBatteryOptimizationSettings();
Future<void> refreshBatteryOptimization() async {
final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations();
state = state.copyWith(isBatteryOptimizedIgnored: ignored);
}
Future<void> teardown() async {
await RecordingPlatform.setImmersiveMode(enabled: false);
await RecordingPlatform.disableDoNotDisturb();
await RecordingPlatform.disposePreview();
await _statusSubscription?.cancel();
_statusSubscription = null;
state = const RecordingSessionState();
}
Future<void> _listenStatus() async {
await _statusSubscription?.cancel();
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
state = state.copyWith(status: status);
});
}
Future<void> _dispose() async {
await _statusSubscription?.cancel();
}
}

View File

@@ -0,0 +1,25 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CameraPreviewWidget extends StatelessWidget {
const CameraPreviewWidget({super.key});
@override
Widget build(BuildContext context) {
if (!Platform.isAndroid) {
return const ColoredBox(
color: Colors.black,
child: Center(child: Text('仅 Android 支持相机预览')),
);
}
return AndroidView(
viewType: 'recording-camera-preview',
layoutDirection: TextDirection.ltr,
creationParams: const <String, dynamic>{},
creationParamsCodec: const StandardMessageCodec(),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
class RecordingTouchLockOverlay extends StatefulWidget {
const RecordingTouchLockOverlay({
super.key,
required this.enabled,
required this.onUnlocked,
this.unlockHoldDuration = const Duration(seconds: 2),
});
final bool enabled;
final VoidCallback onUnlocked;
final Duration unlockHoldDuration;
@override
State<RecordingTouchLockOverlay> createState() =>
_RecordingTouchLockOverlayState();
}
class _RecordingTouchLockOverlayState extends State<RecordingTouchLockOverlay> {
Timer? _holdTimer;
bool _isHolding = false;
@override
void didUpdateWidget(RecordingTouchLockOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.enabled) {
_cancelHold();
}
}
@override
void dispose() {
_cancelHold();
super.dispose();
}
void _cancelHold() {
_holdTimer?.cancel();
_holdTimer = null;
_isHolding = false;
}
void _startHold() {
if (!widget.enabled) return;
setState(() => _isHolding = true);
_holdTimer?.cancel();
_holdTimer = Timer(widget.unlockHoldDuration, () {
if (!mounted) return;
_cancelHold();
widget.onUnlocked();
setState(() {});
});
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) => _startHold(),
onPointerUp: (_) => _cancelHold(),
onPointerCancel: (_) => _cancelHold(),
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.01),
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 48),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(24),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
_isHolding
? '保持按住 ${widget.unlockHoldDuration.inSeconds}s 解锁…'
: '防误触已开启,按住 ${widget.unlockHoldDuration.inSeconds}s 解锁',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
),
),
),
),
),
);
}
}