From fb61e28e2fc4402fb212bd894797f270fdfbcdc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Wed, 3 Jun 2026 16:04:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=BD=95=E5=88=B6=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 11 + android/app/src/main/AndroidManifest.xml | 33 +- .../example/flutter_template/MainActivity.kt | 44 ++- .../recording/BatteryOptimizationHelper.kt | 37 ++ .../recording/DoNotDisturbHelper.kt | 48 +++ .../recording/RecordingCameraController.kt | 242 +++++++++++++ .../recording/RecordingForegroundService.kt | 182 ++++++++++ .../recording/RecordingPlatformHandler.kt | 228 ++++++++++++ .../recording/RecordingPreviewFactory.kt | 37 ++ .../recording/RecordingSession.kt | 30 ++ .../recording/RecordingState.kt | 24 ++ android/gradle.properties | 4 + ios/Runner/Info.plist | 4 +- lib/app/config/app_config.dart | 2 +- lib/features/demo/demo_page.dart | 16 +- lib/features/recording/recording_page.dart | 332 ++++++++++++++++++ .../recording/recording_platform.dart | 163 +++++++++ .../recording_session_controller.dart | 243 +++++++++++++ .../widgets/camera_preview_widget.dart | 25 ++ .../widgets/recording_touch_lock_overlay.dart | 100 ++++++ 20 files changed, 1788 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/BatteryOptimizationHelper.kt create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/DoNotDisturbHelper.kt create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingCameraController.kt create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingForegroundService.kt create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPlatformHandler.kt create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPreviewFactory.kt create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingSession.kt create mode 100644 android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingState.kt create mode 100644 lib/features/recording/recording_page.dart create mode 100644 lib/features/recording/recording_platform.dart create mode 100644 lib/features/recording/recording_session_controller.dart create mode 100644 lib/features/recording/widgets/camera_preview_widget.dart create mode 100644 lib/features/recording/widgets/recording_touch_lock_overlay.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a9439be..d1f9811 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { source = "../.." } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e048f86..de75510 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,20 @@ + + + + + + + + + + + + - - + + + - diff --git a/android/app/src/main/kotlin/com/example/flutter_template/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_template/MainActivity.kt index 660cdb0..c0bb582 100644 --- a/android/app/src/main/kotlin/com/example/flutter_template/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/flutter_template/MainActivity.kt @@ -1,5 +1,47 @@ 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.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() + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/BatteryOptimizationHelper.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/BatteryOptimizationHelper.kt new file mode 100644 index 0000000..7ee8ec0 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/BatteryOptimizationHelper.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/DoNotDisturbHelper.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/DoNotDisturbHelper.kt new file mode 100644 index 0000000..d151cfb --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/DoNotDisturbHelper.kt @@ -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() + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingCameraController.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingCameraController.kt new file mode 100644 index 0000000..3ae7c01 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingCameraController.kt @@ -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? = 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" + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingForegroundService.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingForegroundService.kt new file mode 100644 index 0000000..39c7adf --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingForegroundService.kt @@ -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) + } + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPlatformHandler.kt new file mode 100644 index 0000000..ab48d84 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPlatformHandler.kt @@ -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("withAudio") ?: true + val enableDnd = call.argument("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("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() + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPreviewFactory.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPreviewFactory.kt new file mode 100644 index 0000000..c24b176 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingPreviewFactory.kt @@ -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) + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingSession.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingSession.kt new file mode 100644 index 0000000..54229bb --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingSession.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingState.kt b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingState.kt new file mode 100644 index 0000000..ed400a2 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_template/recording/RecordingState.kt @@ -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 = + mapOf( + "state" to state.name.lowercase(), + "outputPath" to outputPath, + "elapsedMillis" to elapsedMillis, + "message" to message, + ) +} diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..475a628 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=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 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 62f1f27..6443a21 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Flutter Template + 飞行极控 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - flutter_template + 飞行极控 CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/app/config/app_config.dart b/lib/app/config/app_config.dart index 05ffa93..ba258e0 100644 --- a/lib/app/config/app_config.dart +++ b/lib/app/config/app_config.dart @@ -20,7 +20,7 @@ class AppConfig { static late EnvironmentValues current; static PackageInfo? packageInfo; - static const appName = 'Flutter Template'; + static const appName = '飞行极控'; static void configure({ required AppEnvironment environment, diff --git a/lib/features/demo/demo_page.dart b/lib/features/demo/demo_page.dart index 7f1f825..c351c7d 100644 --- a/lib/features/demo/demo_page.dart +++ b/lib/features/demo/demo_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.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/features/demo/demo_controller.dart'; +import 'package:flutter_template/features/recording/recording_page.dart'; import 'package:flutter_template/shared/widgets/widgets.dart'; class DemoPage extends ConsumerWidget { @@ -13,7 +15,7 @@ class DemoPage extends ConsumerWidget { final controller = ref.read(demoControllerProvider.notifier); return Scaffold( - appBar: AppBar(title: const Text('Flutter Template')), + appBar: AppBar(title: const Text(AppConfig.appName)), body: SafeAreaWrapper( child: ListView( padding: const EdgeInsets.all(AppSpacing.lg), @@ -85,6 +87,18 @@ class DemoPage extends ConsumerWidget { ), ), const SizedBox(height: AppSpacing.lg), + AppButton( + label: '打开录制', + icon: const Icon(Icons.videocam, size: 18), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const RecordingPage(), + ), + ); + }, + ), + const SizedBox(height: AppSpacing.lg), AppStatusView( status: AppViewStatus.empty, empty: AppEmptyView( diff --git a/lib/features/recording/recording_page.dart b/lib/features/recording/recording_page.dart new file mode 100644 index 0000000..24a0022 --- /dev/null +++ b/lib/features/recording/recording_page.dart @@ -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 createState() => _RecordingPageState(); +} + +class _RecordingPageState extends ConsumerState { + var _immersiveApplied = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap()); + } + + Future _bootstrap() async { + await _enterRecordingMode(); + // Allow PlatformView to attach before binding CameraX preview. + await Future.delayed(const Duration(milliseconds: 400)); + if (!mounted) return; + await ref.read(recordingSessionControllerProvider.notifier).prepareSession(); + } + + Future _enterRecordingMode() async { + if (!Platform.isAndroid) return; + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + await RecordingPlatform.setImmersiveMode(enabled: true); + _immersiveApplied = true; + } + + Future _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), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/recording/recording_platform.dart b/lib/features/recording/recording_platform.dart new file mode 100644 index 0000000..b225e37 --- /dev/null +++ b/lib/features/recording/recording_platform.dart @@ -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 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? _statusStream; + + static Stream statusStream() { + if (!isSupported) { + return const Stream.empty(); + } + _statusStream ??= _events + .receiveBroadcastStream() + .map((event) => RecordingStatus.fromMap(Map.from(event as Map))); + return _statusStream!; + } + + static Future initializePreview() async { + final result = await _channel.invokeMapMethod( + 'initializePreview', + ); + return RecordingStatus.fromMap(result ?? const {}); + } + + static Future startRecording({ + bool withAudio = true, + bool enableDoNotDisturb = true, + }) async { + final result = await _channel.invokeMapMethod( + 'startRecording', + { + 'withAudio': withAudio, + 'enableDoNotDisturb': enableDoNotDisturb, + }, + ); + return RecordingStartResult( + outputPath: result?['outputPath'] as String?, + status: RecordingStatus.fromMap( + Map.from(result?['status'] as Map? ?? const {}), + ), + ); + } + + static Future stopRecording() async { + final result = await _channel.invokeMapMethod( + 'stopRecording', + ); + return RecordingStopResult( + outputPath: result?['outputPath'] as String?, + status: RecordingStatus.fromMap( + Map.from(result?['status'] as Map? ?? const {}), + ), + ); + } + + static Future disposePreview() => _channel.invokeMethod('disposePreview'); + + static Future hasNotificationPolicyAccess() async { + return await _channel.invokeMethod('hasNotificationPolicyAccess') ?? + false; + } + + static Future openNotificationPolicySettings() { + return _channel.invokeMethod('openNotificationPolicySettings'); + } + + static Future enableDoNotDisturb() async { + return await _channel.invokeMethod('enableDoNotDisturb') ?? false; + } + + static Future disableDoNotDisturb() { + return _channel.invokeMethod('disableDoNotDisturb'); + } + + static Future isIgnoringBatteryOptimizations() async { + return await _channel.invokeMethod( + 'isIgnoringBatteryOptimizations', + ) ?? + true; + } + + static Future openBatteryOptimizationSettings() { + return _channel.invokeMethod('openBatteryOptimizationSettings'); + } + + static Future setImmersiveMode({required bool enabled}) { + return _channel.invokeMethod( + 'setImmersiveMode', + {'enabled': enabled}, + ); + } + + static Future getStatus() async { + final result = await _channel.invokeMapMethod('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; +} diff --git a/lib/features/recording/recording_session_controller.dart b/lib/features/recording/recording_session_controller.dart new file mode 100644 index 0000000..89f1b6e --- /dev/null +++ b/lib/features/recording/recording_session_controller.dart @@ -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.new, +); + +class RecordingSessionController extends Notifier { + StreamSubscription? _statusSubscription; + + @override + RecordingSessionState build() { + ref.onDispose(_dispose); + return const RecordingSessionState(); + } + + Future prepareSession() async { + if (!RecordingPlatform.isSupported) { + state = state.copyWith(errorMessage: '仅支持 Android 录制'); + return; + } + + final permissions = await [ + 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 = []; + 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 _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.delayed( + Duration(milliseconds: 150 * (attempt + 1)), + ); + } + } + throw StateError('initializePreview retry exhausted'); + } + + Future 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 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 openDndSettings() => + RecordingPlatform.openNotificationPolicySettings(); + + Future refreshDndAccess() async { + final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); + state = state.copyWith(hasDndAccess: hasDnd); + } + + Future openBatterySettings() => + RecordingPlatform.openBatteryOptimizationSettings(); + + Future refreshBatteryOptimization() async { + final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations(); + state = state.copyWith(isBatteryOptimizedIgnored: ignored); + } + + Future teardown() async { + await RecordingPlatform.setImmersiveMode(enabled: false); + await RecordingPlatform.disableDoNotDisturb(); + await RecordingPlatform.disposePreview(); + await _statusSubscription?.cancel(); + _statusSubscription = null; + state = const RecordingSessionState(); + } + + Future _listenStatus() async { + await _statusSubscription?.cancel(); + _statusSubscription = RecordingPlatform.statusStream().listen((status) { + state = state.copyWith(status: status); + }); + } + + Future _dispose() async { + await _statusSubscription?.cancel(); + } +} diff --git a/lib/features/recording/widgets/camera_preview_widget.dart b/lib/features/recording/widgets/camera_preview_widget.dart new file mode 100644 index 0000000..62472fd --- /dev/null +++ b/lib/features/recording/widgets/camera_preview_widget.dart @@ -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 {}, + creationParamsCodec: const StandardMessageCodec(), + ); + } +} diff --git a/lib/features/recording/widgets/recording_touch_lock_overlay.dart b/lib/features/recording/widgets/recording_touch_lock_overlay.dart new file mode 100644 index 0000000..2086e30 --- /dev/null +++ b/lib/features/recording/widgets/recording_touch_lock_overlay.dart @@ -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 createState() => + _RecordingTouchLockOverlayState(); +} + +class _RecordingTouchLockOverlayState extends State { + 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), + ), + ), + ), + ), + ), + ), + ), + ); + } +}