10 Commits

28 changed files with 1375 additions and 182 deletions

1
.gitignore vendored
View File

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

View File

@@ -50,6 +50,11 @@ class RecordingPlatformHandler(
startRecording(withAudio, enableDnd, displayName, result) startRecording(withAudio, enableDnd, displayName, result)
} }
"stopRecording" -> stopRecording(result) "stopRecording" -> stopRecording(result)
"getZoomCapabilities" -> result.success(controller.zoomCapabilitiesMap())
"setZoomRatio" -> {
val ratio = call.argument<Double>("zoomRatio") ?: 1.0
setZoomRatio(ratio, result)
}
"disposePreview" -> { "disposePreview" -> {
controller.unbind() controller.unbind()
result.success(null) result.success(null)
@@ -172,16 +177,28 @@ class RecordingPlatformHandler(
} }
} }
private fun setZoomRatio(ratio: Double, result: MethodChannel.Result) {
controller.setZoomRatio(ratio) { success, capabilities, message ->
mainHandler.post {
if (success) {
result.success(capabilities)
} else {
result.error("ZOOM_FAILED", message ?: "Failed to set camera zoom", null)
}
}
}
}
private fun deliverStopResult(result: MethodChannel.Result, path: String?) { private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR val fileSaved = 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(),
"gallerySaved" to gallerySaved, "fileSaved" to fileSaved,
) )
if (!gallerySaved) { if (!fileSaved) {
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败" payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
} }
result.success(payload) result.success(payload)
} }

19
buildServer.json Normal file
View File

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

7
clean.sh Normal file
View File

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

View File

@@ -1,2 +1,3 @@
#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,2 +1,3 @@
#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,9 +45,34 @@ 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: 5a82b772179df87e6518bddc6f9bb8b4053ce48b PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -344,14 +344,10 @@
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";
@@ -380,14 +376,10 @@
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

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "startup_background.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@@ -16,13 +16,15 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView> </imageView>
</subviews> </subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="E8f-e3-JEx"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="rwG-Bh-0uU"/>
</constraints> </constraints>
</view> </view>
</viewController> </viewController>
@@ -32,6 +34,6 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="168" height="185"/> <image name="StartupBackground" width="750" height="1624"/>
</resources> </resources>
</document> </document>

View File

@@ -18,7 +18,18 @@
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC"> <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/> <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YQm-Ov-qw7">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="top" secondItem="8bC-Xf-vdC" secondAttribute="top" id="7pp-OK-Dgk"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="bottom" secondItem="8bC-Xf-vdC" secondAttribute="bottom" id="Hbf-gY-rbf"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" id="WPc-7k-20p"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="trailing" secondItem="8bC-Xf-vdC" secondAttribute="trailing" id="w1G-WA-3QR"/>
</constraints>
</view> </view>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
@@ -26,4 +37,7 @@
<point key="canvasLocation" x="139" y="122"/> <point key="canvasLocation" x="139" y="122"/>
</scene> </scene>
</scenes> </scenes>
<resources>
<image name="StartupBackground" width="750" height="1624"/>
</resources>
</document> </document>

View File

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

View File

