3 Commits
Author SHA1 Message Date
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
27 changed files with 357 additions and 69 deletions
@@ -3,8 +3,13 @@ package com.run.sportsx.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.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
@@ -18,8 +23,9 @@ 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.util.concurrent.Executor import java.util.concurrent.Executor
import kotlin.math.atan
import kotlin.math.round
class RecordingCameraController( class RecordingCameraController(
private val appContext: Context, private val appContext: Context,
@@ -225,15 +231,18 @@ class RecordingCameraController(
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
@@ -243,7 +252,8 @@ class RecordingCameraController(
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(
@@ -271,9 +281,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,12 +294,21 @@ 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 {
@@ -334,20 +356,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 +415,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 +430,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 +454,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 +567,145 @@ 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 selectorForCurrentLensMode(): CameraSelector { private fun selectorForCurrentLensMode(): CameraSelector {
val cameraId = val cameraId =
if (currentLensMode == LensMode.ULTRA_WIDE) { if (currentLensMode == LensMode.ULTRA_WIDE) {
@@ -571,7 +835,26 @@ 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,
@@ -581,6 +864,8 @@ class RecordingCameraController(
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 B

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

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 _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) {
@@ -150,7 +149,7 @@ class RecordingHudWidget extends StatelessWidget {
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio, maxZoomRatio: maxZoomRatio,
presets: _zoomPresets, presets: _zoomPresetsForRange(minZoomRatio),
onSelected: onZoomSelected, 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 { class _ZoomPresetControl extends StatelessWidget {
@@ -215,7 +221,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();
} }
@@ -76,9 +76,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 +84,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 +99,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>[];
@@ -54,13 +54,13 @@ void main() {
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 0.5x 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('0.5x'), findsOneWidget);
expect(find.text('0.6x'), findsOneWidget); 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);
@@ -75,13 +75,13 @@ void main() {
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 zoom 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('0.5x'), matching: find.byType(TextButton)),
); );
expect(selectedButton.enabled, isFalse); expect(selectedButton.enabled, isFalse);
}); });
@@ -98,11 +98,12 @@ 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.5x'), findsOneWidget);
expect(find.text('0.6x'), findsNothing); expect(find.text('0.6x'), findsNothing);
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 +113,10 @@ void main() {
onZoomSelected: (ratio) => selected = ratio, onZoomSelected: (ratio) => selected = ratio,
); );
await tester.tap(find.text('0.6x')); await tester.tap(find.text('0.5x'));
await tester.pump(); await tester.pump();
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', (
@@ -135,7 +136,7 @@ void main() {
}); });
testWidgets('disables 0.6x while recording on main camera', (tester) async { 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>( final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
@@ -154,7 +155,7 @@ void main() {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true); await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true);
final ultraWideButton = tester.widget<TextButton>( final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('0.5x'), 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)),