diff --git a/.gitignore b/.gitignore index b6066aa..bd82493 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,4 @@ app.*.map.json /android/app/release /android/.kotlin -CLAUDE.md \ No newline at end of file +CLAUDE.md diff --git a/android/app/src/main/kotlin/com/dronex/rec/AppConstants.kt b/android/app/src/main/kotlin/com/dronex/rec/AppConstants.kt index eca38e8..55732a9 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/AppConstants.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/AppConstants.kt @@ -1,10 +1,10 @@ package com.dronex.rec object AppConstants { - const val PACKAGE_NAME = "com.dronex.rec" - const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info" - const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording" - const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events" - const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START" - const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP" + private const val CHANNEL_NAMESPACE = "app.record_tool" + const val PLATFORM_INFO_CHANNEL = "$CHANNEL_NAMESPACE/platform_info" + const val RECORDING_METHOD_CHANNEL = "$CHANNEL_NAMESPACE/recording" + const val RECORDING_EVENT_CHANNEL = "$CHANNEL_NAMESPACE/recording_events" + const val RECORDING_ACTION_START = "$CHANNEL_NAMESPACE.recording.START" + const val RECORDING_ACTION_STOP = "$CHANNEL_NAMESPACE.recording.STOP" } diff --git a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt index 00f0d12..e5f09c7 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingCameraController.kt @@ -3,9 +3,15 @@ package com.dronex.rec.recording import android.content.Context import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraManager +import android.hardware.camera2.CaptureRequest +import android.os.Build import android.util.Log +import androidx.camera.camera2.interop.Camera2CameraControl import androidx.camera.camera2.interop.Camera2CameraInfo +import androidx.camera.camera2.interop.CaptureRequestOptions +import androidx.camera.camera2.interop.ExperimentalCamera2Interop import androidx.camera.core.Camera +import androidx.camera.core.CameraControl import androidx.camera.core.CameraSelector import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider @@ -18,13 +24,18 @@ import androidx.camera.video.VideoRecordEvent import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner -import kotlin.math.atan +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 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 @@ -37,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 @@ -45,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, @@ -134,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, @@ -160,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) { @@ -210,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, ), ) @@ -223,27 +259,144 @@ 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 logicalMin = zoomState?.minZoomRatio ?: 1f - // 兜底两路超广角来源:独立超广角镜头(0.6) 与 逻辑相机原生 <1.0 变焦范围,取更小者。 + val cameraXMin = zoomState?.minZoomRatio ?: 1f + val cameraXMax = zoomState?.maxZoomRatio ?: 3f + val camera2Range = mainCameraZoomRatioRange() + val logicalMin = camera2Range?.lower?.let { minOf(cameraXMin, it) } ?: cameraXMin + val logicalMax = camera2Range?.upper?.let { maxOf(cameraXMax, it) } ?: cameraXMax val minZoom = if (hasUltraWideCamera()) { minOf(ultraWideZoomRatio, logicalMin) } else { logicalMin } - val maxZoom = zoomState?.maxZoomRatio ?: 3f + val maxZoom = logicalMax val zoom = if (currentLensMode == LensMode.ULTRA_WIDE) { ultraWideZoomRatio } else { - (zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom) + currentZoomRatio.coerceIn(minZoom, maxZoom) } currentZoomRatio = zoom Log.d( TAG, - "zoomCapabilities hasUltraWide=${hasUltraWideCamera()} logicalMin=$logicalMin " + + "zoomCapabilities hasUltraWide=${hasUltraWideCamera()} cameraXMin=$cameraXMin " + + "cameraXMax=$cameraXMax camera2Range=${camera2Range?.description()} " + "ultraWideZoomRatio=$ultraWideZoomRatio minZoom=$minZoom maxZoom=$maxZoom zoom=$zoom", ) return mapOf( @@ -259,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 @@ -271,8 +428,11 @@ class RecordingCameraController( } if (ratio < 1.0 && hasUltraWideCamera()) { - switchToUltraWide(onComplete) - return + val logicalRange = mainCameraZoomRatioRange() + if (logicalRange == null || !logicalRange.contains(ratio.toFloat())) { + switchToUltraWide(onComplete) + return + } } if (currentLensMode == LensMode.ULTRA_WIDE) { @@ -281,18 +441,36 @@ class RecordingCameraController( } val zoomState = boundCamera.cameraInfo.zoomState.value - val minZoom = zoomState?.minZoomRatio ?: 1f - val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() + val camera2Range = mainCameraZoomRatioRange() + val minZoom = camera2Range?.lower ?: zoomState?.minZoomRatio ?: 1f + val maxZoom = camera2Range?.upper ?: zoomState?.maxZoomRatio ?: clampedMaxZoom() val nextZoom = ratio.toFloat().coerceIn(minZoom, maxZoom) currentZoomRatio = nextZoom - val future = boundCamera.cameraControl.setZoomRatio(nextZoom) + val future = + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + camera2Range?.contains(nextZoom) == true + ) { + applyCamera2ZoomRatio(boundCamera, nextZoom) + } else { + boundCamera.cameraControl.setZoomRatio(nextZoom) + } future.addListener( { try { future.get() - onComplete(true, zoomCapabilitiesMap(), null) + val capabilities = zoomCapabilitiesMap() + logSetZoomResult(ratio, nextZoom, capabilities) + onComplete(true, capabilities, null) } catch (error: Exception) { + if (isSupersededCamera2ZoomRequest(error)) { + Log.d(TAG, "setZoomRatio superseded by newer Camera2 options") + val capabilities = zoomCapabilitiesMap() + logSetZoomResult(ratio, nextZoom, capabilities) + onComplete(true, capabilities, null) + return@addListener + } Log.e(TAG, "setZoomRatio failed", error) onComplete(false, zoomCapabilitiesMap(), error.message) } @@ -304,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 @@ -321,6 +507,8 @@ class RecordingCameraController( return System.currentTimeMillis() - recordingStartedAt } + fun segmentOutputPaths(): List = latestSegmentOutputPaths + private fun updateStatus(next: RecordingStatus) { status = next statusListener?.invoke(next) @@ -334,10 +522,18 @@ class RecordingCameraController( return } val zoomState = boundCamera.cameraInfo.zoomState.value - val minZoom = zoomState?.minZoomRatio ?: 1f - val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() + val camera2Range = mainCameraZoomRatioRange() + val minZoom = camera2Range?.lower ?: zoomState?.minZoomRatio ?: 1f + val maxZoom = camera2Range?.upper ?: zoomState?.maxZoomRatio ?: clampedMaxZoom() currentZoomRatio = currentZoomRatio.coerceIn(minZoom, maxZoom) - boundCamera.cameraControl.setZoomRatio(currentZoomRatio) + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + camera2Range?.contains(currentZoomRatio) == true + ) { + applyCamera2ZoomRatio(boundCamera, currentZoomRatio) + } else { + boundCamera.cameraControl.setZoomRatio(currentZoomRatio) + } } private fun clampedMaxZoom(): Float { @@ -345,9 +541,11 @@ class RecordingCameraController( } private fun discoverBackCameras(provider: ProcessCameraProvider) { + val manager = appContext.getSystemService(Context.CAMERA_SERVICE) as CameraManager if (mainCameraId == null) { mainCameraId = cameraIdForSelector(provider, CameraSelector.DEFAULT_BACK_CAMERA) } + logPublicCameraDiagnostics(provider, manager, mainCameraId) val ultraWideCamera = findUltraWideCamera(provider, mainCameraId) ultraWideCameraId = ultraWideCamera?.cameraId ultraWideZoomRatio = ultraWideCamera?.zoomRatio ?: DEFAULT_ULTRA_WIDE_ZOOM_RATIO @@ -383,6 +581,13 @@ class RecordingCameraController( val candidates = manager.cameraIdList .mapNotNull { cameraId -> backCameraProfile(manager, cameraId) } + .onEach { profile -> + Log.d( + TAG, + "backCamera ${profile.description()} " + + "bindable=${provider.hasCameraSafely(selectorForCameraId(profile.cameraId))}", + ) + } .filter { it.cameraId != excludedCameraId } .filter { provider.hasCameraSafely(selectorForCameraId(it.cameraId)) } .sortedWith( @@ -391,11 +596,16 @@ class RecordingCameraController( ) val mainProfile = excludedCameraId?.let { backCameraProfile(manager, it) } - val widest = candidates.firstOrNull() ?: return null + val widest = + candidates.firstOrNull() + ?: run { + logPhysicalOnlyUltraWideDiagnostics(manager, mainProfile, excludedCameraId) + return null + } val candidatesDesc = - candidates.joinToString { "id=${it.cameraId} fov=${it.horizontalFov} focal=${it.minFocalLength}" } + candidates.joinToString { it.description() } val mainDesc = - mainProfile?.let { "id=${it.cameraId} fov=${it.horizontalFov} focal=${it.minFocalLength}" } + mainProfile?.description() Log.d(TAG, "ultraWide candidates=[$candidatesDesc] main=$mainDesc") if (mainProfile == null) { return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO) @@ -410,10 +620,98 @@ class RecordingCameraController( "(fovFactor=$ULTRA_WIDE_FOV_FACTOR focalFactor=$ULTRA_WIDE_FOCAL_FACTOR)", ) if (!meaningfullyWider) { + logPhysicalOnlyUltraWideDiagnostics(manager, mainProfile, excludedCameraId) return null } - return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO) + return UltraWideCamera(widest.cameraId, estimateUltraWideZoomRatio(widest, mainProfile)) + } + + private fun logPublicCameraDiagnostics( + provider: ProcessCameraProvider, + manager: CameraManager, + mainCameraId: String?, + ) { + Log.d(TAG, "publicCameraIds=[${manager.cameraIdList.joinToString()}] mainCameraId=$mainCameraId") + manager.cameraIdList.forEach { cameraId -> + try { + val characteristics = manager.getCameraCharacteristics(cameraId) + val bindable = provider.hasCameraSafely(selectorForCameraId(cameraId)) + val physicalIds = physicalCameraIds(characteristics) + Log.d( + TAG, + "publicCamera id=$cameraId facing=${lensFacingDescription(characteristics)} " + + "physicalIds=[${physicalIds.joinToString()}] " + + "capabilities=[${capabilitiesDescription(characteristics)}] " + + "zoomRange=${zoomRatioRangeFromCharacteristics(characteristics)?.description()} " + + "focals=${focalLengthsDescription(characteristics)} " + + "sensor=${sensorSizeDescription(characteristics)} bindable=$bindable", + ) + if (cameraId == mainCameraId) { + logMainPhysicalCameraDiagnostics(manager, cameraId, physicalIds) + } + } catch (error: Exception) { + Log.w(TAG, "publicCamera diagnostics failed for cameraId=$cameraId", error) + } + } + } + + private fun logMainPhysicalCameraDiagnostics( + manager: CameraManager, + mainCameraId: String, + physicalIds: Set, + ) { + Log.d(TAG, "mainCamera id=$mainCameraId physicalIds=[${physicalIds.joinToString()}]") + physicalIds.forEach { physicalId -> + try { + val characteristics = manager.getCameraCharacteristics(physicalId) + Log.d( + TAG, + "mainPhysicalCamera id=$physicalId facing=${lensFacingDescription(characteristics)} " + + "zoomRange=${zoomRatioRangeFromCharacteristics(characteristics)?.description()} " + + "focals=${focalLengthsDescription(characteristics)} " + + "sensor=${sensorSizeDescription(characteristics)}", + ) + } catch (error: Exception) { + Log.w(TAG, "mainPhysicalCamera diagnostics failed for physicalId=$physicalId", error) + } + } + } + + private fun logPhysicalOnlyUltraWideDiagnostics( + manager: CameraManager, + mainProfile: CameraProfile?, + mainCameraId: String?, + ) { + if (mainProfile == null || mainCameraId == null) { + return + } + val characteristics = + try { + manager.getCameraCharacteristics(mainCameraId) + } catch (error: Exception) { + Log.w(TAG, "physicalOnlyUltraWide diagnostics failed for mainCameraId=$mainCameraId", error) + return + } + val physicalProfiles = + physicalCameraIds(characteristics) + .mapNotNull { physicalId -> backCameraProfile(manager, physicalId) } + .filter { it.cameraId != mainCameraId } + .sortedBy { it.minFocalLength } + val widestPhysical = physicalProfiles.firstOrNull() + if (widestPhysical == null) { + Log.d(TAG, "physicalOnlyUltraWide none main=${mainProfile.description()}") + return + } + val meaningfullyWider = + widestPhysical.horizontalFov > mainProfile.horizontalFov * ULTRA_WIDE_FOV_FACTOR || + widestPhysical.minFocalLength < mainProfile.minFocalLength * ULTRA_WIDE_FOCAL_FACTOR + Log.d( + TAG, + "physicalOnlyUltraWide widest=${widestPhysical.description()} " + + "main=${mainProfile.description()} meaningfullyWider=$meaningfullyWider " + + "exposedAsBindableCamera=false action=diagnostic_only", + ) } private fun backCameraProfile( @@ -435,13 +733,168 @@ class RecordingCameraController( val minFocalLength = focalLengths.minOrNull() ?: return null val horizontalFov = 2.0 * atan((physicalSize.width / (2.0f * minFocalLength)).toDouble()) - CameraProfile(cameraId, minFocalLength, horizontalFov) + CameraProfile( + cameraId, + minFocalLength, + horizontalFov, + zoomRatioRangeFromCharacteristics(characteristics), + ) } catch (error: Exception) { Log.w(TAG, "backCameraProfile failed for cameraId=$cameraId", error) null } } + private fun mainCameraZoomRatioRange(): ZoomRatioRange? { + val cameraId = mainCameraId ?: activeCameraId() + return zoomRatioRangeForCamera(cameraId) + } + + private fun activeCameraId(): String? { + val boundCamera = camera ?: return null + return try { + Camera2CameraInfo.from(boundCamera.cameraInfo).cameraId + } catch (error: Exception) { + null + } + } + + private fun zoomRatioRangeForCamera(cameraId: String?): ZoomRatioRange? { + if (cameraId == null) return null + return try { + val manager = appContext.getSystemService(Context.CAMERA_SERVICE) as CameraManager + zoomRatioRangeFromCharacteristics(manager.getCameraCharacteristics(cameraId)) + } catch (error: Exception) { + Log.w(TAG, "zoomRatioRangeForCamera failed for cameraId=$cameraId", error) + null + } + } + + private fun zoomRatioRangeFromCharacteristics( + characteristics: CameraCharacteristics, + ): ZoomRatioRange? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return null + } + val range = characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE) + ?: return null + return ZoomRatioRange(range.lower, range.upper) + } + + private fun physicalCameraIds(characteristics: CameraCharacteristics): Set { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return emptySet() + } + return characteristics.physicalCameraIds + } + + private fun lensFacingDescription(characteristics: CameraCharacteristics): String { + return when (val facing = characteristics.get(CameraCharacteristics.LENS_FACING)) { + CameraCharacteristics.LENS_FACING_BACK -> "BACK" + CameraCharacteristics.LENS_FACING_FRONT -> "FRONT" + CameraCharacteristics.LENS_FACING_EXTERNAL -> "EXTERNAL" + null -> "null" + else -> "UNKNOWN($facing)" + } + } + + private fun capabilitiesDescription(characteristics: CameraCharacteristics): String { + val capabilities = + characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) + ?: return "null" + return capabilities.joinToString { capabilityDescription(it) } + } + + private fun capabilityDescription(capability: Int): String { + return when (capability) { + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE -> + "BACKWARD_COMPATIBLE" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MANUAL_SENSOR -> "MANUAL_SENSOR" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MANUAL_POST_PROCESSING -> + "MANUAL_POST_PROCESSING" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW -> "RAW" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_PRIVATE_REPROCESSING -> + "PRIVATE_REPROCESSING" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_READ_SENSOR_SETTINGS -> + "READ_SENSOR_SETTINGS" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BURST_CAPTURE -> "BURST_CAPTURE" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_YUV_REPROCESSING -> + "YUV_REPROCESSING" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_DEPTH_OUTPUT -> "DEPTH_OUTPUT" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_CONSTRAINED_HIGH_SPEED_VIDEO -> + "CONSTRAINED_HIGH_SPEED_VIDEO" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MOTION_TRACKING -> "MOTION_TRACKING" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA -> + "LOGICAL_MULTI_CAMERA" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MONOCHROME -> "MONOCHROME" + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_SECURE_IMAGE_DATA -> "SECURE_IMAGE_DATA" + else -> "UNKNOWN($capability)" + } + } + + private fun focalLengthsDescription(characteristics: CameraCharacteristics): String { + val focalLengths = + characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) + ?: return "null" + return focalLengths.joinToString(prefix = "[", postfix = "]") + } + + private fun sensorSizeDescription(characteristics: CameraCharacteristics): String { + val size = characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) + ?: return "null" + return "${size.width}x${size.height}" + } + + private fun estimateUltraWideZoomRatio( + ultraWide: CameraProfile, + main: CameraProfile, + ): Float { + if (main.minFocalLength <= 0f) { + return DEFAULT_ULTRA_WIDE_ZOOM_RATIO + } + val rawRatio = ultraWide.minFocalLength / main.minFocalLength + val roundedRatio = round(rawRatio * 10f) / 10f + return roundedRatio.coerceIn(MIN_ULTRA_WIDE_ZOOM_RATIO, MAX_ULTRA_WIDE_ZOOM_RATIO) + } + + @androidx.annotation.OptIn(ExperimentalCamera2Interop::class) + private fun applyCamera2ZoomRatio( + boundCamera: Camera, + zoomRatio: Float, + ) = + Camera2CameraControl.from(boundCamera.cameraControl) + .setCaptureRequestOptions( + CaptureRequestOptions.Builder() + .setCaptureRequestOption( + CaptureRequest.CONTROL_ZOOM_RATIO, + zoomRatio, + ) + .build(), + ) + + private fun isSupersededCamera2ZoomRequest(error: Exception): Boolean { + val cause = + if (error is ExecutionException) { + error.cause + } else { + error + } + return cause is CameraControl.OperationCanceledException && + cause.message?.contains("updated with new options") == true + } + + private fun logSetZoomResult( + requestedRatio: Double, + nextZoom: Float, + capabilities: Map, + ) { + Log.d( + TAG, + "setZoomRatio result requestedRatio=$requestedRatio nextZoom=$nextZoom " + + "currentZoomRatio=$currentZoomRatio returnedZoom=${capabilities["zoomRatio"]}", + ) + } + private fun selectorForCurrentLensMode(): CameraSelector { val cameraId = if (currentLensMode == LensMode.ULTRA_WIDE) { @@ -496,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 @@ -530,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 @@ -554,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) @@ -571,16 +1084,43 @@ class RecordingCameraController( val cameraId: String, val minFocalLength: Float, val horizontalFov: Double, - ) + val zoomRatioRange: ZoomRatioRange?, + ) { + fun description(): String { + return "id=$cameraId fov=$horizontalFov focal=$minFocalLength " + + "zoomRange=${zoomRatioRange?.description()}" + } + } + + private data class ZoomRatioRange( + val lower: Float, + val upper: Float, + ) { + fun contains(ratio: Float): Boolean { + return ratio >= lower && ratio <= upper + } + + fun description(): String { + return "$lower..$upper" + } + } private data class UltraWideCamera( val cameraId: String, 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 + private const val MIN_ULTRA_WIDE_ZOOM_RATIO = 0.3f + private const val MAX_ULTRA_WIDE_ZOOM_RATIO = 0.99f // 适度放宽判定宽容度,覆盖更多机型(更小的 FOV/焦距差异也视为超广角)。 private const val ULTRA_WIDE_FOV_FACTOR = 1.04 private const val ULTRA_WIDE_FOCAL_FACTOR = 0.96 diff --git a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingOutputFactory.kt b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingOutputFactory.kt index 7eabb4c..f5f25ad 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingOutputFactory.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/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/dronex/rec/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt index 8226430..9bf22f2 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/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/dronex/rec/recording/RecordingSegmentMuxer.kt b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingSegmentMuxer.kt new file mode 100644 index 0000000..83b9c18 --- /dev/null +++ b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingSegmentMuxer.kt @@ -0,0 +1,174 @@ +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, + 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/ios/Runner/PlatformInfoPlugin.swift b/ios/Runner/PlatformInfoPlugin.swift index a6ee22d..f219908 100644 --- a/ios/Runner/PlatformInfoPlugin.swift +++ b/ios/Runner/PlatformInfoPlugin.swift @@ -4,7 +4,7 @@ import UIKit final class PlatformInfoPlugin: NSObject, FlutterPlugin { static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( - name: "com.dronex.rec/platform_info", + name: "app.record_tool/platform_info", binaryMessenger: registrar.messenger() ) let plugin = PlatformInfoPlugin() diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift index 83b3353..6b8e53b 100644 --- a/ios/Runner/RecordingPlugin.swift +++ b/ios/Runner/RecordingPlugin.swift @@ -633,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco } private enum RecordingChannelNames { - static let packageName = "com.dronex.rec" - static let method = "\(packageName)/recording" - static let events = "\(packageName)/recording_events" + static let namespace = "app.record_tool" + static let method = "\(namespace)/recording" + static let events = "\(namespace)/recording_events" } final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { diff --git a/lib/core/platform/app_platform_info.dart b/lib/core/platform/app_platform_info.dart index f1aa85c..3f70f66 100644 --- a/lib/core/platform/app_platform_info.dart +++ b/lib/core/platform/app_platform_info.dart @@ -60,7 +60,7 @@ class AppPlatformInfo { AppPlatformInfo._(); static const MethodChannel _channel = MethodChannel( - 'com.dronex.rec/platform_info', + 'app.record_tool/platform_info', ); static Future packageInfo() async { diff --git a/lib/features/recording/model/model_recording_session.dart b/lib/features/recording/model/model_recording_session.dart index 9a06e2c..5e25176 100644 --- a/lib/features/recording/model/model_recording_session.dart +++ b/lib/features/recording/model/model_recording_session.dart @@ -7,6 +7,7 @@ class RecordingSessionState { this.isTouchLocked = true, this.isPreviewReady = false, this.isStartingRecording = false, + this.isSwitchingLens = false, this.hasDndAccess = false, this.isBatteryOptimizedIgnored = true, this.notificationsGranted = true, @@ -19,12 +20,14 @@ class RecordingSessionState { this.errorMessage, this.permissionWarning, this.fileSaveFailed = false, + this.segmentOutputPaths = const [], }); final RecordingStatus status; final bool isTouchLocked; final bool isPreviewReady; final bool isStartingRecording; + final bool isSwitchingLens; final bool hasDndAccess; final bool isBatteryOptimizedIgnored; final bool notificationsGranted; @@ -37,6 +40,7 @@ class RecordingSessionState { final String? errorMessage; final String? permissionWarning; final bool fileSaveFailed; + final List segmentOutputPaths; bool get isRecording => status.isRecording; @@ -53,6 +57,7 @@ class RecordingSessionState { bool? isTouchLocked, bool? isPreviewReady, bool? isStartingRecording, + bool? isSwitchingLens, bool? hasDndAccess, bool? isBatteryOptimizedIgnored, bool? notificationsGranted, @@ -65,6 +70,7 @@ class RecordingSessionState { String? errorMessage, String? permissionWarning, bool? fileSaveFailed, + List? segmentOutputPaths, bool clearPermissionWarning = false, bool clearLastSaved = false, }) { @@ -73,6 +79,7 @@ class RecordingSessionState { isTouchLocked: isTouchLocked ?? this.isTouchLocked, isPreviewReady: isPreviewReady ?? this.isPreviewReady, isStartingRecording: isStartingRecording ?? this.isStartingRecording, + isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens, hasDndAccess: hasDndAccess ?? this.hasDndAccess, isBatteryOptimizedIgnored: isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, @@ -90,6 +97,7 @@ class RecordingSessionState { ? null : (permissionWarning ?? this.permissionWarning), fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed, + segmentOutputPaths: segmentOutputPaths ?? this.segmentOutputPaths, ); } } diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 7841308..803e28a 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -173,7 +173,11 @@ class _RecordingPageState extends ConsumerState { if (!mounted) return; final latest = ref.read(recordingViewModelProvider).session; if (latest.fileSaveFailed) { - AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); + if (latest.segmentOutputPaths.isNotEmpty) { + AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看'); + } else { + AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); + } return; } await _showRecordingSavedDialogIfNeeded(); @@ -374,6 +378,7 @@ class _RecordingHudLayer extends ConsumerWidget { m.session.notificationsGranted, m.session.isRecording, m.session.isStartingRecording, + m.session.isSwitchingLens, m.session.isTouchLocked, m.session.zoomRatio, m.session.minZoomRatio, @@ -391,6 +396,7 @@ class _RecordingHudLayer extends ConsumerWidget { notificationsGranted, isRecording, isStartingRecording, + isSwitchingLens, isTouchLocked, zoomRatio, minZoomRatio, @@ -408,6 +414,7 @@ class _RecordingHudLayer extends ConsumerWidget { notificationsGranted: notificationsGranted, isRecording: isRecording, isStartingRecording: isStartingRecording, + isSwitchingLens: isSwitchingLens, isTouchLocked: isTouchLocked, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, diff --git a/lib/features/recording/platform/recording_channel_names.dart b/lib/features/recording/platform/recording_channel_names.dart index e0293da..4d07f93 100644 --- a/lib/features/recording/platform/recording_channel_names.dart +++ b/lib/features/recording/platform/recording_channel_names.dart @@ -1,5 +1,5 @@ abstract final class RecordingChannelNames { - static const packageName = 'com.dronex.rec'; - static const method = '$packageName/recording'; - static const events = '$packageName/recording_events'; + static const namespace = 'app.record_tool'; + static const method = '$namespace/recording'; + static const events = '$namespace/recording_events'; } 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..d4fe6e6 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -349,10 +349,14 @@ class RecordingViewModel extends Notifier { /// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。 Future setZoomRatio(double ratio) async { final session = state.session; + if (session.isSwitchingLens) { + return; + } final clamped = ratio .clamp(session.minZoomRatio, session.maxZoomRatio) .toDouble(); + _updateSession((s) => s.copyWith(isSwitchingLens: true)); try { final zoom = await RecordingPlatform.setZoomRatio(clamped); _updateSession( @@ -364,8 +368,13 @@ class RecordingViewModel extends Notifier { ), ); } on PlatformException catch (error) { + final message = error.code == 'ZOOM_FAILED' + ? '切换镜头失败,请重试' + : (error.message ?? '相机倍距设置失败'); + _updateSession((s) => s.copyWith(errorMessage: message)); + } finally { _updateSession( - (s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'), + (s) => s.copyWith(isSwitchingLens: false, errorMessage: s.errorMessage), ); } } @@ -373,7 +382,9 @@ class RecordingViewModel extends Notifier { /// 开始录制,可选开启勿扰模式。 Future startRecording({bool enableDoNotDisturb = true}) async { final session = state.session; - if (session.isRecording || session.isStartingRecording) { + if (session.isRecording || + session.isStartingRecording || + session.isSwitchingLens) { return; } if (!session.isPreviewReady) { @@ -400,6 +411,7 @@ class RecordingViewModel extends Notifier { isTouchLocked: true, errorMessage: null, fileSaveFailed: false, + segmentOutputPaths: const [], clearLastSaved: true, ), ); @@ -414,7 +426,7 @@ class RecordingViewModel extends Notifier { /// 停止录制、保存到文件夹,并恢复相机预览。 Future stopRecording() async { - if (!state.session.isRecording) return; + if (!state.session.isRecording || state.session.isSwitchingLens) return; try { final result = await RecordingPlatform.stopRecording(); @@ -431,6 +443,7 @@ class RecordingViewModel extends Notifier { ? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限') : null, fileSaveFailed: fileFailed, + segmentOutputPaths: result.segmentOutputPaths, ), ); } on PlatformException catch (error) { diff --git a/lib/features/recording/widgets/widget_recording_hud.dart b/lib/features/recording/widgets/widget_recording_hud.dart index 10d25af..ab76bc3 100644 --- a/lib/features/recording/widgets/widget_recording_hud.dart +++ b/lib/features/recording/widgets/widget_recording_hud.dart @@ -18,6 +18,7 @@ class RecordingHudWidget extends StatelessWidget { required this.notificationsGranted, required this.isRecording, required this.isStartingRecording, + required this.isSwitchingLens, required this.isTouchLocked, this.showClipboardHint = false, this.clipboardAddress = '', @@ -39,6 +40,7 @@ class RecordingHudWidget extends StatelessWidget { final bool notificationsGranted; final bool isRecording; final bool isStartingRecording; + final bool isSwitchingLens; final bool isTouchLocked; final bool showClipboardHint; final String clipboardAddress; @@ -56,7 +58,6 @@ class RecordingHudWidget extends StatelessWidget { static double get _recordButtonBottom => 63.r; static double get _overlayInfoLeft => 13.r; static double get _overlayInfoBottom => 10.r; - static const List _zoomPresets = [0.6, 1.0]; @override Widget build(BuildContext context) { @@ -144,13 +145,13 @@ class RecordingHudWidget extends StatelessWidget { ), Positioned( right: 16.r, - bottom: _recordButtonBottom + _recordButtonSize + 14.h, + bottom: 260.r, child: _ZoomPresetControl( - isRecording: isRecording, + enabled: !isSwitchingLens, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, maxZoomRatio: maxZoomRatio, - presets: _zoomPresets, + presets: _zoomPresetsForRange(minZoomRatio), onSelected: onZoomSelected, ), ), @@ -162,7 +163,7 @@ class RecordingHudWidget extends StatelessWidget { child: RecordingControlButton( isRecording: isRecording, isStartingRecording: isStartingRecording, - enabled: !isStartingRecording, + enabled: !isStartingRecording && !isSwitchingLens, size: _recordButtonSize, onTap: () { if (isRecording) { @@ -191,11 +192,15 @@ class RecordingHudWidget extends StatelessWidget { ], ); } + + List _zoomPresetsForRange(double minZoomRatio) { + return [if (minZoomRatio < 1.0) minZoomRatio, 1.0]; + } } class _ZoomPresetControl extends StatelessWidget { const _ZoomPresetControl({ - required this.isRecording, + required this.enabled, required this.zoomRatio, required this.minZoomRatio, required this.maxZoomRatio, @@ -203,7 +208,7 @@ class _ZoomPresetControl extends StatelessWidget { required this.onSelected, }); - final bool isRecording; + final bool enabled; final double zoomRatio; final double minZoomRatio; final double maxZoomRatio; @@ -215,7 +220,6 @@ class _ZoomPresetControl extends StatelessWidget { final availablePresets = presets .where(_isPresetAvailable) .toList(growable: false); - if (availablePresets.isEmpty) { return const SizedBox.shrink(); } @@ -236,7 +240,7 @@ class _ZoomPresetControl extends StatelessWidget { displayRatio: preset, requestRatio: preset, selected: _isPresetSelected(preset), - enabled: !_wouldSwitchPhysicalCamera(preset), + enabled: enabled, onSelected: onSelected, ), ], @@ -258,15 +262,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 { @@ -289,7 +284,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, @@ -303,7 +308,7 @@ class _ZoomPresetButton extends StatelessWidget { ), ), child: Text( - '${_formatZoomRatio(displayRatio)}x', + _formatZoomRatio(displayRatio), style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w700, @@ -315,9 +320,12 @@ class _ZoomPresetButton extends StatelessWidget { } String _formatZoomRatio(double ratio) { - if (ratio == ratio.roundToDouble()) { - return ratio.toStringAsFixed(0); + if (ratio < 1.0) { + return '广角'; } - return ratio.toStringAsFixed(1); + if (ratio == ratio.roundToDouble()) { + return '${ratio.toStringAsFixed(0)}x'; + } + return '${ratio.toStringAsFixed(1)}x'; } } diff --git a/test/features/recording/recording_platform_test.dart b/test/features/recording/recording_platform_test.dart index 54bd537..9774d92 100644 --- a/test/features/recording/recording_platform_test.dart +++ b/test/features/recording/recording_platform_test.dart @@ -1,7 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:recording_tool/features/recording/platform/recording_channel_names.dart'; import 'package:recording_tool/features/recording/platform/recording_platform.dart'; void main() { + group('RecordingChannelNames', () { + test('uses stable bridge names without package binding', () { + expect(RecordingChannelNames.method, 'app.record_tool/recording'); + expect(RecordingChannelNames.events, 'app.record_tool/recording_events'); + }); + }); + group('RecordingPlatform support', () { test('supports Android and iOS hosts only', () { expect( diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index 8ac9ad2..25dde3f 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/platform/recording_channel_names.dart'; +import 'package:recording_tool/features/recording/platform/recording_platform.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; void main() { @@ -76,45 +79,42 @@ void main() { expect(session.errorMessage, isNull); }); - test( - 'clamps legacy 0.5x request to 0.6x ultra-wide ratio', - () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel(RecordingChannelNames.method), - (call) async { - calls.add(call); - return { - 'zoomRatio': 0.6, - 'minZoomRatio': 0.6, - 'maxZoomRatio': 3.0, - }; - }, - ); - final container = ProviderContainer(); - addTearDown(container.dispose); - final notifier = container.read(recordingViewModelProvider.notifier); - // ignore: invalid_use_of_protected_member - notifier.state = container - .read(recordingViewModelProvider) - .copyWith( - session: const RecordingSessionState( - zoomRatio: 1.0, - minZoomRatio: 0.6, - maxZoomRatio: 3.0, - ), - ); + test('passes 0.5x to native when camera capabilities allow it', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + calls.add(call); + return { + 'zoomRatio': 0.5, + 'minZoomRatio': 0.5, + 'maxZoomRatio': 3.0, + }; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(recordingViewModelProvider.notifier); + // ignore: invalid_use_of_protected_member + notifier.state = container + .read(recordingViewModelProvider) + .copyWith( + session: const RecordingSessionState( + zoomRatio: 1.0, + minZoomRatio: 0.5, + maxZoomRatio: 3.0, + ), + ); - await notifier.setZoomRatio(0.5); + await notifier.setZoomRatio(0.5); - expect(calls.single.arguments, {'zoomRatio': 0.6}); - final session = container.read(recordingViewModelProvider).session; - expect(session.zoomRatio, 0.6); - expect(session.minZoomRatio, 0.6); - expect(session.maxZoomRatio, 3.0); - }, - ); + expect(calls.single.arguments, {'zoomRatio': 0.5}); + final session = container.read(recordingViewModelProvider).session; + expect(session.zoomRatio, 0.5); + expect(session.minZoomRatio, 0.5); + expect(session.maxZoomRatio, 3.0); + }); test('passes 0.6x to native when camera capabilities allow it', () async { final calls = []; @@ -229,7 +229,152 @@ 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, '切换镜头失败,请重试'); + }, + ); + + test('sets switching lens while native zoom request is pending', () async { + final completer = Completer>(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) => completer.future, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(recordingViewModelProvider.notifier); + + final future = notifier.setZoomRatio(2); + await Future.delayed(Duration.zero); + + expect( + container.read(recordingViewModelProvider).session.isSwitchingLens, + isTrue, + ); + + completer.complete({ + 'zoomRatio': 2.0, + 'minZoomRatio': 1.0, + 'maxZoomRatio': 3.0, + }); + await future; + + expect( + container.read(recordingViewModelProvider).session.isSwitchingLens, + isFalse, + ); + }); + }); + + group('RecordingViewModel.stopRecording', () { + test('does not call native stop while switching lens', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + calls.add(call); + return {}; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(recordingViewModelProvider.notifier); + // ignore: invalid_use_of_protected_member + notifier.state = container + .read(recordingViewModelProvider) + .copyWith( + session: const RecordingSessionState( + status: RecordingStatus(state: RecordingState.recording), + isSwitchingLens: true, + ), + ); + + await notifier.stopRecording(); + + expect(calls, isEmpty); + }); + + test( + 'stores segment output paths when native save falls back to parts', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(RecordingChannelNames.method), + (call) async { + switch (call.method) { + case 'stopRecording': + return { + 'outputPath': 'content://recordings/part1', + 'status': { + 'state': 'error', + 'message': 'Merge failed', + }, + 'fileSaved': false, + 'fileErrorMessage': 'Merge failed', + 'segmentOutputPaths': [ + 'content://recordings/part1', + 'content://recordings/part2', + ], + }; + case 'initializePreview': + return {'state': 'previewing'}; + case 'getZoomCapabilities': + return { + 'zoomRatio': 1.0, + 'minZoomRatio': 1.0, + 'maxZoomRatio': 3.0, + }; + } + return {}; + }, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(recordingViewModelProvider.notifier); + // ignore: invalid_use_of_protected_member + notifier.state = container + .read(recordingViewModelProvider) + .copyWith( + session: const RecordingSessionState( + status: RecordingStatus(state: RecordingState.recording), + ), + ); + + await notifier.stopRecording(); + + final session = container.read(recordingViewModelProvider).session; + expect(session.fileSaveFailed, isTrue); + expect(session.segmentOutputPaths, [ + 'content://recordings/part1', + 'content://recordings/part2', + ]); }, ); }); diff --git a/test/features/recording/widget_recording_hud_test.dart b/test/features/recording/widget_recording_hud_test.dart index 382e9bb..867f036 100644 --- a/test/features/recording/widget_recording_hud_test.dart +++ b/test/features/recording/widget_recording_hud_test.dart @@ -10,6 +10,8 @@ void main() { double minZoomRatio = 1.0, double maxZoomRatio = 3.0, bool isRecording = false, + bool isSwitchingLens = false, + Future Function()? onStop, ValueChanged? onZoomSelected, }) async { await tester.pumpWidget( @@ -25,12 +27,13 @@ void main() { notificationsGranted: true, isRecording: isRecording, isStartingRecording: false, + isSwitchingLens: isSwitchingLens, isTouchLocked: false, zoomRatio: zoomRatio, minZoomRatio: minZoomRatio, maxZoomRatio: maxZoomRatio, onStart: () async {}, - onStop: () async {}, + onStop: onStop ?? () async {}, onOpenDnd: () {}, onOpenBattery: () {}, onToggleTouchLock: () {}, @@ -47,50 +50,50 @@ void main() { testWidgets('shows preset zoom buttons', (tester) async { await pumpHud(tester); - expect(find.text('0.5x'), findsNothing); - expect(find.text('0.6x'), findsNothing); + expect(find.text('广角'), findsNothing); expect(find.text('1x'), findsOneWidget); expect(find.text('2x'), findsNothing); expect(find.text('3x'), findsNothing); }); - testWidgets('shows 0.6x when ultra-wide camera capability is below 0.6', ( + testWidgets('shows wide angle when ultra-wide camera capability is 0.5', ( tester, ) async { await pumpHud(tester, minZoomRatio: 0.5); - expect(find.text('0.5x'), findsNothing); - expect(find.text('0.6x'), findsOneWidget); + expect(find.text('广角'), findsOneWidget); expect(find.text('1x'), findsOneWidget); expect(find.text('2x'), findsNothing); expect(find.text('3x'), findsNothing); }); - testWidgets('shows 0.6x when 0.6x camera capability supports it', ( + testWidgets('shows wide angle when 0.6x camera capability supports it', ( tester, ) async { await pumpHud(tester, minZoomRatio: 0.6); - expect(find.text('0.6x'), findsOneWidget); + expect(find.text('广角'), findsOneWidget); expect(find.text('1x'), findsOneWidget); }); - testWidgets('marks current ultra-wide zoom ratio as selected on 0.6x UI', ( + testWidgets('marks current 0.5x wide angle ratio as selected', ( tester, ) async { await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5); final selectedButton = tester.widget( - find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), + find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), ); expect(selectedButton.enabled, isFalse); }); - testWidgets('marks current 0.6x zoom ratio as selected', (tester) async { + testWidgets('marks current 0.6x wide angle ratio as selected', ( + tester, + ) async { await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6); final selectedButton = tester.widget( - find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), + find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), ); expect(selectedButton.enabled, isFalse); }); @@ -98,11 +101,11 @@ void main() { testWidgets('does not expose presets beyond max zoom ratio', (tester) async { await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55); - expect(find.text('0.6x'), findsNothing); + expect(find.text('广角'), findsOneWidget); expect(find.text('1x'), findsNothing); }); - testWidgets('tapping 0.6x reports 0.6 when camera capability is below 0.6', ( + testWidgets('tapping 0.5x reports 0.5 when camera capability is 0.5', ( tester, ) async { double? selected; @@ -112,10 +115,10 @@ void main() { onZoomSelected: (ratio) => selected = ratio, ); - await tester.tap(find.text('0.6x')); - await tester.pump(); + await tester.tap(find.text('广角')); + await tester.pump(const Duration(milliseconds: 350)); - expect(selected, 0.6); + expect(selected, 0.5); }); testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', ( @@ -128,39 +131,101 @@ void main() { onZoomSelected: (ratio) => selected = ratio, ); - await tester.tap(find.text('0.6x')); - await tester.pump(); + await tester.tap(find.text('广角')); + await tester.pump(const Duration(milliseconds: 350)); expect(selected, 0.6); }); - testWidgets('disables 0.6x while recording on main camera', (tester) async { - await pumpHud(tester, minZoomRatio: 0.5, isRecording: true); - - final ultraWideButton = tester.widget( - find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), - ); - final mainButton = tester.widget( - find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), - ); - - expect(ultraWideButton.enabled, isFalse); - expect(mainButton.enabled, isFalse); - }); - - testWidgets('disables main zoom presets while recording on ultra-wide', ( + testWidgets('allows 0.6x while recording on main camera after unlock', ( tester, ) async { - await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true); + 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)), + find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), + ); + final mainButton = tester.widget( + find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), + ); + + expect(ultraWideButton.enabled, isTrue); + expect(mainButton.enabled, isFalse); + + await tester.tap(find.text('广角')); + await tester.pump(const Duration(milliseconds: 350)); + + expect(selected, 0.6); + }); + + testWidgets('allows 1x while recording on ultra-wide after unlock', ( + tester, + ) async { + 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('广角'), matching: find.byType(TextButton)), ); final mainButton = tester.widget( find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), ); expect(ultraWideButton.enabled, isFalse); - expect(mainButton.enabled, isFalse); + expect(mainButton.enabled, isTrue); + + await tester.tap(find.text('1x')); + await tester.pump(const Duration(milliseconds: 350)); + + expect(selected, 1.0); + }); + + testWidgets('disables stop button while switching lens', (tester) async { + var stopped = false; + await pumpHud( + tester, + minZoomRatio: 0.6, + isRecording: true, + isSwitchingLens: true, + onStop: () async => stopped = true, + ); + + await tester.tap(find.byType(GestureDetector).last); + await tester.pump(); + + expect(stopped, isFalse); + }); + + testWidgets('disables zoom buttons while switching lens', (tester) async { + double? selected; + await pumpHud( + tester, + minZoomRatio: 0.6, + isRecording: true, + isSwitchingLens: true, + onZoomSelected: (ratio) => selected = ratio, + ); + + final ultraWideButton = tester.widget( + find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)), + ); + + expect(ultraWideButton.enabled, isFalse); + await tester.tap(find.text('广角')); + await tester.pump(const Duration(milliseconds: 350)); + + expect(selected, isNull); }); }