Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d93c1c8dd | ||
|
|
c9323f26a7 | ||
|
|
7dafd61314 |
@@ -3,8 +3,13 @@ package com.run.sportsx.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.CameraSelector
|
||||
import androidx.camera.core.Preview
|
||||
@@ -18,8 +23,9 @@ 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.util.concurrent.Executor
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.round
|
||||
|
||||
class RecordingCameraController(
|
||||
private val appContext: Context,
|
||||
@@ -225,15 +231,18 @@ class RecordingCameraController(
|
||||
|
||||
fun zoomCapabilitiesMap(): Map<String, Any> {
|
||||
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
|
||||
@@ -243,7 +252,8 @@ class RecordingCameraController(
|
||||
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(
|
||||
@@ -271,8 +281,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,12 +294,21 @@ 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 {
|
||||
@@ -334,10 +356,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 +375,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 +415,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 +430,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 +454,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<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(
|
||||
@@ -435,13 +567,145 @@ 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<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 selectorForCurrentLensMode(): CameraSelector {
|
||||
val cameraId =
|
||||
if (currentLensMode == LensMode.ULTRA_WIDE) {
|
||||
@@ -571,7 +835,26 @@ 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,
|
||||
@@ -581,6 +864,8 @@ class RecordingCameraController(
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 976 B After Width: | Height: | Size: 761 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 256 KiB |
@@ -56,7 +56,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<double> _zoomPresets = [0.6, 1.0];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -150,7 +149,7 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
zoomRatio: zoomRatio,
|
||||
minZoomRatio: minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio,
|
||||
presets: _zoomPresets,
|
||||
presets: _zoomPresetsForRange(minZoomRatio),
|
||||
onSelected: onZoomSelected,
|
||||
),
|
||||
),
|
||||
@@ -191,6 +190,13 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<double> _zoomPresetsForRange(double minZoomRatio) {
|
||||
return [
|
||||
if (minZoomRatio < 1.0) minZoomRatio,
|
||||
1.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomPresetControl extends StatelessWidget {
|
||||
@@ -215,7 +221,6 @@ class _ZoomPresetControl extends StatelessWidget {
|
||||
final availablePresets = presets
|
||||
.where(_isPresetAvailable)
|
||||
.toList(growable: false);
|
||||
|
||||
if (availablePresets.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -76,45 +76,42 @@ void main() {
|
||||
expect(session.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'clamps legacy 0.5x request to 0.6x ultra-wide ratio',
|
||||
() async {
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'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 = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'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, <String, dynamic>{'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, <String, dynamic>{'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 = <MethodCall>[];
|
||||
|
||||
@@ -54,13 +54,13 @@ void main() {
|
||||
expect(find.text('3x'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows 0.6x when ultra-wide camera capability is below 0.6', (
|
||||
testWidgets('shows 0.5x 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('0.5x'), findsOneWidget);
|
||||
expect(find.text('0.6x'), findsNothing);
|
||||
expect(find.text('1x'), findsOneWidget);
|
||||
expect(find.text('2x'), findsNothing);
|
||||
expect(find.text('3x'), findsNothing);
|
||||
@@ -75,13 +75,13 @@ void main() {
|
||||
expect(find.text('1x'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('marks current ultra-wide zoom ratio as selected on 0.6x UI', (
|
||||
testWidgets('marks current 0.5x zoom ratio as selected', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5);
|
||||
|
||||
final selectedButton = tester.widget<TextButton>(
|
||||
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
|
||||
find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)),
|
||||
);
|
||||
expect(selectedButton.enabled, isFalse);
|
||||
});
|
||||
@@ -98,11 +98,12 @@ 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.5x'), findsOneWidget);
|
||||
expect(find.text('0.6x'), 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,
|
||||
) async {
|
||||
double? selected;
|
||||
@@ -112,10 +113,10 @@ void main() {
|
||||
onZoomSelected: (ratio) => selected = ratio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('0.6x'));
|
||||
await tester.tap(find.text('0.5x'));
|
||||
await tester.pump();
|
||||
|
||||
expect(selected, 0.6);
|
||||
expect(selected, 0.5);
|
||||
});
|
||||
|
||||
testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', (
|
||||
@@ -135,7 +136,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('disables 0.6x while recording on main camera', (tester) async {
|
||||
await pumpHud(tester, minZoomRatio: 0.5, isRecording: true);
|
||||
await pumpHud(tester, minZoomRatio: 0.6, isRecording: true);
|
||||
|
||||
final ultraWideButton = tester.widget<TextButton>(
|
||||
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
|
||||
@@ -154,7 +155,7 @@ void main() {
|
||||
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true);
|
||||
|
||||
final ultraWideButton = tester.widget<TextButton>(
|
||||
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
|
||||
find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)),
|
||||
);
|
||||
final mainButton = tester.widget<TextButton>(
|
||||
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
|
||||
|
||||