完成录制功能
This commit is contained in:
@@ -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 = "../.."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<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
|
||||
android:label="flutter_template"
|
||||
android:label="飞行极控"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
@@ -12,10 +26,6 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
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
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
@@ -25,17 +35,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</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>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Flutter Template</string>
|
||||
<string>飞行极控</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>flutter_template</string>
|
||||
<string>飞行极控</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>(
|
||||
builder: (_) => const RecordingPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
AppStatusView(
|
||||
status: AppViewStatus.empty,
|
||||
empty: AppEmptyView(
|
||||
|
||||
332
lib/features/recording/recording_page.dart
Normal file
332
lib/features/recording/recording_page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
163
lib/features/recording/recording_platform.dart
Normal file
163
lib/features/recording/recording_platform.dart
Normal 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;
|
||||
}
|
||||
243
lib/features/recording/recording_session_controller.dart
Normal file
243
lib/features/recording/recording_session_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
25
lib/features/recording/widgets/camera_preview_widget.dart
Normal file
25
lib/features/recording/widgets/camera_preview_widget.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/features/recording/widgets/recording_touch_lock_overlay.dart
Normal file
100
lib/features/recording/widgets/recording_touch_lock_overlay.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user