2 Commits

23 changed files with 683 additions and 250 deletions

1
.gitignore vendored
View File

@@ -19,7 +19,6 @@ pubspec.lock
*.iws *.iws
.idea/ .idea/
.cursor .cursor
Podfile.lock
# The .vscode folder contains launch configuration and tasks you configure in # 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 # VS Code which you may wish to be included in version control, so this line

View File

@@ -114,7 +114,6 @@ class MainActivity : FlutterActivity() {
"brand" to Build.BRAND, "brand" to Build.BRAND,
"model" to Build.MODEL, "model" to Build.MODEL,
"systemVersion" to Build.VERSION.RELEASE, "systemVersion" to Build.VERSION.RELEASE,
"sdkInt" to Build.VERSION.SDK_INT,
"isPhysicalDevice" to !isEmulator, "isPhysicalDevice" to !isEmulator,
) )
} }

View File

@@ -1,7 +1,10 @@
package com.dronex.rec.recording package com.dronex.rec.recording
import android.content.Context import android.content.Context
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.util.Log import android.util.Log
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.Camera import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
@@ -15,6 +18,7 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import kotlin.math.atan
import java.util.concurrent.Executor import java.util.concurrent.Executor
class RecordingCameraController( class RecordingCameraController(
@@ -26,6 +30,10 @@ class RecordingCameraController(
private var preview: Preview? = null private var preview: Preview? = null
private var videoCapture: VideoCapture<Recorder>? = null private var videoCapture: VideoCapture<Recorder>? = null
private var camera: Camera? = null private var camera: Camera? = null
private var mainCameraId: String? = null
private var ultraWideCameraId: String? = null
private var ultraWideZoomRatio: Float = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
private var currentLensMode: LensMode = LensMode.MAIN
private var activeRecording: Recording? = null private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null private var boundLifecycleOwner: LifecycleOwner? = null
private var currentZoomRatio: Float = 1f private var currentZoomRatio: Float = 1f
@@ -63,14 +71,8 @@ class RecordingCameraController(
.build() .build()
videoCapture = VideoCapture.withOutput(recorder) videoCapture = VideoCapture.withOutput(recorder)
provider.unbindAll() discoverBackCameras(provider)
camera = bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
applyCurrentZoom() applyCurrentZoom()
updateStatus(RecordingStatus(RecordingState.PREVIEWING)) updateStatus(RecordingStatus(RecordingState.PREVIEWING))
@@ -112,14 +114,7 @@ class RecordingCameraController(
try { try {
boundLifecycleOwner = lifecycleOwner boundLifecycleOwner = lifecycleOwner
provider.unbindAll() bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
camera =
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
applyCurrentZoom() applyCurrentZoom()
onReady(true) onReady(true)
} catch (error: Exception) { } catch (error: Exception) {
@@ -230,9 +225,19 @@ 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 minZoom =
if (hasUltraWideCamera()) {
ultraWideZoomRatio
} else {
zoomState?.minZoomRatio ?: 1f
}
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
return mapOf( return mapOf(
"zoomRatio" to zoom.toDouble(), "zoomRatio" to zoom.toDouble(),
@@ -247,12 +252,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 +303,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 +321,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 +337,234 @@ 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
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 { companion object {
private const val TAG = "RecordingCamera" private const val TAG = "RecordingCamera"
private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f
private const val ULTRA_WIDE_FOV_FACTOR = 1.08
private const val ULTRA_WIDE_FOCAL_FACTOR = 0.92
} }
} }

View File

@@ -190,15 +190,15 @@ class RecordingPlatformHandler(
} }
private fun deliverStopResult(result: MethodChannel.Result, path: String?) { 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 = val payload =
mutableMapOf<String, Any?>( mutableMapOf<String, Any?>(
"outputPath" to path, "outputPath" to path,
"status" to controller.status.toMap(), "status" to controller.status.toMap(),
"fileSaved" to fileSaved, "gallerySaved" to gallerySaved,
) )
if (!fileSaved) { if (!gallerySaved) {
payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败" payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
} }
result.success(payload) result.success(payload)
} }

View File

@@ -1,7 +0,0 @@
flutter clean
flutter pub get
rm -rf ios/Pods
rm -rf ios/Podfile.lock
cd ios
pod install
cd ..

View File

@@ -1,3 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"

View File

@@ -1,3 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"

View File

@@ -45,34 +45,9 @@ post_install do |installer|
'$(inherited)', '$(inherited)',
'PERMISSION_CAMERA=1', 'PERMISSION_CAMERA=1',
'PERMISSION_MICROPHONE=1', 'PERMISSION_MICROPHONE=1',
'PERMISSION_PHOTOS=1',
'PERMISSION_PHOTOS_ADD_ONLY=1',
] ]
end end
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 end

View File

@@ -43,6 +43,6 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -344,10 +344,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -376,10 +380,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";

View File

@@ -30,10 +30,8 @@
<string>需要访问相机以显示预览并录制视频。</string> <string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string> <string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>UIFileSharingEnabled</key> <key>NSPhotoLibraryAddUsageDescription</key>
<true/> <string>需要将录制的视频保存到相册。</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -1,5 +1,6 @@
import AVFoundation import AVFoundation
import Flutter import Flutter
import Photos
import UIKit import UIKit
private enum RecordingState: String { private enum RecordingState: String {
@@ -109,8 +110,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
private var audioInput: AVCaptureDeviceInput? private var audioInput: AVCaptureDeviceInput?
private var configured = false private var configured = false
private var latestOutputPath: String? private var latestOutputPath: String?
private var latestFileSaved = true private var latestGallerySaved = true
private var latestFileErrorMessage: String? private var latestGalleryErrorMessage: String?
private var pendingDisplayName: String? private var pendingDisplayName: String?
private var recordingStartedAt: Date? private var recordingStartedAt: Date?
private var elapsedTimer: Timer? private var elapsedTimer: Timer?
@@ -215,10 +216,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
self.pendingDisplayName = displayName self.pendingDisplayName = displayName
self.latestFileSaved = true self.latestGallerySaved = true
self.latestFileErrorMessage = nil self.latestGalleryErrorMessage = nil
let outputURL = try self.createOutputURL(displayName: displayName) let outputURL = try self.createOutputURL(displayName: displayName)
self.latestOutputPath = outputURL.path self.latestOutputPath = outputURL.lastPathComponent
self.recordingStartedAt = Date() self.recordingStartedAt = Date()
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path)) self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self) self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
@@ -254,11 +255,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
var payload: [String: Any] = [ var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any, "outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(), "status": self.currentStatusMap(),
"fileSaved": self.latestFileSaved, "gallerySaved": self.latestGallerySaved,
] ]
if !self.latestFileSaved { if !self.latestGallerySaved {
payload["fileErrorMessage"] = payload["galleryErrorMessage"] =
self.latestFileErrorMessage ?? "保存到文件夹失败" self.latestGalleryErrorMessage ?? "保存到相册失败"
} }
result(payload) result(payload)
} }
@@ -369,8 +370,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
pendingStopResult = nil pendingStopResult = nil
if let error { if let error {
latestFileSaved = false latestGallerySaved = false
latestFileErrorMessage = error.localizedDescription latestGalleryErrorMessage = error.localizedDescription
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
@@ -378,30 +379,29 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return return
} }
latestFileSaved = true saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
latestFileErrorMessage = nil guard let self else { return }
latestOutputPath = outputFileURL.path self.latestGallerySaved = success
guard FileManager.default.fileExists(atPath: outputFileURL.path) else { self.latestGalleryErrorMessage = message
latestFileSaved = false if success {
latestFileErrorMessage = "录制文件未生成" self.updateStatus(
updateStatus( RecordingStatus(
RecordingStatus( state: .previewing,
state: .error, outputPath: self.latestOutputPath,
outputPath: latestOutputPath, elapsedMillis: self.elapsedMillis()
message: latestFileErrorMessage )
) )
) } else {
finishStopRecording(stopResult: stopResult) self.updateStatus(
return RecordingStatus(
state: .error,
outputPath: self.latestOutputPath,
message: message ?? "保存到相册失败"
)
)
}
self.finishStopRecording(stopResult: stopResult)
} }
updateStatus(
RecordingStatus(
state: .previewing,
outputPath: latestOutputPath,
elapsedMillis: elapsedMillis()
)
)
finishStopRecording(stopResult: stopResult)
} }
private func finishStopRecording(stopResult: FlutterResult?) { private func finishStopRecording(stopResult: FlutterResult?) {
@@ -411,16 +411,68 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
var payload: [String: Any] = [ var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any, "outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(), "status": self.currentStatusMap(),
"fileSaved": self.latestFileSaved, "gallerySaved": self.latestGallerySaved,
] ]
if !self.latestFileSaved { if !self.latestGallerySaved {
payload["fileErrorMessage"] = payload["galleryErrorMessage"] =
self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限" self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
} }
stopResult?(payload) 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 { private func configureSession(withAudio: Bool) throws {
if configured { if configured {
try configureAudioInput(enabled: withAudio) try configureAudioInput(enabled: withAudio)
@@ -532,30 +584,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
let fileName = Self.resolveFileName(displayName: displayName) let fileName = Self.resolveFileName(displayName: displayName)
return uniqueOutputURL(in: recordingsURL, preferredFileName: fileName) return recordingsURL.appendingPathComponent(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
} }
private static func resolveFileName(displayName: String?) -> String { private static func resolveFileName(displayName: String?) -> String {
@@ -573,13 +602,6 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return "REC_\(formatter.string(from: Date())).mov" 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) { private func updateStatus(_ next: RecordingStatus) {
status = next status = next
} }

View File

@@ -18,7 +18,7 @@ class RecordingSessionState {
this.lastSavedDisplayName, this.lastSavedDisplayName,
this.errorMessage, this.errorMessage,
this.permissionWarning, this.permissionWarning,
this.fileSaveFailed = false, this.gallerySaveFailed = false,
}); });
final RecordingStatus status; final RecordingStatus status;
@@ -36,7 +36,7 @@ class RecordingSessionState {
final String? lastSavedDisplayName; final String? lastSavedDisplayName;
final String? errorMessage; final String? errorMessage;
final String? permissionWarning; final String? permissionWarning;
final bool fileSaveFailed; final bool gallerySaveFailed;
bool get isRecording => status.isRecording; bool get isRecording => status.isRecording;
@@ -64,7 +64,7 @@ class RecordingSessionState {
String? lastSavedDisplayName, String? lastSavedDisplayName,
String? errorMessage, String? errorMessage,
String? permissionWarning, String? permissionWarning,
bool? fileSaveFailed, bool? gallerySaveFailed,
bool clearPermissionWarning = false, bool clearPermissionWarning = false,
bool clearLastSaved = false, bool clearLastSaved = false,
}) { }) {
@@ -89,7 +89,7 @@ class RecordingSessionState {
permissionWarning: clearPermissionWarning permissionWarning: clearPermissionWarning
? null ? null
: (permissionWarning ?? this.permissionWarning), : (permissionWarning ?? this.permissionWarning),
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed, gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
); );
} }
} }

