diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dfa192b --- /dev/null +++ b/CLAUDE.md @@ -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 +``` + +## 架构 + +这是一个 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` 封装了所有与 Android(Kotlin/CameraX)和 iOS(Swift/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`、`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` — 单例导航器,支持路由去重和自定义滑动过渡动画。 diff --git a/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingCameraController.kt b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingCameraController.kt index c13e93d..47e3b52 100644 --- a/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingCameraController.kt +++ b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingCameraController.kt @@ -24,8 +24,10 @@ 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.util.concurrent.Executor import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors import kotlin.math.atan import kotlin.math.round @@ -33,6 +35,7 @@ class RecordingCameraController( private val appContext: Context, ) { private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext) + private val ioExecutor = Executors.newSingleThreadExecutor() private var cameraProvider: ProcessCameraProvider? = null private var preview: Preview? = null @@ -45,6 +48,12 @@ class RecordingCameraController( private var activeRecording: Recording? = null private var boundLifecycleOwner: LifecycleOwner? = null private var currentZoomRatio: Float = 1f + private var activeWithAudio: Boolean = true + private var activeDisplayName: String? = null + private var currentSegmentFile: File? = null + private val segmentFiles = mutableListOf() + private var nextSegmentIndex: Int = 1 + private var isSwitchingLens: Boolean = false var status: RecordingStatus = RecordingStatus(RecordingState.IDLE) private set @@ -53,7 +62,9 @@ class RecordingCameraController( private var recordingStartedAt: Long = 0L private var latestOutputPath: String? = null + private var latestSegmentOutputPaths: List = emptyList() private var pendingStopCallback: ((String?) -> Unit)? = null + private var pendingSegmentSwitch: PendingSegmentSwitch? = null fun bindPreview( lifecycleOwner: LifecycleOwner, @@ -142,21 +153,55 @@ class RecordingCameraController( 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) { onStarted(false, "Already recording") return } - val outputOptions = - RecordingOutputFactory.buildMediaStoreOutputOptions( + val segmentFile = + RecordingOutputFactory.createSegmentFile( appContext, - displayName, + activeDisplayName, + nextSegmentIndex++, ) - latestOutputPath = null + currentSegmentFile = segmentFile + val outputOptions = RecordingOutputFactory.buildSegmentOutputOptions(segmentFile) val pending = capture.output.prepareRecording(appContext, outputOptions).apply { - if (withAudio) { + if (activeWithAudio) { val granted = ContextCompat.checkSelfPermission( appContext, @@ -168,48 +213,25 @@ class RecordingCameraController( } } - recordingStartedAt = System.currentTimeMillis() - updateStatus( - RecordingStatus( - RecordingState.RECORDING, - outputPath = latestOutputPath, - ), - ) + if (updateStatusOnStart) { + updateStatus( + RecordingStatus( + RecordingState.RECORDING, + outputPath = latestOutputPath, + elapsedMillis = elapsedMillis(), + ), + ) + } 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 { - latestOutputPath = event.outputResults.outputUri.toString() - updateStatus( - RecordingStatus( - RecordingState.PREVIEWING, - outputPath = latestOutputPath, - elapsedMillis = - System.currentTimeMillis() - - recordingStartedAt, - ), - ) - } - val stopCallback = pendingStopCallback - pendingStopCallback = null - stopCallback?.invoke(latestOutputPath) - } + is VideoRecordEvent.Finalize -> handleFinalize(event, segmentFile) } } - onStarted(true, latestOutputPath ?: "recording") + onStarted(true, segmentFile.absolutePath) } fun stopRecording(onStopped: (String?) -> Unit) { @@ -218,12 +240,18 @@ class RecordingCameraController( onStopped(latestOutputPath) return } + if (isSwitchingLens) { + onStopped(null) + return + } pendingStopCallback = onStopped + val elapsed = elapsedMillis() updateStatus( RecordingStatus( RecordingState.STOPPING, outputPath = latestOutputPath, + elapsedMillis = elapsed, ), ) @@ -231,6 +259,119 @@ class RecordingCameraController( 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, + displayName: String?, + ): List { + 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) { + files.forEach { file -> + try { + file.delete() + } catch (_: Exception) { + } + } + } + fun zoomCapabilitiesMap(): Map { val zoomState = camera?.cameraInfo?.zoomState?.value val cameraXMin = zoomState?.minZoomRatio ?: 1f @@ -271,6 +412,10 @@ class RecordingCameraController( ) { val boundCamera = camera if (boundCamera == null) { + if (isSwitchingLens) { + onComplete(false, zoomCapabilitiesMap(), "Camera lens switch is already in progress") + return + } val clamped = if (ratio < 1.0 && hasUltraWideCamera()) { ultraWideZoomRatio @@ -337,12 +482,20 @@ class RecordingCameraController( fun unbind() { activeRecording?.stop() activeRecording = null + pendingSegmentSwitch = null + pendingStopCallback = null + isSwitchingLens = false cameraProvider?.unbindAll() cameraProvider = null preview = null videoCapture = null camera = null boundLifecycleOwner = null + currentSegmentFile = null + cleanupSegmentFiles(segmentFiles) + segmentFiles.clear() + nextSegmentIndex = 1 + activeDisplayName = null currentLensMode = LensMode.MAIN currentZoomRatio = 1f ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO @@ -354,6 +507,8 @@ class RecordingCameraController( return System.currentTimeMillis() - recordingStartedAt } + fun segmentOutputPaths(): List = latestSegmentOutputPaths + private fun updateStatus(next: RecordingStatus) { status = next statusListener?.invoke(next) @@ -794,7 +949,11 @@ class RecordingCameraController( return } if (activeRecording != null) { - onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") + switchRecordingSegmentToLens( + LensMode.ULTRA_WIDE, + ultraWideZoomRatio.toDouble(), + onComplete, + ) return } val provider = cameraProvider @@ -828,7 +987,7 @@ class RecordingCameraController( onComplete: (Boolean, Map, String?) -> Unit, ) { if (activeRecording != null) { - onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") + switchRecordingSegmentToLens(LensMode.MAIN, ratio, onComplete) return } val provider = cameraProvider @@ -852,6 +1011,62 @@ class RecordingCameraController( return ultraWideCameraId != null } + private fun switchRecordingSegmentToLens( + targetLensMode: LensMode, + targetRatio: Double, + onComplete: (Boolean, Map, 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 { return try { hasCamera(selector) @@ -895,6 +1110,12 @@ class RecordingCameraController( val zoomRatio: Float, ) + private data class PendingSegmentSwitch( + val targetLensMode: LensMode, + val targetRatio: Double, + val onComplete: (Boolean, Map, String?) -> Unit, + ) + companion object { private const val TAG = "RecordingCamera" private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f diff --git a/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingOutputFactory.kt b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingOutputFactory.kt index 81f4686..cc05e77 100644 --- a/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingOutputFactory.kt +++ b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingOutputFactory.kt @@ -4,7 +4,9 @@ import android.content.ContentValues import android.content.Context import android.os.Build 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.util.Date import java.util.Locale @@ -13,10 +15,37 @@ object RecordingOutputFactory { private const val RELATIVE_PATH = "Movies/酷跑录像工作台" private const val MIME_TYPE = "video/mp4" - fun buildMediaStoreOutputOptions( + fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions { + return FileOutputOptions.Builder(segmentFile).build() + } + + fun createSegmentFile( context: Context, 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 contentValues = ContentValues().apply { @@ -24,15 +53,42 @@ object RecordingOutputFactory { 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) } } - return MediaStoreOutputOptions.Builder( - context.contentResolver, - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, - ) - .setContentValues(contentValues) - .build() + 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 { diff --git a/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingPlatformHandler.kt index 15d4d21..3f1b1da 100644 --- a/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingPlatformHandler.kt +++ b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingPlatformHandler.kt @@ -196,6 +196,7 @@ class RecordingPlatformHandler( "outputPath" to path, "status" to controller.status.toMap(), "fileSaved" to fileSaved, + "segmentOutputPaths" to controller.segmentOutputPaths(), ) if (!fileSaved) { payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败" diff --git a/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingSegmentMuxer.kt b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingSegmentMuxer.kt new file mode 100644 index 0000000..a968dd6 --- /dev/null +++ b/android/app/src/main/kotlin/com/run/sportsx/recording/RecordingSegmentMuxer.kt @@ -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, + 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() + } +} diff --git a/lib/features/recording/platform/recording_platform.dart b/lib/features/recording/platform/recording_platform.dart index 6bfa443..ecf6d88 100644 --- a/lib/features/recording/platform/recording_platform.dart +++ b/lib/features/recording/platform/recording_platform.dart @@ -207,12 +207,14 @@ class RecordingStopResult { required this.status, this.fileSaved = true, this.fileErrorMessage, + this.segmentOutputPaths = const [], }); final String? outputPath; final RecordingStatus status; final bool fileSaved; final String? fileErrorMessage; + final List segmentOutputPaths; factory RecordingStopResult.fromMap(Map? result) { return RecordingStopResult( @@ -222,6 +224,11 @@ class RecordingStopResult { ), fileSaved: result?['fileSaved'] as bool? ?? true, fileErrorMessage: result?['fileErrorMessage'] as String?, + segmentOutputPaths: + (result?['segmentOutputPaths'] as List?)?.whereType().toList( + growable: false, + ) ?? + const [], ); } } diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index 39276e8..b7dcba6 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -364,9 +364,10 @@ class RecordingViewModel extends Notifier { ), ); } on PlatformException catch (error) { - _updateSession( - (s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'), - ); + final message = error.code == 'ZOOM_FAILED' + ? '切换镜头失败,请重试' + : (error.message ?? '相机倍距设置失败'); + _updateSession((s) => s.copyWith(errorMessage: message)); } } diff --git a/lib/features/recording/widgets/widget_record_header.dart b/lib/features/recording/widgets/widget_record_header.dart index 0b99cec..cde1f34 100644 --- a/lib/features/recording/widgets/widget_record_header.dart +++ b/lib/features/recording/widgets/widget_record_header.dart @@ -170,7 +170,7 @@ class _HeaderPasteActions extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - // _HeaderActionButton(label: 'mock', onPressed: onMockCopy), + _HeaderActionButton(label: 'mock', onPressed: onMockCopy), _HeaderActionButton( label: '粘贴选手信息', onPressed: () => onPasteEventInfo(), diff --git a/lib/features/recording/widgets/widget_recording_hud.dart b/lib/features/recording/widgets/widget_recording_hud.dart index 7159deb..ede5f1e 100644 --- a/lib/features/recording/widgets/widget_recording_hud.dart +++ b/lib/features/recording/widgets/widget_recording_hud.dart @@ -145,7 +145,6 @@ class RecordingHudWidget extends StatelessWidget { right: 16.r, bottom: _recordButtonBottom + _recordButtonSize + 14.h, child: _ZoomPresetControl( - isRecording: isRecording, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, maxZoomRatio: maxZoomRatio, @@ -192,16 +191,12 @@ class RecordingHudWidget extends StatelessWidget { } List _zoomPresetsForRange(double minZoomRatio) { - return [ - if (minZoomRatio < 1.0) minZoomRatio, - 1.0, - ]; + return [if (minZoomRatio < 1.0) minZoomRatio, 1.0]; } } class _ZoomPresetControl extends StatelessWidget { const _ZoomPresetControl({ - required this.isRecording, required this.zoomRatio, required this.minZoomRatio, required this.maxZoomRatio, @@ -209,7 +204,6 @@ class _ZoomPresetControl extends StatelessWidget { required this.onSelected, }); - final bool isRecording; final double zoomRatio; final double minZoomRatio; final double maxZoomRatio; @@ -241,7 +235,7 @@ class _ZoomPresetControl extends StatelessWidget { displayRatio: preset, requestRatio: preset, selected: _isPresetSelected(preset), - enabled: !_wouldSwitchPhysicalCamera(preset), + enabled: true, onSelected: onSelected, ), ], @@ -263,15 +257,6 @@ class _ZoomPresetControl extends StatelessWidget { } 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 { @@ -294,7 +279,17 @@ class _ZoomPresetButton extends StatelessWidget { return Padding( padding: EdgeInsets.symmetric(horizontal: 1.r), child: TextButton( - onPressed: selected || !enabled ? null : () => onSelected(requestRatio), + onPressed: selected || !enabled + ? null + : () => RateLimit.instance.debounce( + key: + 'recording.session.zoom.${requestRatio.toStringAsFixed(1)}', + value: null, + duration: Duration(milliseconds: 300), + onCallback: (_) async { + onSelected(requestRatio); + }, + ), style: TextButton.styleFrom( minimumSize: Size(38.r, 32.r), padding: EdgeInsets.zero, diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index 58c89e2..dbcc0f2 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -226,7 +226,33 @@ void main() { final session = container.read(recordingViewModelProvider).session; 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, '切换镜头失败,请重试'); }, ); }); diff --git a/test/features/recording/widget_recording_hud_test.dart b/test/features/recording/widget_recording_hud_test.dart index 8302fd9..511d6dc 100644 --- a/test/features/recording/widget_recording_hud_test.dart +++ b/test/features/recording/widget_recording_hud_test.dart @@ -75,9 +75,7 @@ void main() { expect(find.text('1x'), findsOneWidget); }); - testWidgets('marks current 0.5x zoom ratio as selected', ( - tester, - ) async { + testWidgets('marks current 0.5x zoom ratio as selected', (tester) async { await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5); final selectedButton = tester.widget( @@ -135,8 +133,16 @@ void main() { expect(selected, 0.6); }); - testWidgets('disables 0.6x while recording on main camera', (tester) async { - await pumpHud(tester, minZoomRatio: 0.6, isRecording: true); + testWidgets('allows 0.6x while recording on main camera after unlock', ( + tester, + ) async { + double? selected; + await pumpHud( + tester, + minZoomRatio: 0.6, + isRecording: true, + onZoomSelected: (ratio) => selected = ratio, + ); final ultraWideButton = tester.widget( 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)), ); - expect(ultraWideButton.enabled, isFalse); + expect(ultraWideButton.enabled, isTrue); 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, ) 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( find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)), @@ -162,6 +180,11 @@ void main() { ); 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); }); }