删除未使用的相机和记录类来重构记录功能;更新pubspec中的依赖项。Yaml为本地插件路径;简化MainActivity和相关类,以提高性能和可维护性。

This commit is contained in:
2026-07-09 16:17:34 +08:00
parent 75c42ea168
commit 6138eddc16
65 changed files with 3677 additions and 2672 deletions
@@ -7,9 +7,7 @@ import android.os.Build
import android.os.Environment
import android.os.StatFs
import android.provider.Settings
import androidx.camera.view.PreviewView
import com.dronex.rec.recording.RecordingPlatformHandler
import com.dronex.rec.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -18,15 +16,8 @@ class MainActivity : FlutterActivity() {
private var platformHandler: RecordingPlatformHandler? = null
private var platformInfoChannel: MethodChannel? = null
var recordingPreviewView: PreviewView? = null
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.platformViewsController.registry.registerViewFactory(
"recording-camera-preview",
RecordingPreviewFactory(this),
)
platformInfoChannel =
MethodChannel(
@@ -51,16 +42,6 @@ class MainActivity : FlutterActivity() {
)
}
fun attachRecordingPreview(previewView: PreviewView) {
recordingPreviewView = previewView
}
fun detachRecordingPreview(previewView: PreviewView? = null) {
if (previewView == null || recordingPreviewView === previewView) {
recordingPreviewView = null
}
}
override fun onDestroy() {
platformInfoChannel?.setMethodCallHandler(null)
platformInfoChannel = null
@@ -1,106 +0,0 @@
package com.dronex.rec.recording
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import androidx.camera.video.FileOutputOptions
import java.io.File
import java.io.FileInputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/飞行极控录像工作台"
private const val MIME_TYPE = "video/mp4"
fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions {
return FileOutputOptions.Builder(segmentFile).build()
}
fun createSegmentFile(
context: Context,
displayName: String?,
index: Int,
): File {
val directory = File(context.cacheDir, "recording_segments")
if (!directory.exists()) {
directory.mkdirs()
}
val baseName = resolveFileName(displayName).removeSuffix(".mp4")
return File(directory, "${baseName}_${System.currentTimeMillis()}_part$index.mp4")
}
fun createMergeFile(context: Context, displayName: String?): File {
val directory = File(context.cacheDir, "recording_segments")
if (!directory.exists()) {
directory.mkdirs()
}
val baseName = resolveFileName(displayName).removeSuffix(".mp4")
return File(directory, "${baseName}_${System.currentTimeMillis()}_merged.mp4")
}
fun publishToMediaStore(
context: Context,
displayName: String?,
sourceFile: File,
): String? {
val fileName = resolveFileName(displayName)
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
put(MediaStore.Video.Media.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri =
resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
?: return null
try {
val outputStream =
resolver.openOutputStream(uri)
?: throw IllegalStateException("Cannot open MediaStore output stream")
outputStream.use { output ->
FileInputStream(sourceFile).use { input -> input.copyTo(output) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val publishedValues =
ContentValues().apply { put(MediaStore.Video.Media.IS_PENDING, 0) }
resolver.update(uri, publishedValues, null, null)
}
return uri.toString()
} catch (error: Exception) {
resolver.delete(uri, null, null)
throw error
}
}
fun publishPartToMediaStore(
context: Context,
displayName: String?,
sourceFile: File,
partIndex: Int,
): String? {
val resolvedName = resolveFileName(displayName)
val partName = resolvedName.replace(".mp4", "_part$partIndex.mp4")
return publishToMediaStore(context, partName, sourceFile)
}
fun resolveFileName(displayName: String?): String {
val trimmed = displayName?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return if (trimmed.lowercase(Locale.US).endsWith(".mp4")) {
trimmed
} else {
"$trimmed.mp4"
}
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4"
}
}
@@ -1,64 +1,30 @@
package com.dronex.rec.recording
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.dronex.rec.AppConstants
import com.dronex.rec.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 {
) : MethodChannel.MethodCallHandler {
private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
private val eventChannel = EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
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
val displayName = call.argument<String>("displayName")
startRecording(withAudio, enableDnd, displayName, result)
}
"stopRecording" -> stopRecording(result)
"getZoomCapabilities" -> result.success(controller.zoomCapabilitiesMap())
"setZoomRatio" -> {
val ratio = call.argument<Double>("zoomRatio") ?: 1.0
setZoomRatio(ratio, result)
}
"disposePreview" -> {
controller.unbind()
result.success(null)
}
"hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity)
@@ -80,130 +46,11 @@ class RecordingPlatformHandler(
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,
displayName: String?,
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, displayName) { 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)
val previewView = activity.recordingPreviewView
if (previewView == null) {
mainHandler.post { deliverStopResult(result, path) }
return@stopRecording
}
controller.rebindForRecording(activity, previewView) { _ ->
mainHandler.post { deliverStopResult(result, path) }
}
}
}
private fun setZoomRatio(ratio: Double, result: MethodChannel.Result) {
controller.setZoomRatio(ratio) { success, capabilities, message ->
mainHandler.post {
if (success) {
result.success(capabilities)
} else {
result.error("ZOOM_FAILED", message ?: "Failed to set camera zoom", null)
}
}
}
}
private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
val fileSaved = path != null && controller.status.state != RecordingState.ERROR
val payload =
mutableMapOf<String, Any?>(
"outputPath" to path,
"status" to controller.status.toMap(),
"fileSaved" to fileSaved,
"segmentOutputPaths" to controller.segmentOutputPaths(),
)
if (!fileSaved) {
payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
}
result.success(payload)
}
private fun setImmersiveMode(enabled: Boolean) {
val window = activity.window
WindowCompat.setDecorFitsSystemWindows(window, !enabled)
@@ -216,41 +63,4 @@ class RecordingPlatformHandler(
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()
}
}
@@ -1,37 +0,0 @@
package com.dronex.rec.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.dronex.rec.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)
}
}
@@ -1,174 +0,0 @@
package com.dronex.rec.recording
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
object RecordingSegmentMuxer {
fun mergeOrCopy(
segments: List<File>,
outputFile: File,
) {
val validSegments = segments.filter { it.exists() && it.length() > 0L }
require(validSegments.isNotEmpty()) { "No recording segments were generated" }
if (validSegments.size == 1) {
copyFile(validSegments.first(), outputFile)
return
}
MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4).use {
muxer ->
val firstTracks = readTrackFormats(validSegments.first())
val videoTrack = firstTracks.video?.let { muxer.addTrack(it) }
val audioTrack = firstTracks.audio?.let { muxer.addTrack(it) }
require(videoTrack != null) { "No video track in recording segment" }
muxer.start()
var offsetUs = 0L
validSegments.forEach { segment ->
val durationUs =
copySegmentTracks(
segment = segment,
muxer = muxer,
videoOutputTrack = videoTrack,
audioOutputTrack = audioTrack,
offsetUs = offsetUs,
)
offsetUs += durationUs + 1L
}
}
}
private fun copyFile(source: File, target: File) {
FileInputStream(source).use { input ->
FileOutputStream(target).use { output -> input.copyTo(output) }
}
}
private fun readTrackFormats(file: File): TrackFormats {
val extractor = MediaExtractor()
extractor.setDataSource(file.absolutePath)
try {
var video: MediaFormat? = null
var audio: MediaFormat? = null
for (index in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(index)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
when {
mime.startsWith("video/") && video == null -> video = format
mime.startsWith("audio/") && audio == null -> audio = format
}
}
return TrackFormats(video = video, audio = audio)
} finally {
extractor.release()
}
}
private fun copySegmentTracks(
segment: File,
muxer: MediaMuxer,
videoOutputTrack: Int,
audioOutputTrack: Int?,
offsetUs: Long,
): Long {
var segmentDurationUs = 0L
segmentDurationUs =
maxOf(
segmentDurationUs,
copyTrack(segment, muxer, "video/", videoOutputTrack, offsetUs),
)
if (audioOutputTrack != null) {
segmentDurationUs =
maxOf(
segmentDurationUs,
copyTrack(segment, muxer, "audio/", audioOutputTrack, offsetUs),
)
}
return segmentDurationUs
}
private fun copyTrack(
segment: File,
muxer: MediaMuxer,
mimePrefix: String,
outputTrack: Int,
offsetUs: Long,
): Long {
val extractor = MediaExtractor()
extractor.setDataSource(segment.absolutePath)
try {
val inputTrack = findTrack(extractor, mimePrefix) ?: return 0L
extractor.selectTrack(inputTrack)
val bufferSize = trackBufferSize(extractor.getTrackFormat(inputTrack))
val buffer = ByteBuffer.allocate(bufferSize)
val info = MediaCodec.BufferInfo()
var firstSampleTimeUs: Long? = null
var lastSampleTimeUs = 0L
while (true) {
buffer.clear()
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
val sampleTimeUs = extractor.sampleTime
val baseTimeUs = firstSampleTimeUs ?: sampleTimeUs.also { firstSampleTimeUs = it }
val normalizedTimeUs = (sampleTimeUs - baseTimeUs).coerceAtLeast(0L)
info.set(
0,
sampleSize,
offsetUs + normalizedTimeUs,
extractor.sampleFlags,
)
muxer.writeSampleData(outputTrack, buffer, info)
lastSampleTimeUs = normalizedTimeUs
extractor.advance()
}
return lastSampleTimeUs
} finally {
extractor.release()
}
}
private fun findTrack(extractor: MediaExtractor, mimePrefix: String): Int? {
for (index in 0 until extractor.trackCount) {
val mime = extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith(mimePrefix)) {
return index
}
}
return null
}
private fun trackBufferSize(format: MediaFormat): Int {
return if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE).coerceAtLeast(DEFAULT_BUFFER_SIZE)
} else {
DEFAULT_BUFFER_SIZE
}
}
private data class TrackFormats(
val video: MediaFormat?,
val audio: MediaFormat?,
)
private const val DEFAULT_BUFFER_SIZE = 2 * 1024 * 1024
}
private inline fun MediaMuxer.use(block: (MediaMuxer) -> Unit) {
try {
block(this)
} finally {
try {
stop()
} catch (_: Exception) {
}
release()
}
}
@@ -1,30 +0,0 @@
package com.dronex.rec.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
}
@@ -1,24 +0,0 @@
package com.dronex.rec.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,
)
}