View File

@@ -172,8 +172,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
await ref.read(recordingViewModelProvider.notifier).stopRecording(); await ref.read(recordingViewModelProvider.notifier).stopRecording();
if (!mounted) return; if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session; final latest = ref.read(recordingViewModelProvider).session;
if (latest.fileSaveFailed) { if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
return; return;
} }
await _showRecordingSavedDialogIfNeeded(); await _showRecordingSavedDialogIfNeeded();
@@ -190,7 +190,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
Future<void> _showRecordingSavedDialogIfNeeded() async { Future<void> _showRecordingSavedDialogIfNeeded() async {
final recordingInfo = ref.read(recordingViewModelProvider); final recordingInfo = ref.read(recordingViewModelProvider);
final session = recordingInfo.session; final session = recordingInfo.session;
if (session.lastSavedDisplayName == null || session.fileSaveFailed) { if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
return; return;
} }

View File

@@ -205,14 +205,14 @@ class RecordingStopResult {
const RecordingStopResult({ const RecordingStopResult({
this.outputPath, this.outputPath,
required this.status, required this.status,
this.fileSaved = true, this.gallerySaved = true,
this.fileErrorMessage, this.galleryErrorMessage,
}); });
final String? outputPath; final String? outputPath;
final RecordingStatus status; final RecordingStatus status;
final bool fileSaved; final bool gallerySaved;
final String? fileErrorMessage; final String? galleryErrorMessage;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) { factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult( return RecordingStopResult(
@@ -220,8 +220,8 @@ class RecordingStopResult {
status: RecordingStatus.fromMap( status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}), Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
), ),
fileSaved: result?['fileSaved'] as bool? ?? true, gallerySaved: result?['gallerySaved'] as bool? ?? true,
fileErrorMessage: result?['fileErrorMessage'] as String?, galleryErrorMessage: result?['galleryErrorMessage'] as String?,
); );
} }
} }

