支持录制中切换广角/主摄

This commit is contained in:
2026-07-01 09:59:21 +08:00
parent f6347e99cd
commit da2e69b014
11 changed files with 656 additions and 82 deletions
+70
View File
@@ -0,0 +1,70 @@
# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 在此仓库中工作提供指引。
## 常用命令
```bash
# 运行全部测试
flutter test
# 运行单个测试文件
flutter test test/features/recording/view_model_recording_test.dart
# 静态分析
flutter analyze
# 构建 Android release APK
flutter build apk --release
# 构建 Android release APK(按 ABI 拆分)
./build-apk-split.sh
# 清理并重装 iOS pods
./clean.sh
# 在指定设备上运行
flutter run -d <device-id>
```
## 架构
这是一个 Flutter 视频录制工具(酷跑录像工作台),支持 Android 和 iOS。应用从系统剪贴板读取赛事信息(来自小程序的 JSON),初始化 CameraX/AVFoundation 相机预览,录制视频并保存到文件系统。
### 状态管理:Riverpod
使用 `NotifierProvider` 模式。主状态 provider 为 `recordingViewModelProvider`,位于 `lib/features/recording/view-model/view_model_recording.dart`。UI 通过 `ref.watch(provider.select(...))` 细粒度读取状态,通过 `ref.read(provider.notifier).method()` 调用操作。
### 状态模型(`RecordingSessionState`
`lib/features/recording/model/model_recording_session.dart` 中的关键字段:
- `isTouchLocked` — 防误触状态(默认 `true`,开始录制时置为 `true`
- `zoomRatio`, `minZoomRatio`, `maxZoomRatio` — 超广角(<1.0)与主摄(1.0)切换
- `isRecording`, `isPreviewReady`, `isStartingRecording`
- `status` — 原生端 `RecordingState`
### 原生桥接
`lib/features/recording/platform/recording_platform.dart` 封装了所有与 AndroidKotlin/CameraX)和 iOSSwift/AVFoundation)通信的 MethodChannel/EventChannel 调用。禁止直接调用 channel,统一通过 `RecordingPlatform`
### 原生关键文件
- Android: `android/app/src/main/kotlin/com/run/sportsx/recording/RecordingCameraController.kt`
- iOS: `ios/Runner/RecordingPlugin.swift`
- 两个平台均实现 `lib/features/recording/platform/recording_channel_names.dart` 中定义的 channel 名称
### 网络层
`lib/core/network/` — 基于 Dio,包含 `ApiClient``ApiResponse<T>``ApiException`、请求头拦截器,以及离线队列(当前未启用)。网络 provider 定义在 `lib/core/network/providers/dio_providers.dart`
### 相机倍距/镜头切换逻辑
`lib/features/recording/widgets/widget_recording_hud.dart:199` 中的 `_ZoomPresetControl` 组件渲染倍距预设按钮(超广角 = `<1.0`,主摄 = `1.0`)。`_wouldSwitchPhysicalCamera` 方法在录制过程中禁止切换物理镜头。组件接收 `isRecording` 和倍距值;`isTouchLocked` 已存在于父级 `RecordingHudWidget` 但未传入 `_ZoomPresetControl`
### 防误触
`widget_recording_touch_lock_overlay.dart` — 全屏遮罩,`isTouchLocked``true` 时显示"防误触已开启"。需长按 2 秒解锁。防误触默认 `true`,开始录制时也会置为 `true``view_model_recording.dart:400`)。
### 路由
`lib/app/router/app_navigator.dart` — 单例导航器,支持路由去重和自定义滑动过渡动画。
@@ -24,8 +24,10 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import java.io.File
import java.util.concurrent.Executor import java.util.concurrent.Executor
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import kotlin.math.atan import kotlin.math.atan
import kotlin.math.round import kotlin.math.round
@@ -33,6 +35,7 @@ class RecordingCameraController(
private val appContext: Context, private val appContext: Context,
) { ) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext) private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
private val ioExecutor = Executors.newSingleThreadExecutor()
private var cameraProvider: ProcessCameraProvider? = null private var cameraProvider: ProcessCameraProvider? = null
private var preview: Preview? = null private var preview: Preview? = null
@@ -45,6 +48,12 @@ class RecordingCameraController(
private var activeRecording: Recording? = null private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null private var boundLifecycleOwner: LifecycleOwner? = null
private var currentZoomRatio: Float = 1f private var currentZoomRatio: Float = 1f
private var activeWithAudio: Boolean = true
private var activeDisplayName: String? = null
private var currentSegmentFile: File? = null
private val segmentFiles = mutableListOf<File>()
private var nextSegmentIndex: Int = 1
private var isSwitchingLens: Boolean = false
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE) var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
private set private set
@@ -53,7 +62,9 @@ class RecordingCameraController(
private var recordingStartedAt: Long = 0L private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null private var latestOutputPath: String? = null
private var latestSegmentOutputPaths: List<String> = emptyList()
private var pendingStopCallback: ((String?) -> Unit)? = null private var pendingStopCallback: ((String?) -> Unit)? = null
private var pendingSegmentSwitch: PendingSegmentSwitch? = null
fun bindPreview( fun bindPreview(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@@ -142,21 +153,55 @@ class RecordingCameraController(
return return
} }
if (activeRecording != null || isSwitchingLens) {
onStarted(false, "Already recording")
return
}
activeWithAudio = withAudio
activeDisplayName = displayName
segmentFiles.clear()
nextSegmentIndex = 1
latestOutputPath = null
latestSegmentOutputPaths = emptyList()
recordingStartedAt = System.currentTimeMillis()
updateStatus(
RecordingStatus(
RecordingState.RECORDING,
outputPath = latestOutputPath,
),
)
startNewSegment(updateStatusOnStart = false, onStarted = onStarted)
}
private fun startNewSegment(
updateStatusOnStart: Boolean,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
onStarted(false, "Camera not ready")
return
}
if (activeRecording != null) { if (activeRecording != null) {
onStarted(false, "Already recording") onStarted(false, "Already recording")
return return
} }
val outputOptions = val segmentFile =
RecordingOutputFactory.buildMediaStoreOutputOptions( RecordingOutputFactory.createSegmentFile(
appContext, appContext,
displayName, activeDisplayName,
nextSegmentIndex++,
) )
latestOutputPath = null currentSegmentFile = segmentFile
val outputOptions = RecordingOutputFactory.buildSegmentOutputOptions(segmentFile)
val pending = val pending =
capture.output.prepareRecording(appContext, outputOptions).apply { capture.output.prepareRecording(appContext, outputOptions).apply {
if (withAudio) { if (activeWithAudio) {
val granted = val granted =
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
appContext, appContext,
@@ -168,48 +213,25 @@ class RecordingCameraController(
} }
} }
recordingStartedAt = System.currentTimeMillis() if (updateStatusOnStart) {
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.RECORDING, RecordingState.RECORDING,
outputPath = latestOutputPath, outputPath = latestOutputPath,
), elapsedMillis = elapsedMillis(),
) ),
)
}
activeRecording = activeRecording =
pending.start(mainExecutor) { event -> pending.start(mainExecutor) { event ->
when (event) { when (event) {
is VideoRecordEvent.Start -> Unit is VideoRecordEvent.Start -> Unit
is VideoRecordEvent.Finalize -> { is VideoRecordEvent.Finalize -> handleFinalize(event, segmentFile)
activeRecording = null
if (event.hasError()) {
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = event.cause?.message
?: "Recording failed",
),
)
} else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis =
System.currentTimeMillis() -
recordingStartedAt,
),
)
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
stopCallback?.invoke(latestOutputPath)
}
} }
} }
onStarted(true, latestOutputPath ?: "recording") onStarted(true, segmentFile.absolutePath)
} }
fun stopRecording(onStopped: (String?) -> Unit) { fun stopRecording(onStopped: (String?) -> Unit) {
@@ -218,12 +240,18 @@ class RecordingCameraController(
onStopped(latestOutputPath) onStopped(latestOutputPath)
return return
} }
if (isSwitchingLens) {
onStopped(null)
return
}
pendingStopCallback = onStopped pendingStopCallback = onStopped
val elapsed = elapsedMillis()
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.STOPPING, RecordingState.STOPPING,
outputPath = latestOutputPath, outputPath = latestOutputPath,
elapsedMillis = elapsed,
), ),
) )
@@ -231,6 +259,119 @@ class RecordingCameraController(
activeRecording = null activeRecording = null
} }
private fun handleFinalize(
event: VideoRecordEvent.Finalize,
segmentFile: File,
) {
activeRecording = null
if (!event.hasError() && segmentFile.exists() && segmentFile.length() > 0L) {
segmentFiles.add(segmentFile)
}
val segmentSwitch = pendingSegmentSwitch
if (segmentSwitch != null) {
pendingSegmentSwitch = null
if (event.hasError()) {
isSwitchingLens = false
val message = event.cause?.message ?: "Recording segment failed"
updateStatus(RecordingStatus(RecordingState.ERROR, message = message))
segmentSwitch.onComplete(false, zoomCapabilitiesMap(), message)
return
}
continueSegmentSwitch(segmentSwitch)
return
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
if (stopCallback == null) {
return
}
if (event.hasError()) {
val message = event.cause?.message ?: "Recording failed"
updateStatus(RecordingStatus(RecordingState.ERROR, message = message))
stopCallback.invoke(null)
return
}
finalizeRecordingOutput(stopCallback)
}
private fun finalizeRecordingOutput(stopCallback: (String?) -> Unit) {
val segments = segmentFiles.toList()
val displayName = activeDisplayName
ioExecutor.execute {
try {
val mergedFile = RecordingOutputFactory.createMergeFile(appContext, displayName)
RecordingSegmentMuxer.mergeOrCopy(segments, mergedFile)
val outputUri =
RecordingOutputFactory.publishToMediaStore(
appContext,
displayName,
mergedFile,
)
latestOutputPath = outputUri
latestSegmentOutputPaths = emptyList()
cleanupSegmentFiles(segments + mergedFile)
mainExecutor.execute {
val elapsed = System.currentTimeMillis() - recordingStartedAt
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis = elapsed,
),
)
stopCallback.invoke(latestOutputPath)
}
} catch (error: Exception) {
Log.e(TAG, "finalizeRecordingOutput failed", error)
val publishedParts = publishSegmentsAfterMergeFailure(segments, displayName)
latestOutputPath = publishedParts.firstOrNull()
latestSegmentOutputPaths = publishedParts
mainExecutor.execute {
updateStatus(
RecordingStatus(
RecordingState.ERROR,
outputPath = latestOutputPath,
message = error.message ?: "保存到文件夹失败",
),
)
stopCallback.invoke(latestOutputPath)
}
}
}
}
private fun publishSegmentsAfterMergeFailure(
segments: List<File>,
displayName: String?,
): List<String> {
return segments.mapIndexedNotNull { index, segment ->
try {
RecordingOutputFactory.publishPartToMediaStore(
appContext,
displayName,
segment,
index + 1,
)
} catch (error: Exception) {
Log.e(TAG, "publish segment after merge failure failed", error)
null
}
}
}
private fun cleanupSegmentFiles(files: List<File>) {
files.forEach { file ->
try {
file.delete()
} catch (_: Exception) {
}
}
}
fun zoomCapabilitiesMap(): Map<String, Any> { fun zoomCapabilitiesMap(): Map<String, Any> {
val zoomState = camera?.cameraInfo?.zoomState?.value val zoomState = camera?.cameraInfo?.zoomState?.value
val cameraXMin = zoomState?.minZoomRatio ?: 1f val cameraXMin = zoomState?.minZoomRatio ?: 1f
@@ -271,6 +412,10 @@ class RecordingCameraController(
) { ) {
val boundCamera = camera val boundCamera = camera
if (boundCamera == null) { if (boundCamera == null) {
if (isSwitchingLens) {
onComplete(false, zoomCapabilitiesMap(), "Camera lens switch is already in progress")
return
}
val clamped = val clamped =
if (ratio < 1.0 && hasUltraWideCamera()) { if (ratio < 1.0 && hasUltraWideCamera()) {
ultraWideZoomRatio ultraWideZoomRatio
@@ -337,12 +482,20 @@ class RecordingCameraController(
fun unbind() { fun unbind() {
activeRecording?.stop() activeRecording?.stop()
activeRecording = null activeRecording = null
pendingSegmentSwitch = null
pendingStopCallback = null
isSwitchingLens = false
cameraProvider?.unbindAll() cameraProvider?.unbindAll()
cameraProvider = null cameraProvider = null
preview = null preview = null
videoCapture = null videoCapture = null
camera = null camera = null
boundLifecycleOwner = null boundLifecycleOwner = null
currentSegmentFile = null
cleanupSegmentFiles(segmentFiles)
segmentFiles.clear()
nextSegmentIndex = 1
activeDisplayName = null
currentLensMode = LensMode.MAIN currentLensMode = LensMode.MAIN
currentZoomRatio = 1f currentZoomRatio = 1f
ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
@@ -354,6 +507,8 @@ class RecordingCameraController(
return System.currentTimeMillis() - recordingStartedAt return System.currentTimeMillis() - recordingStartedAt
} }
fun segmentOutputPaths(): List<String> = latestSegmentOutputPaths
private fun updateStatus(next: RecordingStatus) { private fun updateStatus(next: RecordingStatus) {
status = next status = next
statusListener?.invoke(next) statusListener?.invoke(next)
@@ -794,7 +949,11 @@ class RecordingCameraController(
return return
} }
if (activeRecording != null) { if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") switchRecordingSegmentToLens(
LensMode.ULTRA_WIDE,
ultraWideZoomRatio.toDouble(),
onComplete,
)
return return
} }
val provider = cameraProvider val provider = cameraProvider
@@ -828,7 +987,7 @@ class RecordingCameraController(
onComplete: (Boolean, Map<String, Any>, String?) -> Unit, onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) { ) {
if (activeRecording != null) { if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") switchRecordingSegmentToLens(LensMode.MAIN, ratio, onComplete)
return return
} }
val provider = cameraProvider val provider = cameraProvider
@@ -852,6 +1011,62 @@ class RecordingCameraController(
return ultraWideCameraId != null return ultraWideCameraId != null
} }
private fun switchRecordingSegmentToLens(
targetLensMode: LensMode,
targetRatio: Double,
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) {
val recording = activeRecording
if (recording == null) {
onComplete(false, zoomCapabilitiesMap(), "Recording is not active")
return
}
if (isSwitchingLens || pendingSegmentSwitch != null) {
onComplete(false, zoomCapabilitiesMap(), "Camera lens switch is already in progress")
return
}
pendingSegmentSwitch = PendingSegmentSwitch(targetLensMode, targetRatio, onComplete)
isSwitchingLens = true
recording.stop()
activeRecording = null
}
private fun continueSegmentSwitch(request: PendingSegmentSwitch) {
val provider = cameraProvider
val lifecycleOwner = boundLifecycleOwner
if (provider == null || lifecycleOwner == null) {
isSwitchingLens = false
request.onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
return
}
try {
currentLensMode = request.targetLensMode
currentZoomRatio =
if (request.targetLensMode == LensMode.ULTRA_WIDE) {
ultraWideZoomRatio
} else {
request.targetRatio.toFloat().coerceAtLeast(1f)
}
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
applyCurrentZoom()
startNewSegment(updateStatusOnStart = true) { started, message ->
isSwitchingLens = false
if (started) {
request.onComplete(true, zoomCapabilitiesMap(), null)
} else {
updateStatus(RecordingStatus(RecordingState.ERROR, message = message))
request.onComplete(false, zoomCapabilitiesMap(), message)
}
}
} catch (error: Exception) {
Log.e(TAG, "continueSegmentSwitch failed", error)
isSwitchingLens = false
updateStatus(RecordingStatus(RecordingState.ERROR, message = error.message))
request.onComplete(false, zoomCapabilitiesMap(), error.message)
}
}
private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean { private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean {
return try { return try {
hasCamera(selector) hasCamera(selector)
@@ -895,6 +1110,12 @@ class RecordingCameraController(
val zoomRatio: Float, val zoomRatio: Float,
) )
private data class PendingSegmentSwitch(
val targetLensMode: LensMode,
val targetRatio: Double,
val onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
)
companion object { companion object {
private const val TAG = "RecordingCamera" private const val TAG = "RecordingCamera"
private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f
@@ -4,7 +4,9 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.camera.video.MediaStoreOutputOptions import androidx.camera.video.FileOutputOptions
import java.io.File
import java.io.FileInputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -13,10 +15,37 @@ object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/酷跑录像工作台" private const val RELATIVE_PATH = "Movies/酷跑录像工作台"
private const val MIME_TYPE = "video/mp4" private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions( fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions {
return FileOutputOptions.Builder(segmentFile).build()
}
fun createSegmentFile(
context: Context, context: Context,
displayName: String?, displayName: String?,
): MediaStoreOutputOptions { 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 fileName = resolveFileName(displayName)
val contentValues = val contentValues =
ContentValues().apply { ContentValues().apply {
@@ -24,15 +53,42 @@ object RecordingOutputFactory {
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE) put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH) put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
put(MediaStore.Video.Media.IS_PENDING, 1)
} }
} }
return MediaStoreOutputOptions.Builder( val resolver = context.contentResolver
context.contentResolver, val uri =
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
) ?: return null
.setContentValues(contentValues) try {
.build() 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 { fun resolveFileName(displayName: String?): String {
@@ -196,6 +196,7 @@ class RecordingPlatformHandler(
"outputPath" to path, "outputPath" to path,
"status" to controller.status.toMap(), "status" to controller.status.toMap(),
"fileSaved" to fileSaved, "fileSaved" to fileSaved,
"segmentOutputPaths" to controller.segmentOutputPaths(),
) )
if (!fileSaved) { if (!fileSaved) {
payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败" payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
@@ -0,0 +1,174 @@
package com.run.sportsx.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()
}
}
@@ -207,12 +207,14 @@ class RecordingStopResult {
required this.status, required this.status,
this.fileSaved = true, this.fileSaved = true,
this.fileErrorMessage, this.fileErrorMessage,
this.segmentOutputPaths = const [],
}); });
final String? outputPath; final String? outputPath;
final RecordingStatus status; final RecordingStatus status;
final bool fileSaved; final bool fileSaved;
final String? fileErrorMessage; final String? fileErrorMessage;
final List<String> segmentOutputPaths;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) { factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult( return RecordingStopResult(
@@ -222,6 +224,11 @@ class RecordingStopResult {
), ),
fileSaved: result?['fileSaved'] as bool? ?? true, fileSaved: result?['fileSaved'] as bool? ?? true,
fileErrorMessage: result?['fileErrorMessage'] as String?, fileErrorMessage: result?['fileErrorMessage'] as String?,
segmentOutputPaths:
(result?['segmentOutputPaths'] as List?)?.whereType<String>().toList(
growable: false,
) ??
const [],
); );
} }
} }
@@ -364,9 +364,10 @@ class RecordingViewModel extends Notifier<RecordingModel> {
), ),
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
_updateSession( final message = error.code == 'ZOOM_FAILED'
(s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'), ? '切换镜头失败,请重试'
); : (error.message ?? '相机倍距设置失败');
_updateSession((s) => s.copyWith(errorMessage: message));
} }
} }
@@ -170,7 +170,7 @@ class _HeaderPasteActions extends StatelessWidget {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
// _HeaderActionButton(label: 'mock', onPressed: onMockCopy), _HeaderActionButton(label: 'mock', onPressed: onMockCopy),
_HeaderActionButton( _HeaderActionButton(
label: '粘贴选手信息', label: '粘贴选手信息',
onPressed: () => onPasteEventInfo(), onPressed: () => onPasteEventInfo(),
@@ -145,7 +145,6 @@ class RecordingHudWidget extends StatelessWidget {
right: 16.r, right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h, bottom: _recordButtonBottom + _recordButtonSize + 14.h,
child: _ZoomPresetControl( child: _ZoomPresetControl(
isRecording: isRecording,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio, maxZoomRatio: maxZoomRatio,
@@ -192,16 +191,12 @@ class RecordingHudWidget extends StatelessWidget {
} }
List<double> _zoomPresetsForRange(double minZoomRatio) { List<double> _zoomPresetsForRange(double minZoomRatio) {
return [ return [if (minZoomRatio < 1.0) minZoomRatio, 1.0];
if (minZoomRatio < 1.0) minZoomRatio,
1.0,
];
} }
} }
class _ZoomPresetControl extends StatelessWidget { class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({ const _ZoomPresetControl({
required this.isRecording,
required this.zoomRatio, required this.zoomRatio,
required this.minZoomRatio, required this.minZoomRatio,
required this.maxZoomRatio, required this.maxZoomRatio,
@@ -209,7 +204,6 @@ class _ZoomPresetControl extends StatelessWidget {
required this.onSelected, required this.onSelected,
}); });
final bool isRecording;
final double zoomRatio; final double zoomRatio;
final double minZoomRatio; final double minZoomRatio;
final double maxZoomRatio; final double maxZoomRatio;
@@ -241,7 +235,7 @@ class _ZoomPresetControl extends StatelessWidget {
displayRatio: preset, displayRatio: preset,
requestRatio: preset, requestRatio: preset,
selected: _isPresetSelected(preset), selected: _isPresetSelected(preset),
enabled: !_wouldSwitchPhysicalCamera(preset), enabled: true,
onSelected: onSelected, onSelected: onSelected,
), ),
], ],
@@ -263,15 +257,6 @@ class _ZoomPresetControl extends StatelessWidget {
} }
return (zoomRatio - preset).abs() < 0.05; return (zoomRatio - preset).abs() < 0.05;
} }
bool _wouldSwitchPhysicalCamera(double preset) {
if (!isRecording) {
return false;
}
final currentIsUltraWide = zoomRatio < 1.0;
final targetIsUltraWide = preset < 1.0;
return currentIsUltraWide != targetIsUltraWide;
}
} }
class _ZoomPresetButton extends StatelessWidget { class _ZoomPresetButton extends StatelessWidget {
@@ -294,7 +279,17 @@ class _ZoomPresetButton extends StatelessWidget {
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: 1.r), padding: EdgeInsets.symmetric(horizontal: 1.r),
child: TextButton( child: TextButton(
onPressed: selected || !enabled ? null : () => onSelected(requestRatio), onPressed: selected || !enabled
? null
: () => RateLimit.instance.debounce<void>(
key:
'recording.session.zoom.${requestRatio.toStringAsFixed(1)}',
value: null,
duration: Duration(milliseconds: 300),
onCallback: (_) async {
onSelected(requestRatio);
},
),
style: TextButton.styleFrom( style: TextButton.styleFrom(
minimumSize: Size(38.r, 32.r), minimumSize: Size(38.r, 32.r),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -226,7 +226,33 @@ void main() {
final session = container.read(recordingViewModelProvider).session; final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0); expect(session.zoomRatio, 1.0);
expect(session.errorMessage, 'Zoom is unavailable'); expect(session.errorMessage, '切换镜头失败,请重试');
},
);
test(
'maps native zoom failure to user friendly lens switch message',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
throw PlatformException(
code: 'ZOOM_FAILED',
message: 'Cannot switch physical camera while recording',
);
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(0.6);
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0);
expect(session.errorMessage, '切换镜头失败,请重试');
}, },
); );
}); });
@@ -75,9 +75,7 @@ void main() {
expect(find.text('1x'), findsOneWidget); expect(find.text('1x'), findsOneWidget);
}); });
testWidgets('marks current 0.5x zoom ratio as selected', ( testWidgets('marks current 0.5x zoom ratio as selected', (tester) async {
tester,
) async {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5); await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5);
final selectedButton = tester.widget<TextButton>( final selectedButton = tester.widget<TextButton>(
@@ -135,8 +133,16 @@ void main() {
expect(selected, 0.6); expect(selected, 0.6);
}); });
testWidgets('disables 0.6x while recording on main camera', (tester) async { testWidgets('allows 0.6x while recording on main camera after unlock', (
await pumpHud(tester, minZoomRatio: 0.6, isRecording: true); tester,
) async {
double? selected;
await pumpHud(
tester,
minZoomRatio: 0.6,
isRecording: true,
onZoomSelected: (ratio) => selected = ratio,
);
final ultraWideButton = tester.widget<TextButton>( final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
@@ -145,14 +151,26 @@ void main() {
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
); );
expect(ultraWideButton.enabled, isFalse); expect(ultraWideButton.enabled, isTrue);
expect(mainButton.enabled, isFalse); expect(mainButton.enabled, isFalse);
await tester.tap(find.text('0.6x'));
await tester.pump();
expect(selected, 0.6);
}); });
testWidgets('disables main zoom presets while recording on ultra-wide', ( testWidgets('allows 1x while recording on ultra-wide after unlock', (
tester, tester,
) async { ) async {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true); double? selected;
await pumpHud(
tester,
zoomRatio: 0.5,
minZoomRatio: 0.5,
isRecording: true,
onZoomSelected: (ratio) => selected = ratio,
);
final ultraWideButton = tester.widget<TextButton>( final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)),
@@ -162,6 +180,11 @@ void main() {
); );
expect(ultraWideButton.enabled, isFalse); expect(ultraWideButton.enabled, isFalse);
expect(mainButton.enabled, isFalse); expect(mainButton.enabled, isTrue);
await tester.tap(find.text('1x'));
await tester.pump();
expect(selected, 1.0);
}); });
} }