Compare commits
2 Commits
c01ce1dca0
...
linfeng/de
| Author | SHA1 | Date | |
|---|---|---|---|
| 88d8dfda04 | |||
| d39d85cd99 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,7 +19,6 @@ pubspec.lock
|
||||
*.iws
|
||||
.idea/
|
||||
.cursor
|
||||
Podfile.lock
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
|
||||
@@ -114,7 +114,6 @@ class MainActivity : FlutterActivity() {
|
||||
"brand" to Build.BRAND,
|
||||
"model" to Build.MODEL,
|
||||
"systemVersion" to Build.VERSION.RELEASE,
|
||||
"sdkInt" to Build.VERSION.SDK_INT,
|
||||
"isPhysicalDevice" to !isEmulator,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.util.Log
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.Preview
|
||||
@@ -15,6 +18,7 @@ import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlin.math.atan
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class RecordingCameraController(
|
||||
@@ -26,6 +30,10 @@ class RecordingCameraController(
|
||||
private var preview: Preview? = null
|
||||
private var videoCapture: VideoCapture<Recorder>? = 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 boundLifecycleOwner: LifecycleOwner? = null
|
||||
private var currentZoomRatio: Float = 1f
|
||||
@@ -63,14 +71,8 @@ class RecordingCameraController(
|
||||
.build()
|
||||
videoCapture = VideoCapture.withOutput(recorder)
|
||||
|
||||
provider.unbindAll()
|
||||
camera =
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
videoCapture,
|
||||
)
|
||||
discoverBackCameras(provider)
|
||||
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
|
||||
applyCurrentZoom()
|
||||
|
||||
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
|
||||
@@ -112,14 +114,7 @@ class RecordingCameraController(
|
||||
|
||||
try {
|
||||
boundLifecycleOwner = lifecycleOwner
|
||||
provider.unbindAll()
|
||||
camera =
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
videoCapture,
|
||||
)
|
||||
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
|
||||
applyCurrentZoom()
|
||||
onReady(true)
|
||||
} catch (error: Exception) {
|
||||
@@ -230,9 +225,19 @@ class RecordingCameraController(
|
||||
|
||||
fun zoomCapabilitiesMap(): Map<String, Any> {
|
||||
val zoomState = camera?.cameraInfo?.zoomState?.value
|
||||
val minZoom = zoomState?.minZoomRatio ?: 1f
|
||||
val minZoom =
|
||||
if (hasUltraWideCamera()) {
|
||||
ultraWideZoomRatio
|
||||
} else {
|
||||
zoomState?.minZoomRatio ?: 1f
|
||||
}
|
||||
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
|
||||
return mapOf(
|
||||
"zoomRatio" to zoom.toDouble(),
|
||||
@@ -247,12 +252,27 @@ class RecordingCameraController(
|
||||
) {
|
||||
val boundCamera = camera
|
||||
if (boundCamera == null) {
|
||||
val clamped = ratio.toFloat().coerceAtLeast(1f)
|
||||
val clamped =
|
||||
if (ratio < 1.0 && hasUltraWideCamera()) {
|
||||
ultraWideZoomRatio
|
||||
} else {
|
||||
ratio.toFloat().coerceAtLeast(1f)
|
||||
}
|
||||
currentZoomRatio = clamped
|
||||
onComplete(true, zoomCapabilitiesMap(), null)
|
||||
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 minZoom = zoomState?.minZoomRatio ?: 1f
|
||||
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
|
||||
@@ -283,7 +303,9 @@ class RecordingCameraController(
|
||||
videoCapture = null
|
||||
camera = null
|
||||
boundLifecycleOwner = null
|
||||
currentLensMode = LensMode.MAIN
|
||||
currentZoomRatio = 1f
|
||||
ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
|
||||
updateStatus(RecordingStatus(RecordingState.IDLE))
|
||||
}
|
||||
|
||||
@@ -299,6 +321,11 @@ class RecordingCameraController(
|
||||
|
||||
private fun applyCurrentZoom() {
|
||||
val boundCamera = camera ?: return
|
||||
if (currentLensMode == LensMode.ULTRA_WIDE) {
|
||||
currentZoomRatio = ultraWideZoomRatio
|
||||
boundCamera.cameraControl.setZoomRatio(1f)
|
||||
return
|
||||
}
|
||||
val zoomState = boundCamera.cameraInfo.zoomState.value
|
||||
val minZoom = zoomState?.minZoomRatio ?: 1f
|
||||
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
|
||||
@@ -310,7 +337,234 @@ class RecordingCameraController(
|
||||
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
|
||||
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
|
||||
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 {
|
||||
private const val TAG = "RecordingCamera"
|
||||
private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f
|
||||
private const val ULTRA_WIDE_FOV_FACTOR = 1.08
|
||||
private const val ULTRA_WIDE_FOCAL_FACTOR = 0.92
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,15 +190,15 @@ class RecordingPlatformHandler(
|
||||
}
|
||||
|
||||
private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
|
||||
val fileSaved = path != null && controller.status.state != RecordingState.ERROR
|
||||
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
|
||||
val payload =
|
||||
mutableMapOf<String, Any?>(
|
||||
"outputPath" to path,
|
||||
"status" to controller.status.toMap(),
|
||||
"fileSaved" to fileSaved,
|
||||
"gallerySaved" to gallerySaved,
|
||||
)
|
||||
if (!fileSaved) {
|
||||
payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
|
||||
if (!gallerySaved) {
|
||||
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
|
||||
}
|
||||
result.success(payload)
|
||||
}
|
||||
|
||||
7
clean.sh
7
clean.sh
@@ -1,7 +0,0 @@
|
||||
flutter clean
|
||||
flutter pub get
|
||||
rm -rf ios/Pods
|
||||
rm -rf ios/Podfile.lock
|
||||
cd ios
|
||||
pod install
|
||||
cd ..
|
||||
@@ -1,3 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
|
||||
|
||||
29
ios/Podfile
29
ios/Podfile
@@ -45,34 +45,9 @@ post_install do |installer|
|
||||
'$(inherited)',
|
||||
'PERMISSION_CAMERA=1',
|
||||
'PERMISSION_MICROPHONE=1',
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_PHOTOS_ADD_ONLY=1',
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
pods_runner_dir = File.join(
|
||||
installer.sandbox.root,
|
||||
'Target Support Files',
|
||||
'Pods-Runner'
|
||||
)
|
||||
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner.*.xcconfig')).each do |config_path|
|
||||
config = File.read(config_path)
|
||||
config.gsub!(
|
||||
'FRAMEWORK_SEARCH_PATHS = $(inherited)',
|
||||
'FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"'
|
||||
)
|
||||
File.write(config_path, config)
|
||||
end
|
||||
|
||||
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner-frameworks-*input-files.xcfilelist')).each do |file_list_path|
|
||||
file_list = File.read(file_list_path)
|
||||
file_list.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
|
||||
File.write(file_list_path, file_list)
|
||||
end
|
||||
|
||||
frameworks_script = File.join(pods_runner_dir, 'Pods-Runner-frameworks.sh')
|
||||
if File.exist?(frameworks_script)
|
||||
script = File.read(frameworks_script)
|
||||
script.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
|
||||
File.write(frameworks_script, script)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,6 +43,6 @@ SPEC CHECKSUMS:
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
|
||||
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
|
||||
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -344,10 +344,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -376,10 +380,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
|
||||
@@ -30,10 +30,8 @@
|
||||
<string>需要访问相机以显示预览并录制视频。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要将录制的视频保存到相册。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFoundation
|
||||
import Flutter
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
private enum RecordingState: String {
|
||||
@@ -109,8 +110,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
private var audioInput: AVCaptureDeviceInput?
|
||||
private var configured = false
|
||||
private var latestOutputPath: String?
|
||||
private var latestFileSaved = true
|
||||
private var latestFileErrorMessage: String?
|
||||
private var latestGallerySaved = true
|
||||
private var latestGalleryErrorMessage: String?
|
||||
private var pendingDisplayName: String?
|
||||
private var recordingStartedAt: Date?
|
||||
private var elapsedTimer: Timer?
|
||||
@@ -215,10 +216,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
}
|
||||
|
||||
self.pendingDisplayName = displayName
|
||||
self.latestFileSaved = true
|
||||
self.latestFileErrorMessage = nil
|
||||
self.latestGallerySaved = true
|
||||
self.latestGalleryErrorMessage = nil
|
||||
let outputURL = try self.createOutputURL(displayName: displayName)
|
||||
self.latestOutputPath = outputURL.path
|
||||
self.latestOutputPath = outputURL.lastPathComponent
|
||||
self.recordingStartedAt = Date()
|
||||
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
||||
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||
@@ -254,11 +255,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
"fileSaved": self.latestFileSaved,
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
]
|
||||
if !self.latestFileSaved {
|
||||
payload["fileErrorMessage"] =
|
||||
self.latestFileErrorMessage ?? "保存到文件夹失败"
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败"
|
||||
}
|
||||
result(payload)
|
||||
}
|
||||
@@ -369,8 +370,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
pendingStopResult = nil
|
||||
|
||||
if let error {
|
||||
latestFileSaved = false
|
||||
latestFileErrorMessage = error.localizedDescription
|
||||
latestGallerySaved = false
|
||||
latestGalleryErrorMessage = error.localizedDescription
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||
@@ -378,30 +379,29 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
return
|
||||
}
|
||||
|
||||
latestFileSaved = true
|
||||
latestFileErrorMessage = nil
|
||||
latestOutputPath = outputFileURL.path
|
||||
guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
|
||||
latestFileSaved = false
|
||||
latestFileErrorMessage = "录制文件未生成"
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error,
|
||||
outputPath: latestOutputPath,
|
||||
message: latestFileErrorMessage
|
||||
)
|
||||
)
|
||||
finishStopRecording(stopResult: stopResult)
|
||||
return
|
||||
}
|
||||
updateStatus(
|
||||
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
|
||||
guard let self else { return }
|
||||
self.latestGallerySaved = success
|
||||
self.latestGalleryErrorMessage = message
|
||||
if success {
|
||||
self.updateStatus(
|
||||
RecordingStatus(
|
||||
state: .previewing,
|
||||
outputPath: latestOutputPath,
|
||||
elapsedMillis: elapsedMillis()
|
||||
outputPath: self.latestOutputPath,
|
||||
elapsedMillis: self.elapsedMillis()
|
||||
)
|
||||
)
|
||||
finishStopRecording(stopResult: stopResult)
|
||||
} else {
|
||||
self.updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error,
|
||||
outputPath: self.latestOutputPath,
|
||||
message: message ?? "保存到相册失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
self.finishStopRecording(stopResult: stopResult)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStopRecording(stopResult: FlutterResult?) {
|
||||
@@ -411,16 +411,68 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
"fileSaved": self.latestFileSaved,
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
]
|
||||
if !self.latestFileSaved {
|
||||
payload["fileErrorMessage"] =
|
||||
self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
|
||||
}
|
||||
stopResult?(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveVideoToPhotoLibrary(
|
||||
fileURL: URL,
|
||||
completion: @escaping (Bool, String?) -> Void
|
||||
) {
|
||||
let performSave = {
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
|
||||
}) { success, error in
|
||||
if success {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
completion(true, nil)
|
||||
} else {
|
||||
completion(false, error?.localizedDescription ?? "保存到相册失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 14, *) {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
|
||||
if newStatus == .authorized || newStatus == .limited {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
} else {
|
||||
let status = PHPhotoLibrary.authorizationStatus()
|
||||
switch status {
|
||||
case .authorized:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization { newStatus in
|
||||
if newStatus == .authorized {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureSession(withAudio: Bool) throws {
|
||||
if configured {
|
||||
try configureAudioInput(enabled: withAudio)
|
||||
@@ -532,30 +584,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
||||
|
||||
let fileName = Self.resolveFileName(displayName: displayName)
|
||||
return uniqueOutputURL(in: recordingsURL, preferredFileName: fileName)
|
||||
}
|
||||
|
||||
private func uniqueOutputURL(in directoryURL: URL, preferredFileName: String) -> URL {
|
||||
let preferredURL = directoryURL.appendingPathComponent(preferredFileName)
|
||||
guard !FileManager.default.fileExists(atPath: preferredURL.path) else {
|
||||
let fileExtension = preferredURL.pathExtension
|
||||
let baseName = preferredURL.deletingPathExtension().lastPathComponent
|
||||
let timestamp = Self.fileNameDateFormatter.string(from: Date())
|
||||
|
||||
var index = 0
|
||||
while true {
|
||||
let suffix = index == 0 ? timestamp : "\(timestamp)_\(index)"
|
||||
let nextName = fileExtension.isEmpty
|
||||
? "\(baseName)_\(suffix)"
|
||||
: "\(baseName)_\(suffix).\(fileExtension)"
|
||||
let nextURL = directoryURL.appendingPathComponent(nextName)
|
||||
if !FileManager.default.fileExists(atPath: nextURL.path) {
|
||||
return nextURL
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return preferredURL
|
||||
return recordingsURL.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func resolveFileName(displayName: String?) -> String {
|
||||
@@ -573,13 +602,6 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
return "REC_\(formatter.string(from: Date())).mov"
|
||||
}
|
||||
|
||||
private static let fileNameDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func updateStatus(_ next: RecordingStatus) {
|
||||
status = next
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class RecordingSessionState {
|
||||
this.lastSavedDisplayName,
|
||||
this.errorMessage,
|
||||
this.permissionWarning,
|
||||
this.fileSaveFailed = false,
|
||||
this.gallerySaveFailed = false,
|
||||
});
|
||||
|
||||
final RecordingStatus status;
|
||||
@@ -36,7 +36,7 @@ class RecordingSessionState {
|
||||
final String? lastSavedDisplayName;
|
||||
final String? errorMessage;
|
||||
final String? permissionWarning;
|
||||
final bool fileSaveFailed;
|
||||
final bool gallerySaveFailed;
|
||||
|
||||
bool get isRecording => status.isRecording;
|
||||
|
||||
@@ -64,7 +64,7 @@ class RecordingSessionState {
|
||||
String? lastSavedDisplayName,
|
||||
String? errorMessage,
|
||||
String? permissionWarning,
|
||||
bool? fileSaveFailed,
|
||||
bool? gallerySaveFailed,
|
||||
bool clearPermissionWarning = false,
|
||||
bool clearLastSaved = false,
|
||||
}) {
|
||||
@@ -89,7 +89,7 @@ class RecordingSessionState {
|
||||
permissionWarning: clearPermissionWarning
|
||||
? null
|
||||
: (permissionWarning ?? this.permissionWarning),
|
||||
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
|
||||
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
await ref.read(recordingViewModelProvider.notifier).stopRecording();
|
||||
if (!mounted) return;
|
||||
final latest = ref.read(recordingViewModelProvider).session;
|
||||
if (latest.fileSaveFailed) {
|
||||
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
|
||||
if (latest.gallerySaveFailed) {
|
||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
||||
return;
|
||||
}
|
||||
await _showRecordingSavedDialogIfNeeded();
|
||||
@@ -190,7 +190,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||
final session = recordingInfo.session;
|
||||
if (session.lastSavedDisplayName == null || session.fileSaveFailed) {
|
||||
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -205,14 +205,14 @@ class RecordingStopResult {
|
||||
const RecordingStopResult({
|
||||
this.outputPath,
|
||||
required this.status,
|
||||
this.fileSaved = true,
|
||||
this.fileErrorMessage,
|
||||
this.gallerySaved = true,
|
||||
this.galleryErrorMessage,
|
||||
});
|
||||
|
||||
final String? outputPath;
|
||||
final RecordingStatus status;
|
||||
final bool fileSaved;
|
||||
final String? fileErrorMessage;
|
||||
final bool gallerySaved;
|
||||
final String? galleryErrorMessage;
|
||||
|
||||
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
|
||||
return RecordingStopResult(
|
||||
@@ -220,8 +220,8 @@ class RecordingStopResult {
|
||||
status: RecordingStatus.fromMap(
|
||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
||||
),
|
||||
fileSaved: result?['fileSaved'] as bool? ?? true,
|
||||
fileErrorMessage: result?['fileErrorMessage'] as String?,
|
||||
gallerySaved: result?['gallerySaved'] as bool? ?? true,
|
||||
galleryErrorMessage: result?['galleryErrorMessage'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||
@@ -32,19 +31,15 @@ enum ClipboardReadResult {
|
||||
invalid,
|
||||
}
|
||||
|
||||
List<Permission> recordingFileSavePermissionsForHost({
|
||||
List<Permission> recordingGalleryPermissionsForHost({
|
||||
required bool isIOS,
|
||||
required bool isAndroid,
|
||||
int? androidSdkInt,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return const [];
|
||||
return [Permission.photosAddOnly];
|
||||
}
|
||||
if (isAndroid) {
|
||||
if (androidSdkInt != null && androidSdkInt >= 29) {
|
||||
return const [];
|
||||
}
|
||||
return [Permission.storage];
|
||||
return [Permission.videos, Permission.storage];
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
@@ -149,12 +144,11 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
return;
|
||||
}
|
||||
|
||||
final fileSavePermissions = await _fileSavePermissions();
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
...fileSavePermissions,
|
||||
..._galleryPermissions(),
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
@@ -176,8 +170,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
if (!microphoneGranted) {
|
||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||
}
|
||||
if (!_isFileSavePermissionGranted(permissions, fileSavePermissions)) {
|
||||
warnings.add('未授予文件保存权限,录制结束后可能无法保存到文件夹');
|
||||
if (!_isGalleryPermissionGranted(permissions)) {
|
||||
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
||||
}
|
||||
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
@@ -266,36 +260,24 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前平台所需的视频文件保存权限列表。
|
||||
Future<List<Permission>> _fileSavePermissions() async {
|
||||
int? androidSdkInt;
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
androidSdkInt = int.tryParse(
|
||||
(await AppPlatformInfo.deviceInfo()).values['sdkInt'] ?? '',
|
||||
);
|
||||
} on PlatformException {
|
||||
androidSdkInt = null;
|
||||
}
|
||||
}
|
||||
return recordingFileSavePermissionsForHost(
|
||||
/// 当前平台所需的相册/视频保存权限列表。
|
||||
List<Permission> _galleryPermissions() {
|
||||
return recordingGalleryPermissionsForHost(
|
||||
isIOS: Platform.isIOS,
|
||||
isAndroid: Platform.isAndroid,
|
||||
androidSdkInt: androidSdkInt,
|
||||
);
|
||||
}
|
||||
|
||||
/// 判断文件保存相关权限是否至少有一项已授予。
|
||||
bool _isFileSavePermissionGranted(
|
||||
/// 判断相册相关权限是否至少有一项已授予。
|
||||
bool _isGalleryPermissionGranted(
|
||||
Map<Permission, PermissionStatus> permissions,
|
||||
List<Permission> fileSavePermissions,
|
||||
) {
|
||||
for (final permission in fileSavePermissions) {
|
||||
for (final permission in _galleryPermissions()) {
|
||||
if (permissions[permission]?.isGranted ?? false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return fileSavePermissions.isEmpty;
|
||||
return _galleryPermissions().isEmpty;
|
||||
}
|
||||
|
||||
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
|
||||
@@ -399,7 +381,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
lastOutputPath: result.outputPath,
|
||||
isTouchLocked: true,
|
||||
errorMessage: null,
|
||||
fileSaveFailed: false,
|
||||
gallerySaveFailed: false,
|
||||
clearLastSaved: true,
|
||||
),
|
||||
);
|
||||
@@ -412,13 +394,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止录制、保存到文件夹,并恢复相机预览。
|
||||
/// 停止录制、保存到相册,并恢复相机预览。
|
||||
Future<void> stopRecording() async {
|
||||
if (!state.session.isRecording) return;
|
||||
|
||||
try {
|
||||
final result = await RecordingPlatform.stopRecording();
|
||||
final fileFailed = !result.fileSaved;
|
||||
final galleryFailed = !result.gallerySaved;
|
||||
final savedName = recordingFileNameForPlatform(
|
||||
state.clipboardRecordingModel.filename,
|
||||
);
|
||||
@@ -426,11 +408,11 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
(s) => s.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath ?? s.lastOutputPath,
|
||||
lastSavedDisplayName: fileFailed ? null : savedName,
|
||||
errorMessage: fileFailed
|
||||
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
|
||||
lastSavedDisplayName: galleryFailed ? null : savedName,
|
||||
errorMessage: galleryFailed
|
||||
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
||||
: null,
|
||||
fileSaveFailed: fileFailed,
|
||||
gallerySaveFailed: galleryFailed,
|
||||
),
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
|
||||
@@ -56,7 +56,7 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
static double get _recordButtonBottom => 63.r;
|
||||
static double get _overlayInfoLeft => 13.r;
|
||||
static double get _overlayInfoBottom => 10.r;
|
||||
static const List<double> _zoomPresets = [1.0, 2.0, 3.0];
|
||||
static const List<double> _zoomPresets = [0.6, 1.0];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -146,6 +146,7 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
right: 16.r,
|
||||
bottom: _recordButtonBottom + _recordButtonSize + 14.h,
|
||||
child: _ZoomPresetControl(
|
||||
isRecording: isRecording,
|
||||
zoomRatio: zoomRatio,
|
||||
minZoomRatio: minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio,
|
||||
@@ -194,6 +195,7 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
|
||||
class _ZoomPresetControl extends StatelessWidget {
|
||||
const _ZoomPresetControl({
|
||||
required this.isRecording,
|
||||
required this.zoomRatio,
|
||||
required this.minZoomRatio,
|
||||
required this.maxZoomRatio,
|
||||
@@ -201,6 +203,7 @@ class _ZoomPresetControl extends StatelessWidget {
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final bool isRecording;
|
||||
final double zoomRatio;
|
||||
final double minZoomRatio;
|
||||
final double maxZoomRatio;
|
||||
@@ -210,7 +213,7 @@ class _ZoomPresetControl extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final availablePresets = presets
|
||||
.where((preset) => preset >= minZoomRatio && preset <= maxZoomRatio)
|
||||
.where(_isPresetAvailable)
|
||||
.toList(growable: false);
|
||||
|
||||
if (availablePresets.isEmpty) {
|
||||
@@ -230,8 +233,10 @@ class _ZoomPresetControl extends StatelessWidget {
|
||||
children: [
|
||||
for (final preset in availablePresets)
|
||||
_ZoomPresetButton(
|
||||
ratio: preset,
|
||||
selected: (zoomRatio - preset).abs() < 0.05,
|
||||
displayRatio: preset,
|
||||
requestRatio: preset,
|
||||
selected: _isPresetSelected(preset),
|
||||
enabled: !_wouldSwitchPhysicalCamera(preset),
|
||||
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 {
|
||||
const _ZoomPresetButton({
|
||||
required this.ratio,
|
||||
required this.displayRatio,
|
||||
required this.requestRatio,
|
||||
required this.selected,
|
||||
required this.enabled,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final double ratio;
|
||||
final double displayRatio;
|
||||
final double requestRatio;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final ValueChanged<double> onSelected;
|
||||
|
||||
@override
|
||||
@@ -257,7 +289,7 @@ class _ZoomPresetButton extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 1.r),
|
||||
child: TextButton(
|
||||
onPressed: selected ? null : () => onSelected(ratio),
|
||||
onPressed: selected || !enabled ? null : () => onSelected(requestRatio),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: Size(38.r, 32.r),
|
||||
padding: EdgeInsets.zero,
|
||||
@@ -271,7 +303,7 @@ class _ZoomPresetButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${ratio.toStringAsFixed(0)}x',
|
||||
'${_formatZoomRatio(displayRatio)}x',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:recording_tool/features/dialog/dialog-record.dart';
|
||||
|
||||
/// 录制结束并保存到文件夹后的后续操作弹窗。
|
||||
/// 录制结束并保存到相册后的后续操作弹窗。
|
||||
Future<void> showRecordingSavedDialog(
|
||||
BuildContext context, {
|
||||
required String sessionTitle,
|
||||
@@ -10,7 +10,7 @@ Future<void> showRecordingSavedDialog(
|
||||
}) {
|
||||
return RecordDialog.showDouble(
|
||||
context,
|
||||
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
|
||||
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
|
||||
leftText: '继续本轮',
|
||||
rightText: '录制新轮',
|
||||
onLeftPressed: onContinueRound,
|
||||
|
||||
@@ -71,7 +71,7 @@ void main() {
|
||||
});
|
||||
|
||||
group('iOS permission configuration', () {
|
||||
test('Podfile enables camera and microphone permission macros only', () {
|
||||
test('Podfile enables camera, microphone and photos permission macros', () {
|
||||
final podfile = File('ios/Podfile').readAsStringSync();
|
||||
|
||||
expect(
|
||||
@@ -80,8 +80,8 @@ void main() {
|
||||
);
|
||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
||||
expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'")));
|
||||
expect(podfile, isNot(contains("'PERMISSION_PHOTOS_ADD_ONLY=1'")));
|
||||
expect(podfile, contains("'PERMISSION_PHOTOS=1'"));
|
||||
expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ void main() {
|
||||
onPressed: () {
|
||||
RecordDialog.showDouble(
|
||||
context,
|
||||
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
|
||||
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
|
||||
leftText: '继续本轮',
|
||||
rightText: '录制新轮',
|
||||
onLeftPressed: () => leftTapped = true,
|
||||
@@ -123,7 +123,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
|
||||
expect(find.text('本轮比赛视频已保存到文件夹\n请选择后续录制信息'), findsOneWidget);
|
||||
expect(find.text('本轮比赛视频已保存到相册\n请选择后续录制信息'), findsOneWidget);
|
||||
expect(find.text('继续本轮'), findsOneWidget);
|
||||
expect(find.text('录制新轮'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -18,20 +18,4 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('RecordingStopResult', () {
|
||||
test('parses file save result fields from platform payload', () {
|
||||
final result = RecordingStopResult.fromMap(<String, dynamic>{
|
||||
'outputPath': '/Documents/recordings/test.mov',
|
||||
'status': <String, dynamic>{'state': 'previewing'},
|
||||
'fileSaved': false,
|
||||
'fileErrorMessage': '保存到文件夹失败',
|
||||
});
|
||||
|
||||
expect(result.outputPath, '/Documents/recordings/test.mov');
|
||||
expect(result.status.state, RecordingState.previewing);
|
||||
expect(result.fileSaved, isFalse);
|
||||
expect(result.fileErrorMessage, '保存到文件夹失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.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/view-model/view_model_recording.dart';
|
||||
|
||||
@@ -75,6 +76,83 @@ void main() {
|
||||
expect(session.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'clamps legacy 0.5x request to 0.6x ultra-wide ratio',
|
||||
() async {
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'zoomRatio': 0.6,
|
||||
'minZoomRatio': 0.6,
|
||||
'maxZoomRatio': 3.0,
|
||||
};
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final notifier = container.read(recordingViewModelProvider.notifier);
|
||||
// ignore: invalid_use_of_protected_member
|
||||
notifier.state = container
|
||||
.read(recordingViewModelProvider)
|
||||
.copyWith(
|
||||
session: const RecordingSessionState(
|
||||
zoomRatio: 1.0,
|
||||
minZoomRatio: 0.6,
|
||||
maxZoomRatio: 3.0,
|
||||
),
|
||||
);
|
||||
|
||||
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 {
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
@@ -98,6 +176,37 @@ void main() {
|
||||
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(
|
||||
'keeps previous zoom ratio and stores error when native fails',
|
||||
() async {
|
||||
@@ -125,37 +234,24 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
group('recordingFileSavePermissionsForHost', () {
|
||||
test('does not request photo permission on iOS', () {
|
||||
final permissions = recordingFileSavePermissionsForHost(
|
||||
group('recordingGalleryPermissionsForHost', () {
|
||||
test('requests only add-only photo permission on iOS', () {
|
||||
final permissions = recordingGalleryPermissionsForHost(
|
||||
isIOS: true,
|
||||
isAndroid: false,
|
||||
);
|
||||
|
||||
expect(permissions, isEmpty);
|
||||
expect(permissions, isNot(contains(Permission.photosAddOnly)));
|
||||
expect(permissions, <Permission>[Permission.photosAddOnly]);
|
||||
expect(permissions, isNot(contains(Permission.photos)));
|
||||
});
|
||||
|
||||
test('requests storage permission on Android 9 and below', () {
|
||||
final permissions = recordingFileSavePermissionsForHost(
|
||||
test('keeps Android gallery permissions unchanged', () {
|
||||
final permissions = recordingGalleryPermissionsForHost(
|
||||
isIOS: false,
|
||||
isAndroid: true,
|
||||
androidSdkInt: 28,
|
||||
);
|
||||
|
||||
expect(permissions, <Permission>[Permission.storage]);
|
||||
expect(permissions, isNot(contains(Permission.videos)));
|
||||
});
|
||||
|
||||
test('does not request file save permission on Android 10 and above', () {
|
||||
final permissions = recordingFileSavePermissionsForHost(
|
||||
isIOS: false,
|
||||
isAndroid: true,
|
||||
androidSdkInt: 29,
|
||||
);
|
||||
|
||||
expect(permissions, isEmpty);
|
||||
expect(permissions, <Permission>[Permission.videos, Permission.storage]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ void main() {
|
||||
double zoomRatio = 1.0,
|
||||
double minZoomRatio = 1.0,
|
||||
double maxZoomRatio = 3.0,
|
||||
bool isRecording = false,
|
||||
ValueChanged<double>? onZoomSelected,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -22,7 +23,7 @@ void main() {
|
||||
hasDndAccess: true,
|
||||
isBatteryOptimizedIgnored: true,
|
||||
notificationsGranted: true,
|
||||
isRecording: false,
|
||||
isRecording: isRecording,
|
||||
isStartingRecording: false,
|
||||
isTouchLocked: false,
|
||||
zoomRatio: zoomRatio,
|
||||
@@ -46,35 +47,120 @@ void main() {
|
||||
testWidgets('shows preset zoom buttons', (tester) async {
|
||||
await pumpHud(tester);
|
||||
|
||||
expect(find.text('0.5x'), findsNothing);
|
||||
expect(find.text('0.6x'), findsNothing);
|
||||
expect(find.text('1x'), findsOneWidget);
|
||||
expect(find.text('2x'), findsOneWidget);
|
||||
expect(find.text('3x'), findsOneWidget);
|
||||
expect(find.text('2x'), findsNothing);
|
||||
expect(find.text('3x'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('marks current zoom ratio as selected', (tester) async {
|
||||
await pumpHud(tester, zoomRatio: 2.0);
|
||||
testWidgets('shows 0.6x when ultra-wide camera capability is below 0.6', (
|
||||
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>(
|
||||
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);
|
||||
});
|
||||
|
||||
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('2x'), findsOneWidget);
|
||||
expect(find.text('3x'), findsNothing);
|
||||
expect(find.text('0.6x'), findsNothing);
|
||||
expect(find.text('1x'), 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;
|
||||
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();
|
||||
|
||||
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