14 Commits
Author SHA1 Message Date
linfeng 4a85b9f83e Merge run recording updates into drone branch 2026-07-01 11:54:06 +08:00
linfeng e4057c3d6a 添加 CLAUDE.md 到 .gitignore 文件 2026-07-01 11:32:22 +08:00
linfeng 90e8966528 重构通道名称,在Android和iOS实现中使用统一的命名空间‘app.record_tool’。相应地更新相关的常量和方法通道。增强在录制HUD中的缩放比例显示格式。 2026-07-01 11:19:30 +08:00
linfeng c8a7494344 1.广角/主摄切换按钮已上移:
2.新增 isSwitchingLens 状态、避免切换镜头    stop 按钮禁用,不会触发 stopRecording
倍距按钮禁用,避免连续切换
ViewModel 层也兜底:isSwitchingLens=true 时直接拒绝 stop
3.合并失败兜底提示
2026-07-01 10:59:28 +08:00
linfeng 02614c2817 更新 .gitignore 文件以排除 CLAUDE.md,并注释掉录制头部小部件中的 mock 按钮 2026-07-01 10:38:50 +08:00
linfeng 9f49f73a31 删除 agent 说明书 2026-07-01 10:37:09 +08:00
linfeng da2e69b014 支持录制中切换广角/主摄 2026-07-01 09:59:21 +08:00
linfeng f6347e99cd 兼容 IOS 2026-06-30 17:28:03 +08:00
linfeng d40c13d802 兼容 IOS 2026-06-30 17:27:54 +08:00
linfeng 23319cfddb 修复部分机型无法切换广角和主摄功能 2026-06-30 17:06:35 +08:00
linfeng 6d93c1c8dd 解决安卓端广角无法读取问题 2026-06-30 16:30:24 +08:00
linfeng c9323f26a7 启动图修改 2026-06-30 14:19:02 +08:00
linfeng 7dafd61314 修改图标 2026-06-30 13:57:18 +08:00
linfeng d056d28d83 1.更换包名
2.更换 APP 名字
2026-06-30 13:38:16 +08:00
18 changed files with 1220 additions and 187 deletions
+1
View File
@@ -48,3 +48,4 @@ app.*.map.json
/android/app/release /android/app/release
/android/.kotlin /android/.kotlin
CLAUDE.md
@@ -1,10 +1,10 @@
package com.dronex.rec package com.dronex.rec
object AppConstants { object AppConstants {
const val PACKAGE_NAME = "com.dronex.rec" private const val CHANNEL_NAMESPACE = "app.record_tool"
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info" const val PLATFORM_INFO_CHANNEL = "$CHANNEL_NAMESPACE/platform_info"
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording" const val RECORDING_METHOD_CHANNEL = "$CHANNEL_NAMESPACE/recording"
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events" const val RECORDING_EVENT_CHANNEL = "$CHANNEL_NAMESPACE/recording_events"
const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START" const val RECORDING_ACTION_START = "$CHANNEL_NAMESPACE.recording.START"
const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP" const val RECORDING_ACTION_STOP = "$CHANNEL_NAMESPACE.recording.STOP"
} }
@@ -3,9 +3,15 @@ package com.dronex.rec.recording
import android.content.Context import android.content.Context
import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.os.Build
import android.util.Log import android.util.Log
import androidx.camera.camera2.interop.Camera2CameraControl
import androidx.camera.camera2.interop.Camera2CameraInfo 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.Camera
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
@@ -18,13 +24,18 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import kotlin.math.atan import java.io.File
import java.util.concurrent.Executor 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( class RecordingCameraController(
private val appContext: Context, private val appContext: Context,
) { ) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext) private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
private val ioExecutor = Executors.newSingleThreadExecutor()
private var cameraProvider: ProcessCameraProvider? = null private var cameraProvider: ProcessCameraProvider? = null
private var preview: Preview? = null private var preview: Preview? = null
@@ -37,6 +48,12 @@ class RecordingCameraController(
private var activeRecording: Recording? = null private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null private var boundLifecycleOwner: LifecycleOwner? = null
private var currentZoomRatio: Float = 1f private var currentZoomRatio: Float = 1f
private var activeWithAudio: Boolean = true
private var activeDisplayName: String? = null
private var currentSegmentFile: File? = null
private val segmentFiles = mutableListOf<File>()
private var nextSegmentIndex: Int = 1
private var isSwitchingLens: Boolean = false
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE) var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
private set private set
@@ -45,7 +62,9 @@ class RecordingCameraController(
private var recordingStartedAt: Long = 0L private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null private var latestOutputPath: String? = null
private var latestSegmentOutputPaths: List<String> = emptyList()
private var pendingStopCallback: ((String?) -> Unit)? = null private var pendingStopCallback: ((String?) -> Unit)? = null
private var pendingSegmentSwitch: PendingSegmentSwitch? = null
fun bindPreview( fun bindPreview(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@@ -134,21 +153,55 @@ class RecordingCameraController(
return return
} }
if (activeRecording != null || isSwitchingLens) {
onStarted(false, "Already recording")
return
}
activeWithAudio = withAudio
activeDisplayName = displayName
segmentFiles.clear()
nextSegmentIndex = 1
latestOutputPath = null
latestSegmentOutputPaths = emptyList()
recordingStartedAt = System.currentTimeMillis()
updateStatus(
RecordingStatus(
RecordingState.RECORDING,
outputPath = latestOutputPath,
),
)
startNewSegment(updateStatusOnStart = false, onStarted = onStarted)
}
private fun startNewSegment(
updateStatusOnStart: Boolean,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
onStarted(false, "Camera not ready")
return
}
if (activeRecording != null) { if (activeRecording != null) {
onStarted(false, "Already recording") onStarted(false, "Already recording")
return return
} }
val outputOptions = val segmentFile =
RecordingOutputFactory.buildMediaStoreOutputOptions( RecordingOutputFactory.createSegmentFile(
appContext, appContext,
displayName, activeDisplayName,
nextSegmentIndex++,
) )
latestOutputPath = null currentSegmentFile = segmentFile
val outputOptions = RecordingOutputFactory.buildSegmentOutputOptions(segmentFile)
val pending = val pending =
capture.output.prepareRecording(appContext, outputOptions).apply { capture.output.prepareRecording(appContext, outputOptions).apply {
if (withAudio) { if (activeWithAudio) {
val granted = val granted =
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
appContext, appContext,
@@ -160,48 +213,25 @@ class RecordingCameraController(
} }
} }
recordingStartedAt = System.currentTimeMillis() if (updateStatusOnStart) {
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.RECORDING, RecordingState.RECORDING,
outputPath = latestOutputPath, outputPath = latestOutputPath,
elapsedMillis = elapsedMillis(),
), ),
) )
}
activeRecording = activeRecording =
pending.start(mainExecutor) { event -> pending.start(mainExecutor) { event ->
when (event) { when (event) {
is VideoRecordEvent.Start -> Unit is VideoRecordEvent.Start -> Unit
is VideoRecordEvent.Finalize -> { is VideoRecordEvent.Finalize -> handleFinalize(event, segmentFile)
activeRecording = null
if (event.hasError()) {
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = event.cause?.message
?: "Recording failed",
),
)
} else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis =
System.currentTimeMillis() -
recordingStartedAt,
),
)
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
stopCallback?.invoke(latestOutputPath)
}
} }
} }
onStarted(true, latestOutputPath ?: "recording") onStarted(true, segmentFile.absolutePath)
} }
fun stopRecording(onStopped: (String?) -> Unit) { fun stopRecording(onStopped: (String?) -> Unit) {
@@ -210,12 +240,18 @@ class RecordingCameraController(
onStopped(latestOutputPath) onStopped(latestOutputPath)
return return
} }
if (isSwitchingLens) {
onStopped(null)
return
}
pendingStopCallback = onStopped pendingStopCallback = onStopped
val elapsed = elapsedMillis()
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.STOPPING, RecordingState.STOPPING,
outputPath = latestOutputPath, outputPath = latestOutputPath,
elapsedMillis = elapsed,
), ),
) )
@@ -223,27 +259,144 @@ class RecordingCameraController(
activeRecording = null activeRecording = null
} }
private fun handleFinalize(
event: VideoRecordEvent.Finalize,
segmentFile: File,
) {
activeRecording = null
if (!event.hasError() && segmentFile.exists() && segmentFile.length() > 0L) {
segmentFiles.add(segmentFile)
}
val segmentSwitch = pendingSegmentSwitch
if (segmentSwitch != null) {
pendingSegmentSwitch = null
if (event.hasError()) {
isSwitchingLens = false
val message = event.cause?.message ?: "Recording segment failed"
updateStatus(RecordingStatus(RecordingState.ERROR, message = message))
segmentSwitch.onComplete(false, zoomCapabilitiesMap(), message)
return
}
continueSegmentSwitch(segmentSwitch)
return
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
if (stopCallback == null) {
return
}
if (event.hasError()) {
val message = event.cause?.message ?: "Recording failed"
updateStatus(RecordingStatus(RecordingState.ERROR, message = message))
stopCallback.invoke(null)
return
}
finalizeRecordingOutput(stopCallback)
}
private fun finalizeRecordingOutput(stopCallback: (String?) -> Unit) {
val segments = segmentFiles.toList()
val displayName = activeDisplayName
ioExecutor.execute {
try {
val mergedFile = RecordingOutputFactory.createMergeFile(appContext, displayName)
RecordingSegmentMuxer.mergeOrCopy(segments, mergedFile)
val outputUri =
RecordingOutputFactory.publishToMediaStore(
appContext,
displayName,
mergedFile,
)
latestOutputPath = outputUri
latestSegmentOutputPaths = emptyList()
cleanupSegmentFiles(segments + mergedFile)
mainExecutor.execute {
val elapsed = System.currentTimeMillis() - recordingStartedAt
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis = elapsed,
),
)
stopCallback.invoke(latestOutputPath)
}
} catch (error: Exception) {
Log.e(TAG, "finalizeRecordingOutput failed", error)
val publishedParts = publishSegmentsAfterMergeFailure(segments, displayName)
latestOutputPath = publishedParts.firstOrNull()
latestSegmentOutputPaths = publishedParts
mainExecutor.execute {
updateStatus(
RecordingStatus(
RecordingState.ERROR,
outputPath = latestOutputPath,
message = error.message ?: "保存到文件夹失败",
),
)
stopCallback.invoke(latestOutputPath)
}
}
}
}
private fun publishSegmentsAfterMergeFailure(
segments: List<File>,
displayName: String?,
): List<String> {
return segments.mapIndexedNotNull { index, segment ->
try {
RecordingOutputFactory.publishPartToMediaStore(
appContext,
displayName,
segment,
index + 1,
)
} catch (error: Exception) {
Log.e(TAG, "publish segment after merge failure failed", error)
null
}
}
}
private fun cleanupSegmentFiles(files: List<File>) {
files.forEach { file ->
try {
file.delete()
} catch (_: Exception) {
}
}
}
fun zoomCapabilitiesMap(): Map<String, Any> { fun zoomCapabilitiesMap(): Map<String, Any> {
val zoomState = camera?.cameraInfo?.zoomState?.value val zoomState = camera?.cameraInfo?.zoomState?.value
val logicalMin = zoomState?.minZoomRatio ?: 1f val cameraXMin = zoomState?.minZoomRatio ?: 1f
// 兜底两路超广角来源:独立超广角镜头(0.6) 与 逻辑相机原生 <1.0 变焦范围,取更小者。 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 = val minZoom =
if (hasUltraWideCamera()) { if (hasUltraWideCamera()) {
minOf(ultraWideZoomRatio, logicalMin) minOf(ultraWideZoomRatio, logicalMin)
} else { } else {
logicalMin logicalMin
} }
val maxZoom = zoomState?.maxZoomRatio ?: 3f val maxZoom = logicalMax
val zoom = val zoom =
if (currentLensMode == LensMode.ULTRA_WIDE) { if (currentLensMode == LensMode.ULTRA_WIDE) {
ultraWideZoomRatio ultraWideZoomRatio
} else { } else {
(zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom) currentZoomRatio.coerceIn(minZoom, maxZoom)
} }
currentZoomRatio = zoom currentZoomRatio = zoom
Log.d( Log.d(
TAG, TAG,
"zoomCapabilities hasUltraWide=${hasUltraWideCamera()} logicalMin=$logicalMin " + "zoomCapabilities hasUltraWide=${hasUltraWideCamera()} cameraXMin=$cameraXMin " +
"cameraXMax=$cameraXMax camera2Range=${camera2Range?.description()} " +
"ultraWideZoomRatio=$ultraWideZoomRatio minZoom=$minZoom maxZoom=$maxZoom zoom=$zoom", "ultraWideZoomRatio=$ultraWideZoomRatio minZoom=$minZoom maxZoom=$maxZoom zoom=$zoom",
) )
return mapOf( return mapOf(
@@ -259,6 +412,10 @@ class RecordingCameraController(
) { ) {
val boundCamera = camera val boundCamera = camera
if (boundCamera == null) { if (boundCamera == null) {
if (isSwitchingLens) {
onComplete(false, zoomCapabilitiesMap(), "Camera lens switch is already in progress")
return
}
val clamped = val clamped =
if (ratio < 1.0 && hasUltraWideCamera()) { if (ratio < 1.0 && hasUltraWideCamera()) {
ultraWideZoomRatio ultraWideZoomRatio
@@ -271,9 +428,12 @@ class RecordingCameraController(
} }
if (ratio < 1.0 && hasUltraWideCamera()) { if (ratio < 1.0 && hasUltraWideCamera()) {
val logicalRange = mainCameraZoomRatioRange()
if (logicalRange == null || !logicalRange.contains(ratio.toFloat())) {
switchToUltraWide(onComplete) switchToUltraWide(onComplete)
return return
} }
}
if (currentLensMode == LensMode.ULTRA_WIDE) { if (currentLensMode == LensMode.ULTRA_WIDE) {
switchToMainAndZoom(ratio, onComplete) switchToMainAndZoom(ratio, onComplete)
@@ -281,18 +441,36 @@ class RecordingCameraController(
} }
val zoomState = boundCamera.cameraInfo.zoomState.value val zoomState = boundCamera.cameraInfo.zoomState.value
val minZoom = zoomState?.minZoomRatio ?: 1f val camera2Range = mainCameraZoomRatioRange()
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() val minZoom = camera2Range?.lower ?: zoomState?.minZoomRatio ?: 1f
val maxZoom = camera2Range?.upper ?: zoomState?.maxZoomRatio ?: clampedMaxZoom()
val nextZoom = ratio.toFloat().coerceIn(minZoom, maxZoom) val nextZoom = ratio.toFloat().coerceIn(minZoom, maxZoom)
currentZoomRatio = nextZoom 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( future.addListener(
{ {
try { try {
future.get() future.get()
onComplete(true, zoomCapabilitiesMap(), null) val capabilities = zoomCapabilitiesMap()
logSetZoomResult(ratio, nextZoom, capabilities)
onComplete(true, capabilities, null)
} catch (error: Exception) { } 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) Log.e(TAG, "setZoomRatio failed", error)
onComplete(false, zoomCapabilitiesMap(), error.message) onComplete(false, zoomCapabilitiesMap(), error.message)
} }
@@ -304,12 +482,20 @@ class RecordingCameraController(
fun unbind() { fun unbind() {
activeRecording?.stop() activeRecording?.stop()
activeRecording = null activeRecording = null
pendingSegmentSwitch = null
pendingStopCallback = null
isSwitchingLens = false
cameraProvider?.unbindAll() cameraProvider?.unbindAll()
cameraProvider = null cameraProvider = null
preview = null preview = null
videoCapture = null videoCapture = null
camera = null camera = null
boundLifecycleOwner = null boundLifecycleOwner = null
currentSegmentFile = null
cleanupSegmentFiles(segmentFiles)
segmentFiles.clear()
nextSegmentIndex = 1
activeDisplayName = null
currentLensMode = LensMode.MAIN currentLensMode = LensMode.MAIN
currentZoomRatio = 1f currentZoomRatio = 1f
ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
@@ -321,6 +507,8 @@ class RecordingCameraController(
return System.currentTimeMillis() - recordingStartedAt return System.currentTimeMillis() - recordingStartedAt
} }
fun segmentOutputPaths(): List<String> = latestSegmentOutputPaths
private fun updateStatus(next: RecordingStatus) { private fun updateStatus(next: RecordingStatus) {
status = next status = next
statusListener?.invoke(next) statusListener?.invoke(next)
@@ -334,20 +522,30 @@ class RecordingCameraController(
return return
} }
val zoomState = boundCamera.cameraInfo.zoomState.value val zoomState = boundCamera.cameraInfo.zoomState.value
val minZoom = zoomState?.minZoomRatio ?: 1f val camera2Range = mainCameraZoomRatioRange()
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() val minZoom = camera2Range?.lower ?: zoomState?.minZoomRatio ?: 1f
val maxZoom = camera2Range?.upper ?: zoomState?.maxZoomRatio ?: clampedMaxZoom()
currentZoomRatio = currentZoomRatio.coerceIn(minZoom, maxZoom) currentZoomRatio = currentZoomRatio.coerceIn(minZoom, maxZoom)
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
camera2Range?.contains(currentZoomRatio) == true
) {
applyCamera2ZoomRatio(boundCamera, currentZoomRatio)
} else {
boundCamera.cameraControl.setZoomRatio(currentZoomRatio) boundCamera.cameraControl.setZoomRatio(currentZoomRatio)
} }
}
private fun clampedMaxZoom(): Float { private fun clampedMaxZoom(): Float {
return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f
} }
private fun discoverBackCameras(provider: ProcessCameraProvider) { private fun discoverBackCameras(provider: ProcessCameraProvider) {
val manager = appContext.getSystemService(Context.CAMERA_SERVICE) as CameraManager
if (mainCameraId == null) { if (mainCameraId == null) {
mainCameraId = cameraIdForSelector(provider, CameraSelector.DEFAULT_BACK_CAMERA) mainCameraId = cameraIdForSelector(provider, CameraSelector.DEFAULT_BACK_CAMERA)
} }
logPublicCameraDiagnostics(provider, manager, mainCameraId)
val ultraWideCamera = findUltraWideCamera(provider, mainCameraId) val ultraWideCamera = findUltraWideCamera(provider, mainCameraId)
ultraWideCameraId = ultraWideCamera?.cameraId ultraWideCameraId = ultraWideCamera?.cameraId
ultraWideZoomRatio = ultraWideCamera?.zoomRatio ?: DEFAULT_ULTRA_WIDE_ZOOM_RATIO ultraWideZoomRatio = ultraWideCamera?.zoomRatio ?: DEFAULT_ULTRA_WIDE_ZOOM_RATIO
@@ -383,6 +581,13 @@ class RecordingCameraController(
val candidates = val candidates =
manager.cameraIdList manager.cameraIdList
.mapNotNull { cameraId -> backCameraProfile(manager, cameraId) } .mapNotNull { cameraId -> backCameraProfile(manager, cameraId) }
.onEach { profile ->
Log.d(
TAG,
"backCamera ${profile.description()} " +
"bindable=${provider.hasCameraSafely(selectorForCameraId(profile.cameraId))}",
)
}
.filter { it.cameraId != excludedCameraId } .filter { it.cameraId != excludedCameraId }
.filter { provider.hasCameraSafely(selectorForCameraId(it.cameraId)) } .filter { provider.hasCameraSafely(selectorForCameraId(it.cameraId)) }
.sortedWith( .sortedWith(
@@ -391,11 +596,16 @@ class RecordingCameraController(
) )
val mainProfile = excludedCameraId?.let { backCameraProfile(manager, it) } 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 = val candidatesDesc =
candidates.joinToString { "id=${it.cameraId} fov=${it.horizontalFov} focal=${it.minFocalLength}" } candidates.joinToString { it.description() }
val mainDesc = val mainDesc =
mainProfile?.let { "id=${it.cameraId} fov=${it.horizontalFov} focal=${it.minFocalLength}" } mainProfile?.description()
Log.d(TAG, "ultraWide candidates=[$candidatesDesc] main=$mainDesc") Log.d(TAG, "ultraWide candidates=[$candidatesDesc] main=$mainDesc")
if (mainProfile == null) { if (mainProfile == null) {
return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO) 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)", "(fovFactor=$ULTRA_WIDE_FOV_FACTOR focalFactor=$ULTRA_WIDE_FOCAL_FACTOR)",
) )
if (!meaningfullyWider) { if (!meaningfullyWider) {
logPhysicalOnlyUltraWideDiagnostics(manager, mainProfile, excludedCameraId)
return null 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<String>,
) {
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( private fun backCameraProfile(
@@ -435,13 +733,168 @@ class RecordingCameraController(
val minFocalLength = focalLengths.minOrNull() ?: return null val minFocalLength = focalLengths.minOrNull() ?: return null
val horizontalFov = val horizontalFov =
2.0 * atan((physicalSize.width / (2.0f * minFocalLength)).toDouble()) 2.0 * atan((physicalSize.width / (2.0f * minFocalLength)).toDouble())
CameraProfile(cameraId, minFocalLength, horizontalFov) CameraProfile(
cameraId,
minFocalLength,
horizontalFov,
zoomRatioRangeFromCharacteristics(characteristics),
)
} catch (error: Exception) { } catch (error: Exception) {
Log.w(TAG, "backCameraProfile failed for cameraId=$cameraId", error) Log.w(TAG, "backCameraProfile failed for cameraId=$cameraId", error)
null 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<String> {
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<String, Any>,
) {
Log.d(
TAG,
"setZoomRatio result requestedRatio=$requestedRatio nextZoom=$nextZoom " +
"currentZoomRatio=$currentZoomRatio returnedZoom=${capabilities["zoomRatio"]}",
)
}
private fun selectorForCurrentLensMode(): CameraSelector { private fun selectorForCurrentLensMode(): CameraSelector {
val cameraId = val cameraId =
if (currentLensMode == LensMode.ULTRA_WIDE) { if (currentLensMode == LensMode.ULTRA_WIDE) {
@@ -496,7 +949,11 @@ class RecordingCameraController(
return return
} }
if (activeRecording != null) { if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") switchRecordingSegmentToLens(
LensMode.ULTRA_WIDE,
ultraWideZoomRatio.toDouble(),
onComplete,
)
return return
} }
val provider = cameraProvider val provider = cameraProvider
@@ -530,7 +987,7 @@ class RecordingCameraController(
onComplete: (Boolean, Map<String, Any>, String?) -> Unit, onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) { ) {
if (activeRecording != null) { if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording") switchRecordingSegmentToLens(LensMode.MAIN, ratio, onComplete)
return return
} }
val provider = cameraProvider val provider = cameraProvider
@@ -554,6 +1011,62 @@ class RecordingCameraController(
return ultraWideCameraId != null return ultraWideCameraId != null
} }
private fun switchRecordingSegmentToLens(
targetLensMode: LensMode,
targetRatio: Double,
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) {
val recording = activeRecording
if (recording == null) {
onComplete(false, zoomCapabilitiesMap(), "Recording is not active")
return
}
if (isSwitchingLens || pendingSegmentSwitch != null) {
onComplete(false, zoomCapabilitiesMap(), "Camera lens switch is already in progress")
return
}
pendingSegmentSwitch = PendingSegmentSwitch(targetLensMode, targetRatio, onComplete)
isSwitchingLens = true
recording.stop()
activeRecording = null
}
private fun continueSegmentSwitch(request: PendingSegmentSwitch) {
val provider = cameraProvider
val lifecycleOwner = boundLifecycleOwner
if (provider == null || lifecycleOwner == null) {
isSwitchingLens = false
request.onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
return
}
try {
currentLensMode = request.targetLensMode
currentZoomRatio =
if (request.targetLensMode == LensMode.ULTRA_WIDE) {
ultraWideZoomRatio
} else {
request.targetRatio.toFloat().coerceAtLeast(1f)
}
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
applyCurrentZoom()
startNewSegment(updateStatusOnStart = true) { started, message ->
isSwitchingLens = false
if (started) {
request.onComplete(true, zoomCapabilitiesMap(), null)
} else {
updateStatus(RecordingStatus(RecordingState.ERROR, message = message))
request.onComplete(false, zoomCapabilitiesMap(), message)
}
}
} catch (error: Exception) {
Log.e(TAG, "continueSegmentSwitch failed", error)
isSwitchingLens = false
updateStatus(RecordingStatus(RecordingState.ERROR, message = error.message))
request.onComplete(false, zoomCapabilitiesMap(), error.message)
}
}
private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean { private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean {
return try { return try {
hasCamera(selector) hasCamera(selector)
@@ -571,16 +1084,43 @@ class RecordingCameraController(
val cameraId: String, val cameraId: String,
val minFocalLength: Float, val minFocalLength: Float,
val horizontalFov: Double, 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( private data class UltraWideCamera(
val cameraId: String, val cameraId: String,
val zoomRatio: Float, val zoomRatio: Float,
) )
private data class PendingSegmentSwitch(
val targetLensMode: LensMode,
val targetRatio: Double,
val onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
)
companion object { companion object {
private const val TAG = "RecordingCamera" private const val TAG = "RecordingCamera"
private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f
private const val MIN_ULTRA_WIDE_ZOOM_RATIO = 0.3f
private const val MAX_ULTRA_WIDE_ZOOM_RATIO = 0.99f
// 适度放宽判定宽容度,覆盖更多机型(更小的 FOV/焦距差异也视为超广角)。 // 适度放宽判定宽容度,覆盖更多机型(更小的 FOV/焦距差异也视为超广角)。
private const val ULTRA_WIDE_FOV_FACTOR = 1.04 private const val ULTRA_WIDE_FOV_FACTOR = 1.04
private const val ULTRA_WIDE_FOCAL_FACTOR = 0.96 private const val ULTRA_WIDE_FOCAL_FACTOR = 0.96
@@ -4,7 +4,9 @@ import android.content.ContentValues
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import androidx.camera.video.MediaStoreOutputOptions import androidx.camera.video.FileOutputOptions
import java.io.File
import java.io.FileInputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -13,10 +15,37 @@ object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/飞行极控录像工作台" private const val RELATIVE_PATH = "Movies/飞行极控录像工作台"
private const val MIME_TYPE = "video/mp4" private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions( fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions {
return FileOutputOptions.Builder(segmentFile).build()
}
fun createSegmentFile(
context: Context, context: Context,
displayName: String?, displayName: String?,
): MediaStoreOutputOptions { index: Int,
): File {
val directory = File(context.cacheDir, "recording_segments")
if (!directory.exists()) {
directory.mkdirs()
}
val baseName = resolveFileName(displayName).removeSuffix(".mp4")
return File(directory, "${baseName}_${System.currentTimeMillis()}_part$index.mp4")
}
fun createMergeFile(context: Context, displayName: String?): File {
val directory = File(context.cacheDir, "recording_segments")
if (!directory.exists()) {
directory.mkdirs()
}
val baseName = resolveFileName(displayName).removeSuffix(".mp4")
return File(directory, "${baseName}_${System.currentTimeMillis()}_merged.mp4")
}
fun publishToMediaStore(
context: Context,
displayName: String?,
sourceFile: File,
): String? {
val fileName = resolveFileName(displayName) val fileName = resolveFileName(displayName)
val contentValues = val contentValues =
ContentValues().apply { ContentValues().apply {
@@ -24,15 +53,42 @@ object RecordingOutputFactory {
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE) put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH) put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
put(MediaStore.Video.Media.IS_PENDING, 1)
} }
} }
return MediaStoreOutputOptions.Builder( val resolver = context.contentResolver
context.contentResolver, val uri =
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
) ?: return null
.setContentValues(contentValues) try {
.build() val outputStream =
resolver.openOutputStream(uri)
?: throw IllegalStateException("Cannot open MediaStore output stream")
outputStream.use { output ->
FileInputStream(sourceFile).use { input -> input.copyTo(output) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val publishedValues =
ContentValues().apply { put(MediaStore.Video.Media.IS_PENDING, 0) }
resolver.update(uri, publishedValues, null, null)
}
return uri.toString()
} catch (error: Exception) {
resolver.delete(uri, null, null)
throw error
}
}
fun publishPartToMediaStore(
context: Context,
displayName: String?,
sourceFile: File,
partIndex: Int,
): String? {
val resolvedName = resolveFileName(displayName)
val partName = resolvedName.replace(".mp4", "_part$partIndex.mp4")
return publishToMediaStore(context, partName, sourceFile)
} }
fun resolveFileName(displayName: String?): String { fun resolveFileName(displayName: String?): String {
@@ -196,6 +196,7 @@ class RecordingPlatformHandler(
"outputPath" to path, "outputPath" to path,
"status" to controller.status.toMap(), "status" to controller.status.toMap(),
"fileSaved" to fileSaved, "fileSaved" to fileSaved,
"segmentOutputPaths" to controller.segmentOutputPaths(),
) )
if (!fileSaved) { if (!fileSaved) {
payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败" payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
@@ -0,0 +1,174 @@
package com.dronex.rec.recording
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
object RecordingSegmentMuxer {
fun mergeOrCopy(
segments: List<File>,
outputFile: File,
) {
val validSegments = segments.filter { it.exists() && it.length() > 0L }
require(validSegments.isNotEmpty()) { "No recording segments were generated" }
if (validSegments.size == 1) {
copyFile(validSegments.first(), outputFile)
return
}
MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4).use {
muxer ->
val firstTracks = readTrackFormats(validSegments.first())
val videoTrack = firstTracks.video?.let { muxer.addTrack(it) }
val audioTrack = firstTracks.audio?.let { muxer.addTrack(it) }
require(videoTrack != null) { "No video track in recording segment" }
muxer.start()
var offsetUs = 0L
validSegments.forEach { segment ->
val durationUs =
copySegmentTracks(
segment = segment,
muxer = muxer,
videoOutputTrack = videoTrack,
audioOutputTrack = audioTrack,
offsetUs = offsetUs,
)
offsetUs += durationUs + 1L
}
}
}
private fun copyFile(source: File, target: File) {
FileInputStream(source).use { input ->
FileOutputStream(target).use { output -> input.copyTo(output) }
}
}
private fun readTrackFormats(file: File): TrackFormats {
val extractor = MediaExtractor()
extractor.setDataSource(file.absolutePath)
try {
var video: MediaFormat? = null
var audio: MediaFormat? = null
for (index in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(index)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
when {
mime.startsWith("video/") && video == null -> video = format
mime.startsWith("audio/") && audio == null -> audio = format
}
}
return TrackFormats(video = video, audio = audio)
} finally {
extractor.release()
}
}
private fun copySegmentTracks(
segment: File,
muxer: MediaMuxer,
videoOutputTrack: Int,
audioOutputTrack: Int?,
offsetUs: Long,
): Long {
var segmentDurationUs = 0L
segmentDurationUs =
maxOf(
segmentDurationUs,
copyTrack(segment, muxer, "video/", videoOutputTrack, offsetUs),
)
if (audioOutputTrack != null) {
segmentDurationUs =
maxOf(
segmentDurationUs,
copyTrack(segment, muxer, "audio/", audioOutputTrack, offsetUs),
)
}
return segmentDurationUs
}
private fun copyTrack(
segment: File,
muxer: MediaMuxer,
mimePrefix: String,
outputTrack: Int,
offsetUs: Long,
): Long {
val extractor = MediaExtractor()
extractor.setDataSource(segment.absolutePath)
try {
val inputTrack = findTrack(extractor, mimePrefix) ?: return 0L
extractor.selectTrack(inputTrack)
val bufferSize = trackBufferSize(extractor.getTrackFormat(inputTrack))
val buffer = ByteBuffer.allocate(bufferSize)
val info = MediaCodec.BufferInfo()
var firstSampleTimeUs: Long? = null
var lastSampleTimeUs = 0L
while (true) {
buffer.clear()
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
val sampleTimeUs = extractor.sampleTime
val baseTimeUs = firstSampleTimeUs ?: sampleTimeUs.also { firstSampleTimeUs = it }
val normalizedTimeUs = (sampleTimeUs - baseTimeUs).coerceAtLeast(0L)
info.set(
0,
sampleSize,
offsetUs + normalizedTimeUs,
extractor.sampleFlags,
)
muxer.writeSampleData(outputTrack, buffer, info)
lastSampleTimeUs = normalizedTimeUs
extractor.advance()
}
return lastSampleTimeUs
} finally {
extractor.release()
}
}
private fun findTrack(extractor: MediaExtractor, mimePrefix: String): Int? {
for (index in 0 until extractor.trackCount) {
val mime = extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith(mimePrefix)) {
return index
}
}
return null
}
private fun trackBufferSize(format: MediaFormat): Int {
return if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE).coerceAtLeast(DEFAULT_BUFFER_SIZE)
} else {
DEFAULT_BUFFER_SIZE
}
}
private data class TrackFormats(
val video: MediaFormat?,
val audio: MediaFormat?,
)
private const val DEFAULT_BUFFER_SIZE = 2 * 1024 * 1024
}
private inline fun MediaMuxer.use(block: (MediaMuxer) -> Unit) {
try {
block(this)
} finally {
try {
stop()
} catch (_: Exception) {
}
release()
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin { final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) { static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
name: "com.dronex.rec/platform_info", name: "app.record_tool/platform_info",
binaryMessenger: registrar.messenger() binaryMessenger: registrar.messenger()
) )
let plugin = PlatformInfoPlugin() let plugin = PlatformInfoPlugin()
+3 -3
View File
@@ -633,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
private enum RecordingChannelNames { private enum RecordingChannelNames {
static let packageName = "com.dronex.rec" static let namespace = "app.record_tool"
static let method = "\(packageName)/recording" static let method = "\(namespace)/recording"
static let events = "\(packageName)/recording_events" static let events = "\(namespace)/recording_events"
} }
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
+1 -1
View File
@@ -60,7 +60,7 @@ class AppPlatformInfo {
AppPlatformInfo._(); AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel( static const MethodChannel _channel = MethodChannel(
'com.dronex.rec/platform_info', 'app.record_tool/platform_info',
); );
static Future<AppPackageInfo> packageInfo() async { static Future<AppPackageInfo> packageInfo() async {
@@ -7,6 +7,7 @@ class RecordingSessionState {
this.isTouchLocked = true, this.isTouchLocked = true,
this.isPreviewReady = false, this.isPreviewReady = false,
this.isStartingRecording = false, this.isStartingRecording = false,
this.isSwitchingLens = false,
this.hasDndAccess = false, this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true, this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true, this.notificationsGranted = true,
@@ -19,12 +20,14 @@ class RecordingSessionState {
this.errorMessage, this.errorMessage,
this.permissionWarning, this.permissionWarning,
this.fileSaveFailed = false, this.fileSaveFailed = false,
this.segmentOutputPaths = const [],
}); });
final RecordingStatus status; final RecordingStatus status;
final bool isTouchLocked; final bool isTouchLocked;
final bool isPreviewReady; final bool isPreviewReady;
final bool isStartingRecording; final bool isStartingRecording;
final bool isSwitchingLens;
final bool hasDndAccess; final bool hasDndAccess;
final bool isBatteryOptimizedIgnored; final bool isBatteryOptimizedIgnored;
final bool notificationsGranted; final bool notificationsGranted;
@@ -37,6 +40,7 @@ class RecordingSessionState {
final String? errorMessage; final String? errorMessage;
final String? permissionWarning; final String? permissionWarning;
final bool fileSaveFailed; final bool fileSaveFailed;
final List<String> segmentOutputPaths;
bool get isRecording => status.isRecording; bool get isRecording => status.isRecording;
@@ -53,6 +57,7 @@ class RecordingSessionState {
bool? isTouchLocked, bool? isTouchLocked,
bool? isPreviewReady, bool? isPreviewReady,
bool? isStartingRecording, bool? isStartingRecording,
bool? isSwitchingLens,
bool? hasDndAccess, bool? hasDndAccess,
bool? isBatteryOptimizedIgnored, bool? isBatteryOptimizedIgnored,
bool? notificationsGranted, bool? notificationsGranted,
@@ -65,6 +70,7 @@ class RecordingSessionState {
String? errorMessage, String? errorMessage,
String? permissionWarning, String? permissionWarning,
bool? fileSaveFailed, bool? fileSaveFailed,
List<String>? segmentOutputPaths,
bool clearPermissionWarning = false, bool clearPermissionWarning = false,
bool clearLastSaved = false, bool clearLastSaved = false,
}) { }) {
@@ -73,6 +79,7 @@ class RecordingSessionState {
isTouchLocked: isTouchLocked ?? this.isTouchLocked, isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady, isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording, isStartingRecording: isStartingRecording ?? this.isStartingRecording,
isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens,
hasDndAccess: hasDndAccess ?? this.hasDndAccess, hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored: isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -90,6 +97,7 @@ class RecordingSessionState {
? null ? null
: (permissionWarning ?? this.permissionWarning), : (permissionWarning ?? this.permissionWarning),
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed, fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
segmentOutputPaths: segmentOutputPaths ?? this.segmentOutputPaths,
); );
} }
} }
@@ -173,7 +173,11 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
if (!mounted) return; if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session; final latest = ref.read(recordingViewModelProvider).session;
if (latest.fileSaveFailed) { if (latest.fileSaveFailed) {
if (latest.segmentOutputPaths.isNotEmpty) {
AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看');
} else {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
}
return; return;
} }
await _showRecordingSavedDialogIfNeeded(); await _showRecordingSavedDialogIfNeeded();
@@ -374,6 +378,7 @@ class _RecordingHudLayer extends ConsumerWidget {
m.session.notificationsGranted, m.session.notificationsGranted,
m.session.isRecording, m.session.isRecording,
m.session.isStartingRecording, m.session.isStartingRecording,
m.session.isSwitchingLens,
m.session.isTouchLocked, m.session.isTouchLocked,
m.session.zoomRatio, m.session.zoomRatio,
m.session.minZoomRatio, m.session.minZoomRatio,
@@ -391,6 +396,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted, notificationsGranted,
isRecording, isRecording,
isStartingRecording, isStartingRecording,
isSwitchingLens,
isTouchLocked, isTouchLocked,
zoomRatio, zoomRatio,
minZoomRatio, minZoomRatio,
@@ -408,6 +414,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted: notificationsGranted, notificationsGranted: notificationsGranted,
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: isStartingRecording, isStartingRecording: isStartingRecording,
isSwitchingLens: isSwitchingLens,
isTouchLocked: isTouchLocked, isTouchLocked: isTouchLocked,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames { abstract final class RecordingChannelNames {
static const packageName = 'com.dronex.rec'; static const namespace = 'app.record_tool';
static const method = '$packageName/recording'; static const method = '$namespace/recording';
static const events = '$packageName/recording_events'; static const events = '$namespace/recording_events';
} }
@@ -207,12 +207,14 @@ class RecordingStopResult {
required this.status, required this.status,
this.fileSaved = true, this.fileSaved = true,
this.fileErrorMessage, this.fileErrorMessage,
this.segmentOutputPaths = const [],
}); });
final String? outputPath; final String? outputPath;
final RecordingStatus status; final RecordingStatus status;
final bool fileSaved; final bool fileSaved;
final String? fileErrorMessage; final String? fileErrorMessage;
final List<String> segmentOutputPaths;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) { factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult( return RecordingStopResult(
@@ -222,6 +224,11 @@ class RecordingStopResult {
), ),
fileSaved: result?['fileSaved'] as bool? ?? true, fileSaved: result?['fileSaved'] as bool? ?? true,
fileErrorMessage: result?['fileErrorMessage'] as String?, fileErrorMessage: result?['fileErrorMessage'] as String?,
segmentOutputPaths:
(result?['segmentOutputPaths'] as List?)?.whereType<String>().toList(
growable: false,
) ??
const [],
); );
} }
} }
@@ -349,10 +349,14 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。 /// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
Future<void> setZoomRatio(double ratio) async { Future<void> setZoomRatio(double ratio) async {
final session = state.session; final session = state.session;
if (session.isSwitchingLens) {
return;
}
final clamped = ratio final clamped = ratio
.clamp(session.minZoomRatio, session.maxZoomRatio) .clamp(session.minZoomRatio, session.maxZoomRatio)
.toDouble(); .toDouble();
_updateSession((s) => s.copyWith(isSwitchingLens: true));
try { try {
final zoom = await RecordingPlatform.setZoomRatio(clamped); final zoom = await RecordingPlatform.setZoomRatio(clamped);
_updateSession( _updateSession(
@@ -364,8 +368,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
), ),
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
final message = error.code == 'ZOOM_FAILED'
? '切换镜头失败,请重试'
: (error.message ?? '相机倍距设置失败');
_updateSession((s) => s.copyWith(errorMessage: message));
} finally {
_updateSession( _updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'), (s) => s.copyWith(isSwitchingLens: false, errorMessage: s.errorMessage),
); );
} }
} }
@@ -373,7 +382,9 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 开始录制,可选开启勿扰模式。 /// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async { Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session; final session = state.session;
if (session.isRecording || session.isStartingRecording) { if (session.isRecording ||
session.isStartingRecording ||
session.isSwitchingLens) {
return; return;
} }
if (!session.isPreviewReady) { if (!session.isPreviewReady) {
@@ -400,6 +411,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
isTouchLocked: true, isTouchLocked: true,
errorMessage: null, errorMessage: null,
fileSaveFailed: false, fileSaveFailed: false,
segmentOutputPaths: const [],
clearLastSaved: true, clearLastSaved: true,
), ),
); );
@@ -414,7 +426,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 停止录制、保存到文件夹,并恢复相机预览。 /// 停止录制、保存到文件夹,并恢复相机预览。
Future<void> stopRecording() async { Future<void> stopRecording() async {
if (!state.session.isRecording) return; if (!state.session.isRecording || state.session.isSwitchingLens) return;
try { try {
final result = await RecordingPlatform.stopRecording(); final result = await RecordingPlatform.stopRecording();
@@ -431,6 +443,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限') ? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
: null, : null,
fileSaveFailed: fileFailed, fileSaveFailed: fileFailed,
segmentOutputPaths: result.segmentOutputPaths,
), ),
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
@@ -18,6 +18,7 @@ class RecordingHudWidget extends StatelessWidget {
required this.notificationsGranted, required this.notificationsGranted,
required this.isRecording, required this.isRecording,
required this.isStartingRecording, required this.isStartingRecording,
required this.isSwitchingLens,
required this.isTouchLocked, required this.isTouchLocked,
this.showClipboardHint = false, this.showClipboardHint = false,
this.clipboardAddress = '', this.clipboardAddress = '',
@@ -39,6 +40,7 @@ class RecordingHudWidget extends StatelessWidget {
final bool notificationsGranted; final bool notificationsGranted;
final bool isRecording; final bool isRecording;
final bool isStartingRecording; final bool isStartingRecording;
final bool isSwitchingLens;
final bool isTouchLocked; final bool isTouchLocked;
final bool showClipboardHint; final bool showClipboardHint;
final String clipboardAddress; final String clipboardAddress;
@@ -56,7 +58,6 @@ class RecordingHudWidget extends StatelessWidget {
static double get _recordButtonBottom => 63.r; static double get _recordButtonBottom => 63.r;
static double get _overlayInfoLeft => 13.r; static double get _overlayInfoLeft => 13.r;
static double get _overlayInfoBottom => 10.r; static double get _overlayInfoBottom => 10.r;
static const List<double> _zoomPresets = [0.6, 1.0];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -144,13 +145,13 @@ class RecordingHudWidget extends StatelessWidget {
), ),
Positioned( Positioned(
right: 16.r, right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h, bottom: 260.r,
child: _ZoomPresetControl( child: _ZoomPresetControl(
isRecording: isRecording, enabled: !isSwitchingLens,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio, maxZoomRatio: maxZoomRatio,
presets: _zoomPresets, presets: _zoomPresetsForRange(minZoomRatio),
onSelected: onZoomSelected, onSelected: onZoomSelected,
), ),
), ),
@@ -162,7 +163,7 @@ class RecordingHudWidget extends StatelessWidget {
child: RecordingControlButton( child: RecordingControlButton(
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: isStartingRecording, isStartingRecording: isStartingRecording,
enabled: !isStartingRecording, enabled: !isStartingRecording && !isSwitchingLens,
size: _recordButtonSize, size: _recordButtonSize,
onTap: () { onTap: () {
if (isRecording) { if (isRecording) {
@@ -191,11 +192,15 @@ class RecordingHudWidget extends StatelessWidget {
], ],
); );
} }
List<double> _zoomPresetsForRange(double minZoomRatio) {
return [if (minZoomRatio < 1.0) minZoomRatio, 1.0];
}
} }
class _ZoomPresetControl extends StatelessWidget { class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({ const _ZoomPresetControl({
required this.isRecording, required this.enabled,
required this.zoomRatio, required this.zoomRatio,
required this.minZoomRatio, required this.minZoomRatio,
required this.maxZoomRatio, required this.maxZoomRatio,
@@ -203,7 +208,7 @@ class _ZoomPresetControl extends StatelessWidget {
required this.onSelected, required this.onSelected,
}); });
final bool isRecording; final bool enabled;
final double zoomRatio; final double zoomRatio;
final double minZoomRatio; final double minZoomRatio;
final double maxZoomRatio; final double maxZoomRatio;
@@ -215,7 +220,6 @@ class _ZoomPresetControl extends StatelessWidget {
final availablePresets = presets final availablePresets = presets
.where(_isPresetAvailable) .where(_isPresetAvailable)
.toList(growable: false); .toList(growable: false);
if (availablePresets.isEmpty) { if (availablePresets.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -236,7 +240,7 @@ class _ZoomPresetControl extends StatelessWidget {
displayRatio: preset, displayRatio: preset,
requestRatio: preset, requestRatio: preset,
selected: _isPresetSelected(preset), selected: _isPresetSelected(preset),
enabled: !_wouldSwitchPhysicalCamera(preset), enabled: enabled,
onSelected: onSelected, onSelected: onSelected,
), ),
], ],
@@ -258,15 +262,6 @@ class _ZoomPresetControl extends StatelessWidget {
} }
return (zoomRatio - preset).abs() < 0.05; return (zoomRatio - preset).abs() < 0.05;
} }
bool _wouldSwitchPhysicalCamera(double preset) {
if (!isRecording) {
return false;
}
final currentIsUltraWide = zoomRatio < 1.0;
final targetIsUltraWide = preset < 1.0;
return currentIsUltraWide != targetIsUltraWide;
}
} }
class _ZoomPresetButton extends StatelessWidget { class _ZoomPresetButton extends StatelessWidget {
@@ -289,7 +284,17 @@ class _ZoomPresetButton extends StatelessWidget {
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: 1.r), padding: EdgeInsets.symmetric(horizontal: 1.r),
child: TextButton( child: TextButton(
onPressed: selected || !enabled ? null : () => onSelected(requestRatio), onPressed: selected || !enabled
? null
: () => RateLimit.instance.debounce<void>(
key:
'recording.session.zoom.${requestRatio.toStringAsFixed(1)}',
value: null,
duration: Duration(milliseconds: 300),
onCallback: (_) async {
onSelected(requestRatio);
},
),
style: TextButton.styleFrom( style: TextButton.styleFrom(
minimumSize: Size(38.r, 32.r), minimumSize: Size(38.r, 32.r),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -303,7 +308,7 @@ class _ZoomPresetButton extends StatelessWidget {
), ),
), ),
child: Text( child: Text(
'${_formatZoomRatio(displayRatio)}x', _formatZoomRatio(displayRatio),
style: TextStyle( style: TextStyle(
fontSize: 13.sp, fontSize: 13.sp,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -315,9 +320,12 @@ class _ZoomPresetButton extends StatelessWidget {
} }
String _formatZoomRatio(double ratio) { String _formatZoomRatio(double ratio) {
if (ratio == ratio.roundToDouble()) { if (ratio < 1.0) {
return ratio.toStringAsFixed(0); return '广角';
} }
return ratio.toStringAsFixed(1); if (ratio == ratio.roundToDouble()) {
return '${ratio.toStringAsFixed(0)}x';
}
return '${ratio.toStringAsFixed(1)}x';
} }
} }
@@ -1,7 +1,15 @@
import 'package:flutter_test/flutter_test.dart'; 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'; import 'package:recording_tool/features/recording/platform/recording_platform.dart';
void main() { 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', () { group('RecordingPlatform support', () {
test('supports Android and iOS hosts only', () { test('supports Android and iOS hosts only', () {
expect( expect(
@@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.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/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.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'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() { void main() {
@@ -76,9 +79,7 @@ void main() {
expect(session.errorMessage, isNull); expect(session.errorMessage, isNull);
}); });
test( test('passes 0.5x to native when camera capabilities allow it', () async {
'clamps legacy 0.5x request to 0.6x ultra-wide ratio',
() async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler( .setMockMethodCallHandler(
@@ -86,8 +87,8 @@ void main() {
(call) async { (call) async {
calls.add(call); calls.add(call);
return <String, dynamic>{ return <String, dynamic>{
'zoomRatio': 0.6, 'zoomRatio': 0.5,
'minZoomRatio': 0.6, 'minZoomRatio': 0.5,
'maxZoomRatio': 3.0, 'maxZoomRatio': 3.0,
}; };
}, },
@@ -101,20 +102,19 @@ void main() {
.copyWith( .copyWith(
session: const RecordingSessionState( session: const RecordingSessionState(
zoomRatio: 1.0, zoomRatio: 1.0,
minZoomRatio: 0.6, minZoomRatio: 0.5,
maxZoomRatio: 3.0, maxZoomRatio: 3.0,
), ),
); );
await notifier.setZoomRatio(0.5); await notifier.setZoomRatio(0.5);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.6}); expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.5});
final session = container.read(recordingViewModelProvider).session; final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 0.6); expect(session.zoomRatio, 0.5);
expect(session.minZoomRatio, 0.6); expect(session.minZoomRatio, 0.5);
expect(session.maxZoomRatio, 3.0); expect(session.maxZoomRatio, 3.0);
}, });
);
test('passes 0.6x to native when camera capabilities allow it', () async { test('passes 0.6x to native when camera capabilities allow it', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
@@ -229,7 +229,152 @@ void main() {
final session = container.read(recordingViewModelProvider).session; final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0); expect(session.zoomRatio, 1.0);
expect(session.errorMessage, 'Zoom is unavailable'); expect(session.errorMessage, '切换镜头失败,请重试');
},
);
test(
'maps native zoom failure to user friendly lens switch message',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
throw PlatformException(
code: 'ZOOM_FAILED',
message: 'Cannot switch physical camera while recording',
);
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(0.6);
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0);
expect(session.errorMessage, '切换镜头失败,请重试');
},
);
test('sets switching lens while native zoom request is pending', () async {
final completer = Completer<Map<String, dynamic>>();
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<void>.delayed(Duration.zero);
expect(
container.read(recordingViewModelProvider).session.isSwitchingLens,
isTrue,
);
completer.complete(<String, dynamic>{
'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 = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{};
},
);
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 <String, dynamic>{
'outputPath': 'content://recordings/part1',
'status': <String, dynamic>{
'state': 'error',
'message': 'Merge failed',
},
'fileSaved': false,
'fileErrorMessage': 'Merge failed',
'segmentOutputPaths': <String>[
'content://recordings/part1',
'content://recordings/part2',
],
};
case 'initializePreview':
return <String, dynamic>{'state': 'previewing'};
case 'getZoomCapabilities':
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
}
return <String, dynamic>{};
},
);
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, <String>[
'content://recordings/part1',
'content://recordings/part2',
]);
}, },
); );
}); });
@@ -10,6 +10,8 @@ void main() {
double minZoomRatio = 1.0, double minZoomRatio = 1.0,
double maxZoomRatio = 3.0, double maxZoomRatio = 3.0,
bool isRecording = false, bool isRecording = false,
bool isSwitchingLens = false,
Future<void> Function()? onStop,
ValueChanged<double>? onZoomSelected, ValueChanged<double>? onZoomSelected,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -25,12 +27,13 @@ void main() {
notificationsGranted: true, notificationsGranted: true,
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: false, isStartingRecording: false,
isSwitchingLens: isSwitchingLens,
isTouchLocked: false, isTouchLocked: false,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio, maxZoomRatio: maxZoomRatio,
onStart: () async {}, onStart: () async {},
onStop: () async {}, onStop: onStop ?? () async {},
onOpenDnd: () {}, onOpenDnd: () {},
onOpenBattery: () {}, onOpenBattery: () {},
onToggleTouchLock: () {}, onToggleTouchLock: () {},
@@ -47,50 +50,50 @@ void main() {
testWidgets('shows preset zoom buttons', (tester) async { testWidgets('shows preset zoom buttons', (tester) async {
await pumpHud(tester); await pumpHud(tester);
expect(find.text('0.5x'), findsNothing); expect(find.text('广角'), findsNothing);
expect(find.text('0.6x'), findsNothing);
expect(find.text('1x'), findsOneWidget); expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing); expect(find.text('2x'), findsNothing);
expect(find.text('3x'), 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, tester,
) async { ) async {
await pumpHud(tester, minZoomRatio: 0.5); await pumpHud(tester, minZoomRatio: 0.5);
expect(find.text('0.5x'), findsNothing); expect(find.text('广角'), findsOneWidget);
expect(find.text('0.6x'), findsOneWidget);
expect(find.text('1x'), findsOneWidget); expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing); expect(find.text('2x'), findsNothing);
expect(find.text('3x'), 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, tester,
) async { ) async {
await pumpHud(tester, minZoomRatio: 0.6); await pumpHud(tester, minZoomRatio: 0.6);
expect(find.text('0.6x'), findsOneWidget); expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), 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, tester,
) async { ) async {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5); await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5);
final selectedButton = tester.widget<TextButton>( final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
); );
expect(selectedButton.enabled, isFalse); 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); await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6);
final selectedButton = tester.widget<TextButton>( final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
); );
expect(selectedButton.enabled, isFalse); expect(selectedButton.enabled, isFalse);
}); });
@@ -98,11 +101,11 @@ void main() {
testWidgets('does not expose presets beyond max zoom ratio', (tester) async { testWidgets('does not expose presets beyond max zoom ratio', (tester) async {
await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55); await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55);
expect(find.text('0.6x'), findsNothing); expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsNothing); 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, tester,
) async { ) async {
double? selected; double? selected;
@@ -112,10 +115,10 @@ void main() {
onZoomSelected: (ratio) => selected = ratio, onZoomSelected: (ratio) => selected = ratio,
); );
await tester.tap(find.text('0.6x')); await tester.tap(find.text('广角'));
await tester.pump(); 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', ( testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', (
@@ -128,39 +131,101 @@ void main() {
onZoomSelected: (ratio) => selected = ratio, onZoomSelected: (ratio) => selected = ratio,
); );
await tester.tap(find.text('0.6x')); await tester.tap(find.text('广角'));
await tester.pump(); await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6); expect(selected, 0.6);
}); });
testWidgets('disables 0.6x while recording on main camera', (tester) async { testWidgets('allows 0.6x while recording on main camera after unlock', (
await pumpHud(tester, minZoomRatio: 0.5, isRecording: true);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
);
final mainButton = tester.widget<TextButton>(
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', (
tester, tester,
) async { ) 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<TextButton>( final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
final mainButton = tester.widget<TextButton>(
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<TextButton>(
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
); );
final mainButton = tester.widget<TextButton>( final mainButton = tester.widget<TextButton>(
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
); );
expect(ultraWideButton.enabled, isFalse); expect(ultraWideButton.enabled, 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<TextButton>(
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);
}); });
} }