@@ -1,6 +1,5 @@
import AVFoundation import AVFoundation
import Flutter import Flutter
import Photos
import UIKit import UIKit
private enum RecordingState: String { private enum RecordingState: String {
@@ -110,12 +109,13 @@ 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 latestGallerySaved = true private var latestFileSaved = true
private var latestGalleryErrorMessage: String? private var latestFileErrorMessage: 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?
private var pendingStopResult: FlutterResult? private var pendingStopResult: FlutterResult?
private var currentZoomRatio: CGFloat = 1.0
private(set) var status = RecordingStatus(state: .idle) { private(set) var status = RecordingStatus(state: .idle) {
didSet { didSet {
@@ -215,10 +215,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
self.pendingDisplayName = displayName self.pendingDisplayName = displayName
self.latestGallerySaved = true self.latestFileSaved = true
self.latestGalleryErrorMessage = nil self.latestFileErrorMessage = nil
let outputURL = try self.createOutputURL(displayName: displayName) let outputURL = try self.createOutputURL(displayName: displayName)
self.latestOutputPath = outputURL.lastPathComponent self.latestOutputPath = outputURL.path
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 +254,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(),
"gallerySaved": self.latestGallerySaved, "fileSaved": self.latestFileSaved,
] ]
if !self.latestGallerySaved { if !self.latestFileSaved {
payload["galleryErrorMessage"] = payload["fileErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败" self.latestFileErrorMessage ?? "保存到文件夹失败"
} }
result(payload) result(payload)
} }
@@ -291,6 +291,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
self.session.commitConfiguration() self.session.commitConfiguration()
self.videoInput = nil self.videoInput = nil
self.audioInput = nil self.audioInput = nil
self.currentZoomRatio = 1.0
self.configured = false self.configured = false
self.updateStatus(RecordingStatus(state: .idle)) self.updateStatus(RecordingStatus(state: .idle))
@@ -312,6 +313,54 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return status.toMap() return status.toMap()
} }
func zoomCapabilities(result: @escaping FlutterResult) {
sessionQueue.async { [weak self] in
guard let self else { return }
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
}
}
func setZoomRatio(_ ratio: CGFloat, result: @escaping FlutterResult) {
sessionQueue.async { [weak self] in
guard let self else { return }
guard let device = self.videoInput?.device else {
self.currentZoomRatio = max(1.0, ratio)
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
return
}
do {
// (1.0x = ) S zoomFactor
let baseline = self.mainBaselineFactor(for: device)
let nextZoom = self.clampedZoomRatio(ratio * baseline, for: device)
try device.lockForConfiguration()
device.videoZoomFactor = nextZoom
device.unlockForConfiguration()
self.currentZoomRatio = nextZoom
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
} catch {
DispatchQueue.main.async {
result(
FlutterError(
code: "ZOOM_FAILED",
message: error.localizedDescription,
details: nil
)
)
}
}
}
}
func fileOutput( func fileOutput(
_ output: AVCaptureFileOutput, _ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL, didFinishRecordingTo outputFileURL: URL,
@@ -322,8 +371,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
pendingStopResult = nil pendingStopResult = nil
if let error { if let error {
latestGallerySaved = false latestFileSaved = false
latestGalleryErrorMessage = error.localizedDescription latestFileErrorMessage = error.localizedDescription
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
@@ -331,29 +380,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return return
} }
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in latestFileSaved = true
guard let self else { return } latestFileErrorMessage = nil
self.latestGallerySaved = success latestOutputPath = outputFileURL.path
self.latestGalleryErrorMessage = message guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
if success { latestFileSaved = false
self.updateStatus( latestFileErrorMessage = "录制文件未生成"
RecordingStatus( updateStatus(
state: .previewing, RecordingStatus(
outputPath: self.latestOutputPath, state: .error,
elapsedMillis: self.elapsedMillis() outputPath: latestOutputPath,
) message: latestFileErrorMessage
) )
} else { )
self.updateStatus( finishStopRecording(stopResult: stopResult)
RecordingStatus( return
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?) {
@@ -363,79 +413,23 @@ 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(),
"gallerySaved": self.latestGallerySaved, "fileSaved": self.latestFileSaved,
] ]
if !self.latestGallerySaved { if !self.latestFileSaved {
payload["galleryErrorMessage"] = payload["fileErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限" self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
} }
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)
return return
} }
guard guard let videoDevice = Self.preferredVideoDevice() else {
let videoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
else {
throw NSError( throw NSError(
domain: "RecordingCamera", code: 1, domain: "RecordingCamera", code: 1,
userInfo: [NSLocalizedDescriptionKey: "No camera device available"]) userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
@@ -465,9 +459,74 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
session.commitConfiguration() session.commitConfiguration()
configured = true configured = true
// ( 1.0x) zoomFactor S
currentZoomRatio = mainBaselineFactor(for: videoDevice)
try applyCurrentZoom()
try configureAudioInput(enabled: withAudio) try configureAudioInput(enabled: withAudio)
} }
/// 广使 minAvailableVideoZoomFactor ( 0.6x)
private static func preferredVideoDevice() -> AVCaptureDevice? {
let preferredTypes: [AVCaptureDevice.DeviceType] = [
.builtInTripleCamera,
.builtInDualWideCamera,
.builtInWideAngleCamera,
]
for type in preferredTypes {
if let device = AVCaptureDevice.default(type, for: .video, position: .back) {
return device
}
}
return AVCaptureDevice.default(for: .video)
}
/// ( 1.0x) zoomFactor S
/// ultra-wide wide 1.0()
private func mainBaselineFactor(for device: AVCaptureDevice) -> CGFloat {
if let first = device.virtualDeviceSwitchOverVideoZoomFactors.first {
let value = CGFloat(truncating: first)
if value > 0 {
return value
}
}
return 1.0
}
private func currentZoomCapabilitiesMap() -> [String: Any] {
guard let device = videoInput?.device else {
return [
"zoomRatio": Double(currentZoomRatio),
"minZoomRatio": 1.0,
"maxZoomRatio": 3.0,
]
}
// zoomFactor S App 使(1.0x = )
let baseline = mainBaselineFactor(for: device)
let minZoom = device.minAvailableVideoZoomFactor
let maxZoom = device.maxAvailableVideoZoomFactor
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
currentZoomRatio = zoom
return [
"zoomRatio": Double(zoom / baseline),
"minZoomRatio": Double(minZoom / baseline),
"maxZoomRatio": Double(maxZoom / baseline),
]
}
private func applyCurrentZoom() throws {
guard let device = videoInput?.device else { return }
let nextZoom = clampedZoomRatio(currentZoomRatio, for: device)
try device.lockForConfiguration()
device.videoZoomFactor = nextZoom
device.unlockForConfiguration()
currentZoomRatio = nextZoom
}
private func clampedZoomRatio(_ ratio: CGFloat, for device: AVCaptureDevice) -> CGFloat {
min(max(ratio, device.minAvailableVideoZoomFactor), device.maxAvailableVideoZoomFactor)
}
private func configureAudioInput(enabled: Bool) throws { private func configureAudioInput(enabled: Bool) throws {
session.beginConfiguration() session.beginConfiguration()
defer { session.commitConfiguration() } defer { session.commitConfiguration() }
@@ -502,7 +561,30 @@ 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 recordingsURL.appendingPathComponent(fileName) 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
} }
private static func resolveFileName(displayName: String?) -> String { private static func resolveFileName(displayName: String?) -> String {
@@ -520,6 +602,13 @@ 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
} }
@@ -587,6 +676,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result) controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
case "stopRecording": case "stopRecording":
controller.stopRecording(result: result) controller.stopRecording(result: result)
case "getZoomCapabilities":
controller.zoomCapabilities(result: result)
case "setZoomRatio":
let args = call.arguments as? [String: Any]
let ratio = args?["zoomRatio"] as? Double ?? 1.0
controller.setZoomRatio(CGFloat(ratio), result: result)
case "disposePreview": case "disposePreview":
controller.disposePreview(result: result) controller.disposePreview(result: result)
case "getStatus": case "getStatus":

View File

@@ -11,11 +11,14 @@ class RecordingSessionState {
this.isBatteryOptimizedIgnored = true, this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true, this.notificationsGranted = true,
this.isMicrophoneGranted = false, this.isMicrophoneGranted = false,
this.zoomRatio = 1.0,
this.minZoomRatio = 1.0,
this.maxZoomRatio = 3.0,
this.lastOutputPath, this.lastOutputPath,
this.lastSavedDisplayName, this.lastSavedDisplayName,
this.errorMessage, this.errorMessage,
this.permissionWarning, this.permissionWarning,
this.gallerySaveFailed = false, this.fileSaveFailed = false,
}); });
final RecordingStatus status; final RecordingStatus status;
@@ -26,11 +29,14 @@ class RecordingSessionState {
final bool isBatteryOptimizedIgnored; final bool isBatteryOptimizedIgnored;
final bool notificationsGranted; final bool notificationsGranted;
final bool isMicrophoneGranted; final bool isMicrophoneGranted;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final String? lastOutputPath; final String? lastOutputPath;
final String? lastSavedDisplayName; final String? lastSavedDisplayName;
final String? errorMessage; final String? errorMessage;
final String? permissionWarning; final String? permissionWarning;
final bool gallerySaveFailed; final bool fileSaveFailed;
bool get isRecording => status.isRecording; bool get isRecording => status.isRecording;
@@ -51,11 +57,14 @@ class RecordingSessionState {
bool? isBatteryOptimizedIgnored, bool? isBatteryOptimizedIgnored,
bool? notificationsGranted, bool? notificationsGranted,
bool? isMicrophoneGranted, bool? isMicrophoneGranted,
double? zoomRatio,
double? minZoomRatio,
double? maxZoomRatio,
String? lastOutputPath, String? lastOutputPath,
String? lastSavedDisplayName, String? lastSavedDisplayName,
String? errorMessage, String? errorMessage,
String? permissionWarning, String? permissionWarning,
bool? gallerySaveFailed, bool? fileSaveFailed,
bool clearPermissionWarning = false, bool clearPermissionWarning = false,
bool clearLastSaved = false, bool clearLastSaved = false,
}) { }) {
@@ -69,6 +78,9 @@ class RecordingSessionState {
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted ?? this.notificationsGranted, notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted, isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
zoomRatio: zoomRatio ?? this.zoomRatio,
minZoomRatio: minZoomRatio ?? this.minZoomRatio,
maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio,
lastOutputPath: lastOutputPath ?? this.lastOutputPath, lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved lastSavedDisplayName: clearLastSaved
? null ? null
@@ -77,7 +89,7 @@ class RecordingSessionState {
permissionWarning: clearPermissionWarning permissionWarning: clearPermissionWarning
? null ? null
: (permissionWarning ?? this.permissionWarning), : (permissionWarning ?? this.permissionWarning),
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed, fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
); );
} }
} }

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.gallerySaveFailed) { if (latest.fileSaveFailed) {
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.gallerySaveFailed) { if (session.lastSavedDisplayName == null || session.fileSaveFailed) {
return; return;
} }
@@ -357,10 +357,7 @@ class _PreviewLoadingLayer extends ConsumerWidget {
} }
class _RecordingHudLayer extends ConsumerWidget { class _RecordingHudLayer extends ConsumerWidget {
const _RecordingHudLayer({ const _RecordingHudLayer({required this.onStart, required this.onStop});
required this.onStart,
required this.onStop,
});
final Future<void> Function() onStart; final Future<void> Function() onStart;
final Future<void> Function() onStop; final Future<void> Function() onStop;
@@ -378,6 +375,9 @@ class _RecordingHudLayer extends ConsumerWidget {
m.session.isRecording, m.session.isRecording,
m.session.isStartingRecording, m.session.isStartingRecording,
m.session.isTouchLocked, m.session.isTouchLocked,
m.session.zoomRatio,
m.session.minZoomRatio,
m.session.maxZoomRatio,
m.hasValidClipboardInfo, m.hasValidClipboardInfo,
m.clipboardRecordingModel.address.trim(), m.clipboardRecordingModel.address.trim(),
), ),
@@ -392,6 +392,9 @@ class _RecordingHudLayer extends ConsumerWidget {
isRecording, isRecording,
isStartingRecording, isStartingRecording,
isTouchLocked, isTouchLocked,
zoomRatio,
minZoomRatio,
maxZoomRatio,
showClipboardHint, showClipboardHint,
clipboardAddress, clipboardAddress,
) = hudState; ) = hudState;
@@ -406,6 +409,9 @@ class _RecordingHudLayer extends ConsumerWidget {
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: isStartingRecording, isStartingRecording: isStartingRecording,
isTouchLocked: isTouchLocked, isTouchLocked: isTouchLocked,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
showClipboardHint: showClipboardHint, showClipboardHint: showClipboardHint,
clipboardAddress: clipboardAddress, clipboardAddress: clipboardAddress,
onStart: onStart, onStart: onStart,
@@ -419,9 +425,15 @@ class _RecordingHudLayer extends ConsumerWidget {
await viewModel.refreshBatteryOptimization(); await viewModel.refreshBatteryOptimization();
}, },
onToggleTouchLock: () { onToggleTouchLock: () {
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked; final locked = ref
.read(recordingViewModelProvider)
.session
.isTouchLocked;
viewModel.setTouchLocked(!locked); viewModel.setTouchLocked(!locked);
}, },
onZoomSelected: (ratio) async {
await viewModel.setZoomRatio(ratio);
},
); );
} }
} }

