4 Commits

6 changed files with 604 additions and 50 deletions

View File

@@ -1,7 +1,10 @@
package com.dronex.rec.recording package com.dronex.rec.recording
import android.content.Context import android.content.Context
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.util.Log import android.util.Log
import androidx.camera.camera2.interop.Camera2CameraInfo
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
@@ -15,6 +18,7 @@ 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
class RecordingCameraController( class RecordingCameraController(
@@ -26,6 +30,10 @@ class RecordingCameraController(
private var preview: Preview? = null private var preview: Preview? = null
private var videoCapture: VideoCapture<Recorder>? = null private var videoCapture: VideoCapture<Recorder>? = null
private var camera: Camera? = null private var camera: Camera? = null
private var mainCameraId: String? = null
private var ultraWideCameraId: String? = null
private var ultraWideZoomRatio: Float = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
private var currentLensMode: LensMode = LensMode.MAIN
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
@@ -63,14 +71,8 @@ class RecordingCameraController(
.build() .build()
videoCapture = VideoCapture.withOutput(recorder) videoCapture = VideoCapture.withOutput(recorder)
provider.unbindAll() discoverBackCameras(provider)
camera = bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
applyCurrentZoom() applyCurrentZoom()
updateStatus(RecordingStatus(RecordingState.PREVIEWING)) updateStatus(RecordingStatus(RecordingState.PREVIEWING))
@@ -112,14 +114,7 @@ class RecordingCameraController(
try { try {
boundLifecycleOwner = lifecycleOwner boundLifecycleOwner = lifecycleOwner
provider.unbindAll() bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
camera =
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
applyCurrentZoom() applyCurrentZoom()
onReady(true) onReady(true)
} catch (error: Exception) { } catch (error: Exception) {
@@ -230,10 +225,27 @@ 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 minZoom = zoomState?.minZoomRatio ?: 1f val logicalMin = zoomState?.minZoomRatio ?: 1f
// 兜底两路超广角来源:独立超广角镜头(0.6) 与 逻辑相机原生 <1.0 变焦范围,取更小者。
val minZoom =
if (hasUltraWideCamera()) {
minOf(ultraWideZoomRatio, logicalMin)
} else {
logicalMin
}
val maxZoom = zoomState?.maxZoomRatio ?: 3f val maxZoom = zoomState?.maxZoomRatio ?: 3f
val zoom = (zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom) val zoom =
if (currentLensMode == LensMode.ULTRA_WIDE) {
ultraWideZoomRatio
} else {
(zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom)
}
currentZoomRatio = zoom currentZoomRatio = zoom
Log.d(
TAG,
"zoomCapabilities hasUltraWide=${hasUltraWideCamera()} logicalMin=$logicalMin " +
"ultraWideZoomRatio=$ultraWideZoomRatio minZoom=$minZoom maxZoom=$maxZoom zoom=$zoom",
)
return mapOf( return mapOf(
"zoomRatio" to zoom.toDouble(), "zoomRatio" to zoom.toDouble(),
"minZoomRatio" to minZoom.toDouble(), "minZoomRatio" to minZoom.toDouble(),
@@ -247,12 +259,27 @@ class RecordingCameraController(
) { ) {
val boundCamera = camera val boundCamera = camera
if (boundCamera == null) { if (boundCamera == null) {
val clamped = ratio.toFloat().coerceAtLeast(1f) val clamped =
if (ratio < 1.0 && hasUltraWideCamera()) {
ultraWideZoomRatio
} else {
ratio.toFloat().coerceAtLeast(1f)
}
currentZoomRatio = clamped currentZoomRatio = clamped
onComplete(true, zoomCapabilitiesMap(), null) onComplete(true, zoomCapabilitiesMap(), null)
return return
} }
if (ratio < 1.0 && hasUltraWideCamera()) {
switchToUltraWide(onComplete)
return
}
if (currentLensMode == LensMode.ULTRA_WIDE) {
switchToMainAndZoom(ratio, onComplete)
return
}
val zoomState = boundCamera.cameraInfo.zoomState.value val zoomState = boundCamera.cameraInfo.zoomState.value
val minZoom = zoomState?.minZoomRatio ?: 1f val minZoom = zoomState?.minZoomRatio ?: 1f
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
@@ -283,7 +310,9 @@ class RecordingCameraController(
videoCapture = null videoCapture = null
camera = null camera = null
boundLifecycleOwner = null boundLifecycleOwner = null
currentLensMode = LensMode.MAIN
currentZoomRatio = 1f currentZoomRatio = 1f
ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
updateStatus(RecordingStatus(RecordingState.IDLE)) updateStatus(RecordingStatus(RecordingState.IDLE))
} }
@@ -299,6 +328,11 @@ class RecordingCameraController(
private fun applyCurrentZoom() { private fun applyCurrentZoom() {
val boundCamera = camera ?: return val boundCamera = camera ?: return
if (currentLensMode == LensMode.ULTRA_WIDE) {
currentZoomRatio = ultraWideZoomRatio
boundCamera.cameraControl.setZoomRatio(1f)
return
}
val zoomState = boundCamera.cameraInfo.zoomState.value val zoomState = boundCamera.cameraInfo.zoomState.value
val minZoom = zoomState?.minZoomRatio ?: 1f val minZoom = zoomState?.minZoomRatio ?: 1f
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom() val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
@@ -310,7 +344,245 @@ class RecordingCameraController(
return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f
} }
private fun discoverBackCameras(provider: ProcessCameraProvider) {
if (mainCameraId == null) {
mainCameraId = cameraIdForSelector(provider, CameraSelector.DEFAULT_BACK_CAMERA)
}
val ultraWideCamera = findUltraWideCamera(provider, mainCameraId)
ultraWideCameraId = ultraWideCamera?.cameraId
ultraWideZoomRatio = ultraWideCamera?.zoomRatio ?: DEFAULT_ULTRA_WIDE_ZOOM_RATIO
if (ultraWideCamera == null && currentLensMode == LensMode.ULTRA_WIDE) {
currentLensMode = LensMode.MAIN
currentZoomRatio = 1f
}
Log.d(
TAG,
"mainCameraId=$mainCameraId ultraWideCameraId=$ultraWideCameraId " +
"ultraWideZoomRatio=$ultraWideZoomRatio",
)
}
private fun cameraIdForSelector(
provider: ProcessCameraProvider,
selector: CameraSelector,
): String? {
return try {
val infos = selector.filter(provider.availableCameraInfos)
infos.firstOrNull()?.let { Camera2CameraInfo.from(it).cameraId }
} catch (error: Exception) {
Log.w(TAG, "cameraIdForSelector failed", error)
null
}
}
private fun findUltraWideCamera(
provider: ProcessCameraProvider,
excludedCameraId: String?,
): UltraWideCamera? {
val manager = appContext.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val candidates =
manager.cameraIdList
.mapNotNull { cameraId -> backCameraProfile(manager, cameraId) }
.filter { it.cameraId != excludedCameraId }
.filter { provider.hasCameraSafely(selectorForCameraId(it.cameraId)) }
.sortedWith(
compareByDescending<CameraProfile> { it.horizontalFov }
.thenBy { it.minFocalLength },
)
val mainProfile = excludedCameraId?.let { backCameraProfile(manager, it) }
val widest = candidates.firstOrNull() ?: return null
val candidatesDesc =
candidates.joinToString { "id=${it.cameraId} fov=${it.horizontalFov} focal=${it.minFocalLength}" }
val mainDesc =
mainProfile?.let { "id=${it.cameraId} fov=${it.horizontalFov} focal=${it.minFocalLength}" }
Log.d(TAG, "ultraWide candidates=[$candidatesDesc] main=$mainDesc")
if (mainProfile == null) {
return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO)
}
val meaningfullyWider =
widest.horizontalFov > mainProfile.horizontalFov * ULTRA_WIDE_FOV_FACTOR ||
widest.minFocalLength < mainProfile.minFocalLength * ULTRA_WIDE_FOCAL_FACTOR
Log.d(
TAG,
"ultraWide decision widest=${widest.cameraId} meaningfullyWider=$meaningfullyWider " +
"(fovFactor=$ULTRA_WIDE_FOV_FACTOR focalFactor=$ULTRA_WIDE_FOCAL_FACTOR)",
)
if (!meaningfullyWider) {
return null
}
return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO)
}
private fun backCameraProfile(
manager: CameraManager,
cameraId: String,
): CameraProfile? {
return try {
val characteristics = manager.getCameraCharacteristics(cameraId)
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
if (facing != CameraCharacteristics.LENS_FACING_BACK) {
return null
}
val focalLengths =
characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
?: return null
val physicalSize =
characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
?: return null
val minFocalLength = focalLengths.minOrNull() ?: return null
val horizontalFov =
2.0 * atan((physicalSize.width / (2.0f * minFocalLength)).toDouble())
CameraProfile(cameraId, minFocalLength, horizontalFov)
} catch (error: Exception) {
Log.w(TAG, "backCameraProfile failed for cameraId=$cameraId", error)
null
}
}
private fun selectorForCurrentLensMode(): CameraSelector {
val cameraId =
if (currentLensMode == LensMode.ULTRA_WIDE) {
ultraWideCameraId
} else {
mainCameraId
}
return if (cameraId != null) {
selectorForCameraId(cameraId)
} else {
CameraSelector.DEFAULT_BACK_CAMERA
}
}
private fun selectorForCameraId(cameraId: String): CameraSelector {
return CameraSelector.Builder()
.addCameraFilter { cameraInfos ->
cameraInfos.filter { Camera2CameraInfo.from(it).cameraId == cameraId }
}
.build()
}
private fun bindUseCases(
provider: ProcessCameraProvider,
lifecycleOwner: LifecycleOwner,
selector: CameraSelector,
) {
val boundPreview = preview ?: throw IllegalStateException("Preview is not ready")
val boundVideoCapture =
videoCapture ?: throw IllegalStateException("Video capture is not ready")
provider.unbindAll()
camera =
provider.bindToLifecycle(
lifecycleOwner,
selector,
boundPreview,
boundVideoCapture,
)
}
private fun switchToUltraWide(
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) {
val ultraWideId = ultraWideCameraId
if (ultraWideId == null) {
onComplete(false, zoomCapabilitiesMap(), "Ultra-wide camera is unavailable")
return
}
if (currentLensMode == LensMode.ULTRA_WIDE) {
currentZoomRatio = ultraWideZoomRatio
onComplete(true, zoomCapabilitiesMap(), null)
return
}
if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording")
return
}
val provider = cameraProvider
val lifecycleOwner = boundLifecycleOwner
if (provider == null || lifecycleOwner == null) {
onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
return
}
try {
currentLensMode = LensMode.ULTRA_WIDE
currentZoomRatio = ultraWideZoomRatio
bindUseCases(provider, lifecycleOwner, selectorForCameraId(ultraWideId))
applyCurrentZoom()
onComplete(true, zoomCapabilitiesMap(), null)
} catch (error: Exception) {
Log.e(TAG, "switchToUltraWide failed", error)
currentLensMode = LensMode.MAIN
currentZoomRatio = 1f
try {
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
applyCurrentZoom()
} catch (restoreError: Exception) {
Log.e(TAG, "restore main camera after ultra-wide failure failed", restoreError)
}
onComplete(false, zoomCapabilitiesMap(), error.message)
}
}
private fun switchToMainAndZoom(
ratio: Double,
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) {
if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording")
return
}
val provider = cameraProvider
val lifecycleOwner = boundLifecycleOwner
if (provider == null || lifecycleOwner == null) {
onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
return
}
try {
currentLensMode = LensMode.MAIN
currentZoomRatio = ratio.toFloat().coerceAtLeast(1f)
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
setZoomRatio(ratio, onComplete)
} catch (error: Exception) {
Log.e(TAG, "switchToMainAndZoom failed", error)
onComplete(false, zoomCapabilitiesMap(), error.message)
}
}
private fun hasUltraWideCamera(): Boolean {
return ultraWideCameraId != null
}
private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean {
return try {
hasCamera(selector)
} catch (error: Exception) {
false
}
}
private enum class LensMode {
MAIN,
ULTRA_WIDE,
}
private data class CameraProfile(
val cameraId: String,
val minFocalLength: Float,
val horizontalFov: Double,
)
private data class UltraWideCamera(
val cameraId: String,
val zoomRatio: Float,
)
companion object { companion object {
private const val TAG = "RecordingCamera" private const val TAG = "RecordingCamera"
private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f
// 适度放宽判定宽容度,覆盖更多机型(更小的 FOV/焦距差异也视为超广角)。
private const val ULTRA_WIDE_FOV_FACTOR = 1.04
private const val ULTRA_WIDE_FOCAL_FACTOR = 0.96
} }
} }

19
buildServer.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "xcode build server",
"version": "1.3.0",
"bspVersion": "2.2.0",
"languages": [
"c",
"cpp",
"objective-c",
"objective-cpp",
"swift"
],
"argv": [
"/opt/homebrew/bin/xcode-build-server"
],
"workspace": "/Users/ZhuanZ/Documents/gdfw/record-tool/ios/Runner.xcworkspace",
"build_root": "/Users/ZhuanZ/Library/Developer/Xcode/DerivedData/Runner-ckjfuyjdkgumnpbnnftroxddsppq",
"scheme": "Runner",
"kind": "xcode"
}

View File

@@ -336,7 +336,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
do { do {
let nextZoom = self.clampedZoomRatio(ratio, for: device) // (1.0x = ) S zoomFactor
let baseline = self.mainBaselineFactor(for: device)
let nextZoom = self.clampedZoomRatio(ratio * baseline, for: device)
try device.lockForConfiguration() try device.lockForConfiguration()
device.videoZoomFactor = nextZoom device.videoZoomFactor = nextZoom
device.unlockForConfiguration() device.unlockForConfiguration()
@@ -427,11 +429,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return return
} }
guard guard let videoDevice = Self.preferredVideoDevice() else {
let videoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
else {
throw NSError( throw NSError(
domain: "RecordingCamera", code: 1, domain: "RecordingCamera", code: 1,
userInfo: [NSLocalizedDescriptionKey: "No camera device available"]) userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
@@ -461,10 +459,39 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
session.commitConfiguration() session.commitConfiguration()
configured = true configured = true
// ( 1.0x) zoomFactor S
currentZoomRatio = mainBaselineFactor(for: videoDevice)
try applyCurrentZoom() try applyCurrentZoom()
try configureAudioInput(enabled: withAudio) try configureAudioInput(enabled: withAudio)
} }
/// 广使 minAvailableVideoZoomFactor ( 0.6x)
private static func preferredVideoDevice() -> AVCaptureDevice? {
let preferredTypes: [AVCaptureDevice.DeviceType] = [
.builtInTripleCamera,
.builtInDualWideCamera,
.builtInWideAngleCamera,
]
for type in preferredTypes {
if let device = AVCaptureDevice.default(type, for: .video, position: .back) {
return device
}
}
return AVCaptureDevice.default(for: .video)
}
/// ( 1.0x) zoomFactor S
/// ultra-wide wide 1.0()
private func mainBaselineFactor(for device: AVCaptureDevice) -> CGFloat {
if let first = device.virtualDeviceSwitchOverVideoZoomFactors.first {
let value = CGFloat(truncating: first)
if value > 0 {
return value
}
}
return 1.0
}
private func currentZoomCapabilitiesMap() -> [String: Any] { private func currentZoomCapabilitiesMap() -> [String: Any] {
guard let device = videoInput?.device else { guard let device = videoInput?.device else {
return [ return [
@@ -474,14 +501,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
] ]
} }
// zoomFactor S App 使(1.0x = )
let baseline = mainBaselineFactor(for: device)
let minZoom = device.minAvailableVideoZoomFactor let minZoom = device.minAvailableVideoZoomFactor
let maxZoom = device.maxAvailableVideoZoomFactor let maxZoom = device.maxAvailableVideoZoomFactor
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device) let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
currentZoomRatio = zoom currentZoomRatio = zoom
return [ return [
"zoomRatio": Double(zoom), "zoomRatio": Double(zoom / baseline),
"minZoomRatio": Double(minZoom), "minZoomRatio": Double(minZoom / baseline),
"maxZoomRatio": Double(maxZoom), "maxZoomRatio": Double(maxZoom / baseline),
] ]
} }

View File

@@ -56,7 +56,7 @@ 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 = [1.0, 2.0, 3.0]; static const List<double> _zoomPresets = [0.6, 1.0];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -146,6 +146,7 @@ class RecordingHudWidget extends StatelessWidget {
right: 16.r, right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h, bottom: _recordButtonBottom + _recordButtonSize + 14.h,
child: _ZoomPresetControl( child: _ZoomPresetControl(
isRecording: isRecording,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio, minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio, maxZoomRatio: maxZoomRatio,
@@ -194,6 +195,7 @@ class RecordingHudWidget extends StatelessWidget {
class _ZoomPresetControl extends StatelessWidget { class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({ const _ZoomPresetControl({
required this.isRecording,
required this.zoomRatio, required this.zoomRatio,
required this.minZoomRatio, required this.minZoomRatio,
required this.maxZoomRatio, required this.maxZoomRatio,
@@ -201,6 +203,7 @@ class _ZoomPresetControl extends StatelessWidget {
required this.onSelected, required this.onSelected,
}); });
final bool isRecording;
final double zoomRatio; final double zoomRatio;
final double minZoomRatio; final double minZoomRatio;
final double maxZoomRatio; final double maxZoomRatio;
@@ -210,7 +213,7 @@ class _ZoomPresetControl extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final availablePresets = presets final availablePresets = presets
.where((preset) => preset >= minZoomRatio && preset <= maxZoomRatio) .where(_isPresetAvailable)
.toList(growable: false); .toList(growable: false);
if (availablePresets.isEmpty) { if (availablePresets.isEmpty) {
@@ -230,8 +233,10 @@ class _ZoomPresetControl extends StatelessWidget {
children: [ children: [
for (final preset in availablePresets) for (final preset in availablePresets)
_ZoomPresetButton( _ZoomPresetButton(
ratio: preset, displayRatio: preset,
selected: (zoomRatio - preset).abs() < 0.05, requestRatio: preset,
selected: _isPresetSelected(preset),
enabled: !_wouldSwitchPhysicalCamera(preset),
onSelected: onSelected, onSelected: onSelected,
), ),
], ],
@@ -239,17 +244,44 @@ class _ZoomPresetControl extends StatelessWidget {
), ),
); );
} }
bool _isPresetAvailable(double preset) {
if (preset < 1.0) {
return minZoomRatio <= preset && maxZoomRatio >= preset;
}
return preset >= minZoomRatio && preset <= maxZoomRatio;
}
bool _isPresetSelected(double preset) {
if (preset < 1.0) {
return zoomRatio < 1.0;
}
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 {
const _ZoomPresetButton({ const _ZoomPresetButton({
required this.ratio, required this.displayRatio,
required this.requestRatio,
required this.selected, required this.selected,
required this.enabled,
required this.onSelected, required this.onSelected,
}); });
final double ratio; final double displayRatio;
final double requestRatio;
final bool selected; final bool selected;
final bool enabled;
final ValueChanged<double> onSelected; final ValueChanged<double> onSelected;
@override @override
@@ -257,7 +289,7 @@ 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 ? null : () => onSelected(ratio), onPressed: selected || !enabled ? null : () => 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,
@@ -271,7 +303,7 @@ class _ZoomPresetButton extends StatelessWidget {
), ),
), ),
child: Text( child: Text(
'${ratio.toStringAsFixed(0)}x', '${_formatZoomRatio(displayRatio)}x',
style: TextStyle( style: TextStyle(
fontSize: 13.sp, fontSize: 13.sp,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -281,4 +313,11 @@ class _ZoomPresetButton extends StatelessWidget {
), ),
); );
} }
String _formatZoomRatio(double ratio) {
if (ratio == ratio.roundToDouble()) {
return ratio.toStringAsFixed(0);
}
return ratio.toStringAsFixed(1);
}
} }

View File

@@ -2,6 +2,7 @@ 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/platform/recording_channel_names.dart'; import 'package:recording_tool/features/recording/platform/recording_channel_names.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';
@@ -75,6 +76,83 @@ void main() {
expect(session.errorMessage, isNull); 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,
),
);
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);
},
);
test('passes 0.6x 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.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,
),
);
await notifier.setZoomRatio(0.6);
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);
});
test('clamps requested zoom ratio before invoking native', () async { test('clamps requested zoom ratio before invoking native', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -98,6 +176,37 @@ void main() {
expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0); expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0);
}); });
test(
'clamps 0.6x to 1x when camera capabilities do not allow it',
() async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(0.6);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 1.0});
expect(
container.read(recordingViewModelProvider).session.zoomRatio,
1.0,
);
},
);
test( test(
'keeps previous zoom ratio and stores error when native fails', 'keeps previous zoom ratio and stores error when native fails',
() async { () async {

View File

@@ -9,6 +9,7 @@ void main() {
double zoomRatio = 1.0, double zoomRatio = 1.0,
double minZoomRatio = 1.0, double minZoomRatio = 1.0,
double maxZoomRatio = 3.0, double maxZoomRatio = 3.0,
bool isRecording = false,
ValueChanged<double>? onZoomSelected, ValueChanged<double>? onZoomSelected,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -22,7 +23,7 @@ void main() {
hasDndAccess: true, hasDndAccess: true,
isBatteryOptimizedIgnored: true, isBatteryOptimizedIgnored: true,
notificationsGranted: true, notificationsGranted: true,
isRecording: false, isRecording: isRecording,
isStartingRecording: false, isStartingRecording: false,
isTouchLocked: false, isTouchLocked: false,
zoomRatio: zoomRatio, zoomRatio: zoomRatio,
@@ -46,35 +47,120 @@ 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('0.6x'), findsNothing);
expect(find.text('1x'), findsOneWidget); expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsOneWidget); expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsOneWidget); expect(find.text('3x'), findsNothing);
}); });
testWidgets('marks current zoom ratio as selected', (tester) async { testWidgets('shows 0.6x when ultra-wide camera capability is below 0.6', (
await pumpHud(tester, zoomRatio: 2.0); tester,
) async {
await pumpHud(tester, minZoomRatio: 0.5);
expect(find.text('0.5x'), findsNothing);
expect(find.text('0.6x'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsNothing);
});
testWidgets('shows 0.6x when 0.6x camera capability supports it', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.6);
expect(find.text('0.6x'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
});
testWidgets('marks current ultra-wide zoom ratio as selected on 0.6x UI', (
tester,
) async {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5);
final selectedButton = tester.widget<TextButton>( final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('2x'), matching: find.byType(TextButton)), find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
testWidgets('marks current 0.6x zoom ratio as selected', (tester) async {
await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6);
final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
); );
expect(selectedButton.enabled, isFalse); expect(selectedButton.enabled, isFalse);
}); });
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, maxZoomRatio: 2.0); await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55);
expect(find.text('1x'), findsOneWidget); expect(find.text('0.6x'), findsNothing);
expect(find.text('2x'), findsOneWidget); expect(find.text('1x'), findsNothing);
expect(find.text('3x'), findsNothing);
}); });
testWidgets('tapping zoom preset reports selected ratio', (tester) async { testWidgets('tapping 0.6x reports 0.6 when camera capability is below 0.6', (
tester,
) async {
double? selected; double? selected;
await pumpHud(tester, onZoomSelected: (ratio) => selected = ratio); await pumpHud(
tester,
minZoomRatio: 0.5,
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('2x')); await tester.tap(find.text('0.6x'));
await tester.pump(); await tester.pump();
expect(selected, 2.0); expect(selected, 0.6);
});
testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', (
tester,
) async {
double? selected;
await pumpHud(
tester,
minZoomRatio: 0.6,
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.6x'));
await tester.pump();
expect(selected, 0.6);
});
testWidgets('disables 0.6x while recording on main camera', (tester) async {
await pumpHud(tester, minZoomRatio: 0.5, isRecording: true);
final ultraWideButton = tester.widget<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,
) async {
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)),
);
final mainButton = tester.widget<TextButton>(
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
);
expect(ultraWideButton.enabled, isFalse);
expect(mainButton.enabled, isFalse);
}); });
} }