View File

@@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/logging/app_logger.dart'; import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:recording_tool/core/permission/permission_service.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_clipboard.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart'; import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/model/model_recording_session.dart';
@@ -32,19 +31,15 @@ enum ClipboardReadResult {
invalid, invalid,
} }
List<Permission> recordingFileSavePermissionsForHost({ List<Permission> recordingGalleryPermissionsForHost({
required bool isIOS, required bool isIOS,
required bool isAndroid, required bool isAndroid,
int? androidSdkInt,
}) { }) {
if (isIOS) { if (isIOS) {
return const []; return [Permission.photosAddOnly];
} }
if (isAndroid) { if (isAndroid) {
if (androidSdkInt != null && androidSdkInt >= 29) { return [Permission.videos, Permission.storage];
return const [];
}
return [Permission.storage];
} }
return const []; return const [];
} }
@@ -149,12 +144,11 @@ class RecordingViewModel extends Notifier<RecordingModel> {
return; return;
} }
final fileSavePermissions = await _fileSavePermissions();
final permissions = await PermissionService.requestMissing([ final permissions = await PermissionService.requestMissing([
Permission.camera, Permission.camera,
Permission.microphone, Permission.microphone,
if (Platform.isAndroid) Permission.notification, if (Platform.isAndroid) Permission.notification,
...fileSavePermissions, ..._galleryPermissions(),
]); ]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
@@ -176,8 +170,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
if (!microphoneGranted) { if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制'); warnings.add('未授予麦克风权限,当前将以静音模式录制');
} }
if (!_isFileSavePermissionGranted(permissions, fileSavePermissions)) { if (!_isGalleryPermissionGranted(permissions)) {
warnings.add('未授予文件保存权限,录制结束后可能无法保存到文件夹'); warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
} }
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
@@ -266,36 +260,24 @@ class RecordingViewModel extends Notifier<RecordingModel> {
} }
} }
/// 当前平台所需的视频文件保存权限列表。 /// 当前平台所需的相册/视频保存权限列表。
Future<List<Permission>> _fileSavePermissions() async { List<Permission> _galleryPermissions() {
int? androidSdkInt; return recordingGalleryPermissionsForHost(
if (Platform.isAndroid) {
try {
androidSdkInt = int.tryParse(
(await AppPlatformInfo.deviceInfo()).values['sdkInt'] ?? '',
);
} on PlatformException {
androidSdkInt = null;
}
}
return recordingFileSavePermissionsForHost(
isIOS: Platform.isIOS, isIOS: Platform.isIOS,
isAndroid: Platform.isAndroid, isAndroid: Platform.isAndroid,
androidSdkInt: androidSdkInt,
); );
} }
/// 判断文件保存相关权限是否至少有一项已授予。 /// 判断相册相关权限是否至少有一项已授予。
bool _isFileSavePermissionGranted( bool _isGalleryPermissionGranted(
Map<Permission, PermissionStatus> permissions, Map<Permission, PermissionStatus> permissions,
List<Permission> fileSavePermissions,
) { ) {
for (final permission in fileSavePermissions) { for (final permission in _galleryPermissions()) {
if (permissions[permission]?.isGranted ?? false) { if (permissions[permission]?.isGranted ?? false) {
return true; return true;
} }
} }
return fileSavePermissions.isEmpty; return _galleryPermissions().isEmpty;
} }
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。 /// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
@@ -399,7 +381,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
lastOutputPath: result.outputPath, lastOutputPath: result.outputPath,
isTouchLocked: true, isTouchLocked: true,
errorMessage: null, errorMessage: null,
fileSaveFailed: false, gallerySaveFailed: false,
clearLastSaved: true, clearLastSaved: true,
), ),
); );
@@ -412,13 +394,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
} }
} }
/// 停止录制、保存到文件夹,并恢复相机预览。 /// 停止录制、保存到相册,并恢复相机预览。
Future<void> stopRecording() async { Future<void> stopRecording() async {
if (!state.session.isRecording) return; if (!state.session.isRecording) return;
try { try {
final result = await RecordingPlatform.stopRecording(); final result = await RecordingPlatform.stopRecording();
final fileFailed = !result.fileSaved; final galleryFailed = !result.gallerySaved;
final savedName = recordingFileNameForPlatform( final savedName = recordingFileNameForPlatform(
state.clipboardRecordingModel.filename, state.clipboardRecordingModel.filename,
); );
@@ -426,11 +408,11 @@ class RecordingViewModel extends Notifier<RecordingModel> {
(s) => s.copyWith( (s) => s.copyWith(
status: result.status, status: result.status,
lastOutputPath: result.outputPath ?? s.lastOutputPath, lastOutputPath: result.outputPath ?? s.lastOutputPath,
lastSavedDisplayName: fileFailed ? null : savedName, lastSavedDisplayName: galleryFailed ? null : savedName,
errorMessage: fileFailed errorMessage: galleryFailed
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限') ? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
: null, : null,
fileSaveFailed: fileFailed, gallerySaveFailed: galleryFailed,
), ),
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {

View File

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

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:recording_tool/features/dialog/dialog-record.dart'; import 'package:recording_tool/features/dialog/dialog-record.dart';
/// 录制结束并保存到文件夹后的后续操作弹窗。 /// 录制结束并保存到相册后的后续操作弹窗。
Future<void> showRecordingSavedDialog( Future<void> showRecordingSavedDialog(
BuildContext context, { BuildContext context, {
required String sessionTitle, required String sessionTitle,
@@ -10,7 +10,7 @@ Future<void> showRecordingSavedDialog(
}) { }) {
return RecordDialog.showDouble( return RecordDialog.showDouble(
context, context,
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息', title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
leftText: '继续本轮', leftText: '继续本轮',
rightText: '录制新轮', rightText: '录制新轮',
onLeftPressed: onContinueRound, onLeftPressed: onContinueRound,

View File

@@ -71,7 +71,7 @@ void main() {
}); });
group('iOS permission configuration', () { 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(); final podfile = File('ios/Podfile').readAsStringSync();
expect( expect(
@@ -80,8 +80,8 @@ void main() {
); );
expect(podfile, contains("'PERMISSION_CAMERA=1'")); expect(podfile, contains("'PERMISSION_CAMERA=1'"));
expect(podfile, contains("'PERMISSION_MICROPHONE=1'")); expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'"))); expect(podfile, contains("'PERMISSION_PHOTOS=1'"));
expect(podfile, isNot(contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"))); expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"));
}); });
}); });
} }

View File

@@ -68,7 +68,7 @@ void main() {
onPressed: () { onPressed: () {
RecordDialog.showDouble( RecordDialog.showDouble(
context, context,
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息', title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
leftText: '继续本轮', leftText: '继续本轮',
rightText: '录制新轮', rightText: '录制新轮',
onLeftPressed: () => leftTapped = true, onLeftPressed: () => leftTapped = true,
@@ -123,7 +123,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
expect(find.text('本轮比赛视频已保存到文件夹\n请选择后续录制信息'), findsOneWidget); expect(find.text('本轮比赛视频已保存到相册\n请选择后续录制信息'), findsOneWidget);
expect(find.text('继续本轮'), findsOneWidget); expect(find.text('继续本轮'), findsOneWidget);
expect(find.text('录制新轮'), findsOneWidget); expect(find.text('录制新轮'), findsOneWidget);
}); });

View File

@@ -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, '保存到文件夹失败');
});
});
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart'; import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
@@ -75,6 +76,83 @@ void main() {
expect(session.errorMessage, isNull); expect(session.errorMessage, isNull);
}); });
test(
'clamps legacy 0.5x request to 0.6x ultra-wide ratio',
() async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 0.6,
'minZoomRatio': 0.6,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
// ignore: invalid_use_of_protected_member
notifier.state = container
.read(recordingViewModelProvider)
.copyWith(
session: const RecordingSessionState(
zoomRatio: 1.0,
minZoomRatio: 0.6,
maxZoomRatio: 3.0,
),
);
await notifier.setZoomRatio(0.5);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.6});
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 0.6);
expect(session.minZoomRatio, 0.6);
expect(session.maxZoomRatio, 3.0);
},
);
test('passes 0.6x to native when camera capabilities allow it', () async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 0.6,
'minZoomRatio': 0.6,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
// ignore: invalid_use_of_protected_member
notifier.state = container
.read(recordingViewModelProvider)
.copyWith(
session: const RecordingSessionState(
zoomRatio: 1.0,
minZoomRatio: 0.6,
maxZoomRatio: 3.0,
),
);
await notifier.setZoomRatio(0.6);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.6});
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 0.6);
expect(session.minZoomRatio, 0.6);
expect(session.maxZoomRatio, 3.0);
});
test('clamps requested zoom ratio before invoking native', () async { test('clamps requested zoom ratio before invoking native', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -98,6 +176,37 @@ void main() {
expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0); expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0);
}); });
test(
'clamps 0.6x to 1x when camera capabilities do not allow it',
() async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(0.6);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 1.0});
expect(
container.read(recordingViewModelProvider).session.zoomRatio,
1.0,
);
},
);
test( test(
'keeps previous zoom ratio and stores error when native fails', 'keeps previous zoom ratio and stores error when native fails',
() async { () async {
@@ -125,37 +234,24 @@ void main() {
); );
}); });
group('recordingFileSavePermissionsForHost', () { group('recordingGalleryPermissionsForHost', () {
test('does not request photo permission on iOS', () { test('requests only add-only photo permission on iOS', () {
final permissions = recordingFileSavePermissionsForHost( final permissions = recordingGalleryPermissionsForHost(
isIOS: true, isIOS: true,
isAndroid: false, isAndroid: false,
); );
expect(permissions, isEmpty); expect(permissions, <Permission>[Permission.photosAddOnly]);
expect(permissions, isNot(contains(Permission.photosAddOnly)));
expect(permissions, isNot(contains(Permission.photos))); expect(permissions, isNot(contains(Permission.photos)));
}); });
test('requests storage permission on Android 9 and below', () { test('keeps Android gallery permissions unchanged', () {
final permissions = recordingFileSavePermissionsForHost( final permissions = recordingGalleryPermissionsForHost(
isIOS: false, isIOS: false,
isAndroid: true, isAndroid: true,
androidSdkInt: 28,
); );
expect(permissions, <Permission>[Permission.storage]); expect(permissions, <Permission>[Permission.videos, 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);
}); });
}); });

View File

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