View File

@@ -81,6 +81,21 @@ class RecordingPlatform {
return RecordingStatus.fromMap(result ?? const {}); return RecordingStatus.fromMap(result ?? const {});
} }
static Future<RecordingZoomCapabilities> getZoomCapabilities() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'getZoomCapabilities',
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingZoomCapabilities> setZoomRatio(double ratio) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'setZoomRatio',
<String, dynamic>{'zoomRatio': ratio},
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingStartResult> startRecording({ static Future<RecordingStartResult> startRecording({
bool withAudio = true, bool withAudio = true,
bool enableDoNotDisturb = true, bool enableDoNotDisturb = true,
@@ -156,6 +171,29 @@ class RecordingPlatform {
} }
} }
class RecordingZoomCapabilities {
const RecordingZoomCapabilities({
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
});
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
factory RecordingZoomCapabilities.fromMap(Map<String, dynamic>? map) {
final minZoomRatio = (map?['minZoomRatio'] as num?)?.toDouble() ?? 1.0;
final maxZoomRatio = (map?['maxZoomRatio'] as num?)?.toDouble() ?? 3.0;
final zoomRatio = (map?['zoomRatio'] as num?)?.toDouble() ?? minZoomRatio;
return RecordingZoomCapabilities(
zoomRatio: zoomRatio.clamp(minZoomRatio, maxZoomRatio).toDouble(),
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
);
}
}
class RecordingStartResult { class RecordingStartResult {
const RecordingStartResult({this.outputPath, required this.status}); const RecordingStartResult({this.outputPath, required this.status});
@@ -167,14 +205,14 @@ class RecordingStopResult {
const RecordingStopResult({ const RecordingStopResult({
this.outputPath, this.outputPath,
required this.status, required this.status,
this.gallerySaved = true, this.fileSaved = true,
this.galleryErrorMessage, this.fileErrorMessage,
}); });
final String? outputPath; final String? outputPath;
final RecordingStatus status; final RecordingStatus status;
final bool gallerySaved; final bool fileSaved;
final String? galleryErrorMessage; final String? fileErrorMessage;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) { factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult( return RecordingStopResult(
@@ -182,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 {}),
), ),
gallerySaved: result?['gallerySaved'] as bool? ?? true, fileSaved: result?['fileSaved'] as bool? ?? true,
galleryErrorMessage: result?['galleryErrorMessage'] as String?, fileErrorMessage: result?['fileErrorMessage'] as String?,
); );
} }
} }

