Compare commits
4 Commits
c01ce1dca0
...
8570486798
| Author | SHA1 | Date | |
|---|---|---|---|
| 8570486798 | |||
| 208920dfea | |||
| 88d8dfda04 | |||
| d39d85cd99 |
@@ -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
19
buildServer.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user