解决安卓端广角无法读取问题

This commit is contained in:
2026-06-30 16:30:24 +08:00
parent c9323f26a7
commit 6d93c1c8dd
4 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,8 +281,11 @@ class RecordingCameraController(
} }
if (ratio < 1.0 && hasUltraWideCamera()) { if (ratio < 1.0 && hasUltraWideCamera()) {
switchToUltraWide(onComplete) val logicalRange = mainCameraZoomRatioRange()
return if (logicalRange == null || !logicalRange.contains(ratio.toFloat())) {
switchToUltraWide(onComplete)
return
}
} }
if (currentLensMode == LensMode.ULTRA_WIDE) { if (currentLensMode == LensMode.ULTRA_WIDE) {
@@ -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,10 +356,18 @@ 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)
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 { private fun clampedMaxZoom(): Float {
@@ -345,9 +375,11 @@ class RecordingCameraController(
} }
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
@@ -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,45 +76,42 @@ 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', final calls = <MethodCall>[];
() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
final calls = <MethodCall>[]; .setMockMethodCallHandler(
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger const MethodChannel(RecordingChannelNames.method),
.setMockMethodCallHandler( (call) async {
const MethodChannel(RecordingChannelNames.method), calls.add(call);
(call) async { return <String, dynamic>{
calls.add(call); 'zoomRatio': 0.5,
return <String, dynamic>{ 'minZoomRatio': 0.5,
'zoomRatio': 0.6, 'maxZoomRatio': 3.0,
'minZoomRatio': 0.6, };
'maxZoomRatio': 3.0, },
}; );
}, final container = ProviderContainer();
); addTearDown(container.dispose);
final container = ProviderContainer(); final notifier = container.read(recordingViewModelProvider.notifier);
addTearDown(container.dispose); // ignore: invalid_use_of_protected_member
final notifier = container.read(recordingViewModelProvider.notifier); notifier.state = container
// ignore: invalid_use_of_protected_member .read(recordingViewModelProvider)
notifier.state = container .copyWith(
.read(recordingViewModelProvider) session: const RecordingSessionState(
.copyWith( zoomRatio: 1.0,
session: const RecordingSessionState( minZoomRatio: 0.5,
zoomRatio: 1.0, maxZoomRatio: 3.0,
minZoomRatio: 0.6, ),
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)),