View File

@@ -7,6 +7,7 @@ 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';
@@ -31,15 +32,19 @@ enum ClipboardReadResult {
invalid, invalid,
} }
List<Permission> recordingGalleryPermissionsForHost({ List<Permission> recordingFileSavePermissionsForHost({
required bool isIOS, required bool isIOS,
required bool isAndroid, required bool isAndroid,
int? androidSdkInt,
}) { }) {
if (isIOS) { if (isIOS) {
return [Permission.photosAddOnly]; return const [];
} }
if (isAndroid) { if (isAndroid) {
return [Permission.videos, Permission.storage]; if (androidSdkInt != null && androidSdkInt >= 29) {
return const [];
}
return [Permission.storage];
} }
return const []; return const [];
} }
@@ -144,11 +149,12 @@ 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,
..._galleryPermissions(), ...fileSavePermissions,
]); ]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
@@ -170,8 +176,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
if (!microphoneGranted) { if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制'); warnings.add('未授予麦克风权限,当前将以静音模式录制');
} }
if (!_isGalleryPermissionGranted(permissions)) { if (!_isFileSavePermissionGranted(permissions, fileSavePermissions)) {
warnings.add('未授予相册权限,录制结束后可能无法保存到相册'); warnings.add('未授予文件保存权限,录制结束后可能无法保存到文件夹');
} }
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
@@ -193,6 +199,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
await _listenStatus(); await _listenStatus();
try { try {
final status = await _initializePreviewWithRetry(); final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession( _updateSession(
(s) => s.copyWith( (s) => s.copyWith(
status: status, status: status,
@@ -239,6 +246,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
); );
try { try {
final status = await _initializePreviewWithRetry(); final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession( _updateSession(
(s) => s.copyWith( (s) => s.copyWith(
status: status, status: status,
@@ -258,24 +266,36 @@ class RecordingViewModel extends Notifier<RecordingModel> {
} }
} }
/// 当前平台所需的相册/视频保存权限列表。 /// 当前平台所需的视频文件保存权限列表。
List<Permission> _galleryPermissions() { Future<List<Permission>> _fileSavePermissions() async {
return recordingGalleryPermissionsForHost( int? androidSdkInt;
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 _isGalleryPermissionGranted( bool _isFileSavePermissionGranted(
Map<Permission, PermissionStatus> permissions, Map<Permission, PermissionStatus> permissions,
List<Permission> fileSavePermissions,
) { ) {
for (final permission in _galleryPermissions()) { for (final permission in fileSavePermissions) {
if (permissions[permission]?.isGranted ?? false) { if (permissions[permission]?.isGranted ?? false) {
return true; return true;
} }
} }
return _galleryPermissions().isEmpty; return fileSavePermissions.isEmpty;
} }
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。 /// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
@@ -309,6 +329,47 @@ class RecordingViewModel extends Notifier<RecordingModel> {
return status?.isGranted == true || status?.isLimited == true; return status?.isGranted == true || status?.isLimited == true;
} }
/// 读取相机支持的倍距范围并同步当前倍距。
Future<void> _refreshZoomCapabilities() async {
try {
final zoom = await RecordingPlatform.getZoomCapabilities();
_updateSession(
(s) => s.copyWith(
zoomRatio: zoom.zoomRatio,
minZoomRatio: zoom.minZoomRatio,
maxZoomRatio: zoom.maxZoomRatio,
errorMessage: null,
),
);
} on PlatformException catch (error) {
AppLogger.debug('读取相机倍距能力失败', error: error);
}
}
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
Future<void> setZoomRatio(double ratio) async {
final session = state.session;
final clamped = ratio
.clamp(session.minZoomRatio, session.maxZoomRatio)
.toDouble();
try {
final zoom = await RecordingPlatform.setZoomRatio(clamped);
_updateSession(
(s) => s.copyWith(
zoomRatio: zoom.zoomRatio,
minZoomRatio: zoom.minZoomRatio,
maxZoomRatio: zoom.maxZoomRatio,
errorMessage: null,
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'),
);
}
}
/// 开始录制,可选开启勿扰模式。 /// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async { Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session; final session = state.session;
@@ -338,7 +399,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
lastOutputPath: result.outputPath, lastOutputPath: result.outputPath,
isTouchLocked: true, isTouchLocked: true,
errorMessage: null, errorMessage: null,
gallerySaveFailed: false, fileSaveFailed: false,
clearLastSaved: true, clearLastSaved: true,
), ),
); );
@@ -351,13 +412,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 galleryFailed = !result.gallerySaved; final fileFailed = !result.fileSaved;
final savedName = recordingFileNameForPlatform( final savedName = recordingFileNameForPlatform(
state.clipboardRecordingModel.filename, state.clipboardRecordingModel.filename,
); );
@@ -365,11 +426,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: galleryFailed ? null : savedName, lastSavedDisplayName: fileFailed ? null : savedName,
errorMessage: galleryFailed errorMessage: fileFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限') ? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
: null, : null,
gallerySaveFailed: galleryFailed, fileSaveFailed: fileFailed,
), ),
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {

View File

@@ -21,11 +21,15 @@ class RecordingHudWidget extends StatelessWidget {
required this.isTouchLocked, required this.isTouchLocked,
this.showClipboardHint = false, this.showClipboardHint = false,
this.clipboardAddress = '', this.clipboardAddress = '',
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
required this.onStart, required this.onStart,
required this.onStop, required this.onStop,
required this.onOpenDnd, required this.onOpenDnd,
required this.onOpenBattery, required this.onOpenBattery,
required this.onToggleTouchLock, required this.onToggleTouchLock,
required this.onZoomSelected,
}); });
final String? errorMessage; final String? errorMessage;
@@ -38,16 +42,21 @@ class RecordingHudWidget extends StatelessWidget {
final bool isTouchLocked; final bool isTouchLocked;
final bool showClipboardHint; final bool showClipboardHint;
final String clipboardAddress; final String clipboardAddress;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final Future<void> Function() onStart; final Future<void> Function() onStart;
final Future<void> Function() onStop; final Future<void> Function() onStop;
final VoidCallback onOpenDnd; final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery; final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock; final VoidCallback onToggleTouchLock;
final ValueChanged<double> onZoomSelected;
static double get _recordButtonSize => 70.r; static double get _recordButtonSize => 70.r;
static double get _recordButtonBottom => 63.r; static double get _recordButtonBottom => 63.r;
static double get _overlayInfoLeft => 13.r; static double get _overlayInfoLeft => 13.r;
static double get _overlayInfoBottom => 10.r; static double get _overlayInfoBottom => 10.r;
static const List<double> _zoomPresets = [0.6, 1.0];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -133,6 +142,18 @@ class RecordingHudWidget extends StatelessWidget {
), ),
), ),
), ),
Positioned(
right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h,
child: _ZoomPresetControl(
isRecording: isRecording,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
presets: _zoomPresets,
onSelected: onZoomSelected,
),
),
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@@ -171,3 +192,132 @@ class RecordingHudWidget extends StatelessWidget {
); );
} }
} }
class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({
required this.isRecording,
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
required this.presets,
required this.onSelected,
});
final bool isRecording;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final List<double> presets;
final ValueChanged<double> onSelected;
@override
Widget build(BuildContext context) {
final availablePresets = presets
.where(_isPresetAvailable)
.toList(growable: false);
if (availablePresets.isEmpty) {
return const SizedBox.shrink();
}
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.46),
borderRadius: BorderRadius.circular(18.r),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: Padding(
padding: EdgeInsets.all(3.r),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final preset in availablePresets)
_ZoomPresetButton(
displayRatio: preset,
requestRatio: preset,
selected: _isPresetSelected(preset),
enabled: !_wouldSwitchPhysicalCamera(preset),
onSelected: onSelected,
),
],
),
),
);
}
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.displayRatio,
required this.requestRatio,
required this.selected,
required this.enabled,
required this.onSelected,
});
final double displayRatio;
final double requestRatio;
final bool selected;
final bool enabled;
final ValueChanged<double> onSelected;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 1.r),
child: TextButton(
onPressed: selected || !enabled ? null : () => onSelected(requestRatio),
style: TextButton.styleFrom(
minimumSize: Size(38.r, 32.r),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: selected ? Colors.black : Colors.white,
disabledForegroundColor: Colors.black,
backgroundColor: selected ? Colors.white : Colors.transparent,
disabledBackgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.r),
),
),
child: Text(
'${_formatZoomRatio(displayRatio)}x',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
),
);
}
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, microphone and photos permission macros', () { test('Podfile enables camera and microphone permission macros only', () {
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, contains("'PERMISSION_PHOTOS=1'")); expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'")));
expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'")); expect(podfile, isNot(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,4 +18,20 @@ 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,8 @@ 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/view-model/view_model_recording.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() { void main() {
@@ -24,6 +26,11 @@ void main() {
tearDown(() { tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null); .setMockMethodCallHandler(SystemChannels.platform, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
null,
);
}); });
group('RecordingViewModel', () { group('RecordingViewModel', () {
@@ -36,27 +43,228 @@ void main() {
expect(model.clipboardRecordingModel.title, defaultClipboardTitle); expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
expect(model.session.isPreviewReady, isFalse); expect(model.session.isPreviewReady, isFalse);
expect(model.session.isRecording, isFalse); expect(model.session.isRecording, isFalse);
expect(model.session.zoomRatio, 1.0);
expect(model.session.minZoomRatio, 1.0);
expect(model.session.maxZoomRatio, 3.0);
}); });
}); });
group('recordingGalleryPermissionsForHost', () { group('RecordingViewModel.setZoomRatio', () {
test('requests only add-only photo permission on iOS', () { test('updates zoom ratio from native response', () async {
final permissions = recordingGalleryPermissionsForHost( TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
expect(call.method, 'setZoomRatio');
expect(call.arguments, <String, dynamic>{'zoomRatio': 2.0});
return <String, dynamic>{
'zoomRatio': 2.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(recordingViewModelProvider.notifier).setZoomRatio(2);
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 2.0);
expect(session.minZoomRatio, 1.0);
expect(session.maxZoomRatio, 3.0);
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
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 1.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(recordingViewModelProvider.notifier).setZoomRatio(4);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 3.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(
'keeps previous zoom ratio and stores error when native fails',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
throw PlatformException(
code: 'ZOOM_FAILED',
message: 'Zoom is unavailable',
);
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(2);
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0);
expect(session.errorMessage, 'Zoom is unavailable');
},
);
});
group('recordingFileSavePermissionsForHost', () {
test('does not request photo permission on iOS', () {
final permissions = recordingFileSavePermissionsForHost(
isIOS: true, isIOS: true,
isAndroid: false, isAndroid: false,
); );
expect(permissions, <Permission>[Permission.photosAddOnly]); expect(permissions, isEmpty);
expect(permissions, isNot(contains(Permission.photosAddOnly)));
expect(permissions, isNot(contains(Permission.photos))); expect(permissions, isNot(contains(Permission.photos)));
}); });
test('keeps Android gallery permissions unchanged', () { test('requests storage permission on Android 9 and below', () {
final permissions = recordingGalleryPermissionsForHost( final permissions = recordingFileSavePermissionsForHost(
isIOS: false, isIOS: false,
isAndroid: true, isAndroid: true,
androidSdkInt: 28,
); );
expect(permissions, <Permission>[Permission.videos, Permission.storage]); 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);
}); });
}); });

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_hud.dart';
void main() {
Future<void> pumpHud(
WidgetTester tester, {
double zoomRatio = 1.0,
double minZoomRatio = 1.0,
double maxZoomRatio = 3.0,
bool isRecording = false,
ValueChanged<double>? onZoomSelected,
}) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder: (context, _) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: RecordingHudWidget(
hasDndAccess: true,
isBatteryOptimizedIgnored: true,
notificationsGranted: true,
isRecording: isRecording,
isStartingRecording: false,
isTouchLocked: false,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
onStart: () async {},
onStop: () async {},
onOpenDnd: () {},
onOpenBattery: () {},
onToggleTouchLock: () {},
onZoomSelected: onZoomSelected ?? (_) {},
),
),
);
},
),
);
await tester.pump();
}
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'), findsNothing);
expect(find.text('3x'), findsNothing);
});
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('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, minZoomRatio: 0.5, maxZoomRatio: 0.55);
expect(find.text('0.6x'), findsNothing);
expect(find.text('1x'), findsNothing);
});
testWidgets('tapping 0.6x reports 0.6 when camera capability is below 0.6', (
tester,
) async {
double? selected;
await pumpHud(
tester,
minZoomRatio: 0.5,
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.6x'));
await tester.pump();
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);
});
}