7 Commits

34 changed files with 1397 additions and 303 deletions

View File

@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val appPackageName = "com.qxy.dronex"
val appPackageName = "com.dronex.rec"
android {
namespace = appPackageName

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.qxy.dronex">
package="com.dronex.rec">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

View File

@@ -1,7 +1,7 @@
package com.qxy.dronex
package com.dronex.rec
object AppConstants {
const val PACKAGE_NAME = "com.qxy.dronex"
const val PACKAGE_NAME = "com.dronex.rec"
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex
package com.dronex.rec
import android.content.Context
import android.content.pm.ApplicationInfo
@@ -7,8 +7,8 @@ import android.os.Build
import android.os.Environment
import android.os.StatFs
import androidx.camera.view.PreviewView
import com.qxy.dronex.recording.RecordingPlatformHandler
import com.qxy.dronex.recording.RecordingPreviewFactory
import com.dronex.rec.recording.RecordingPlatformHandler
import com.dronex.rec.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
import android.content.Context
import android.content.Intent

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
import android.app.NotificationManager
import android.content.Context

View File

@@ -0,0 +1,570 @@
package com.dronex.rec.recording
import android.content.Context
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.util.Log
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import kotlin.math.atan
import java.util.concurrent.Executor
class RecordingCameraController(
private val appContext: Context,
) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
private var cameraProvider: ProcessCameraProvider? = null
private var preview: Preview? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var camera: Camera? = null
private var mainCameraId: String? = null
private var ultraWideCameraId: String? = null
private var ultraWideZoomRatio: Float = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
private var currentLensMode: LensMode = LensMode.MAIN
private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null
private var currentZoomRatio: Float = 1f
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
private set
var statusListener: ((RecordingStatus) -> Unit)? = null
private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val future = ProcessCameraProvider.getInstance(appContext)
future.addListener(
{
try {
val provider = future.get()
cameraProvider = provider
boundLifecycleOwner = lifecycleOwner
preview =
Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val recorder =
Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
discoverBackCameras(provider)
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
applyCurrentZoom()
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
onReady(true)
} catch (error: Exception) {
Log.e(TAG, "bindPreview failed", error)
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = error.message,
),
)
onReady(false)
}
},
mainExecutor,
)
}
fun rebindForRecording(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val provider = cameraProvider
if (provider == null) {
bindPreview(lifecycleOwner, previewView, onReady)
return
}
if (
boundLifecycleOwner === lifecycleOwner &&
preview != null &&
videoCapture != null
) {
onReady(true)
return
}
try {
boundLifecycleOwner = lifecycleOwner
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
applyCurrentZoom()
onReady(true)
} catch (error: Exception) {
Log.e(TAG, "rebindForRecording failed", error)
onReady(false)
}
}
fun startRecording(
withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
onStarted(false, "Camera not ready")
return
}
if (activeRecording != null) {
onStarted(false, "Already recording")
return
}
val outputOptions =
RecordingOutputFactory.buildMediaStoreOutputOptions(
appContext,
displayName,
)
latestOutputPath = null
val pending =
capture.output.prepareRecording(appContext, outputOptions).apply {
if (withAudio) {
val granted =
ContextCompat.checkSelfPermission(
appContext,
android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) {
withAudioEnabled()
}
}
}
recordingStartedAt = System.currentTimeMillis()
updateStatus(
RecordingStatus(
RecordingState.RECORDING,
outputPath = latestOutputPath,
),
)
activeRecording =
pending.start(mainExecutor) { event ->
when (event) {
is VideoRecordEvent.Start -> Unit
is VideoRecordEvent.Finalize -> {
activeRecording = null
if (event.hasError()) {
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = event.cause?.message
?: "Recording failed",
),
)
} else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis =
System.currentTimeMillis() -
recordingStartedAt,
),
)
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
stopCallback?.invoke(latestOutputPath)
}
}
}
onStarted(true, latestOutputPath ?: "recording")
}
fun stopRecording(onStopped: (String?) -> Unit) {
val recording = activeRecording
if (recording == null) {
onStopped(latestOutputPath)
return
}
pendingStopCallback = onStopped
updateStatus(
RecordingStatus(
RecordingState.STOPPING,
outputPath = latestOutputPath,
),
)
recording.stop()
activeRecording = null
}
fun zoomCapabilitiesMap(): Map<String, Any> {
val zoomState = camera?.cameraInfo?.zoomState?.value
val minZoom =
if (hasUltraWideCamera()) {
ultraWideZoomRatio
} else {
zoomState?.minZoomRatio ?: 1f
}
val maxZoom = zoomState?.maxZoomRatio ?: 3f
val zoom =
if (currentLensMode == LensMode.ULTRA_WIDE) {
ultraWideZoomRatio
} else {
(zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom)
}
currentZoomRatio = 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() {
activeRecording?.stop()
activeRecording = null
cameraProvider?.unbindAll()
cameraProvider = null
preview = null
videoCapture = null
camera = null
boundLifecycleOwner = null
currentLensMode = LensMode.MAIN
currentZoomRatio = 1f
ultraWideZoomRatio = DEFAULT_ULTRA_WIDE_ZOOM_RATIO
updateStatus(RecordingStatus(RecordingState.IDLE))
}
fun elapsedMillis(): Long {
if (status.state != RecordingState.RECORDING) return 0L
return System.currentTimeMillis() - recordingStartedAt
}
private fun updateStatus(next: RecordingStatus) {
status = 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
if (mainProfile == null) {
return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO)
}
val meaningfullyWider =
widest.horizontalFov > mainProfile.horizontalFov * ULTRA_WIDE_FOV_FACTOR ||
widest.minFocalLength < mainProfile.minFocalLength * ULTRA_WIDE_FOCAL_FACTOR
if (!meaningfullyWider) {
return null
}
return UltraWideCamera(widest.cameraId, DEFAULT_ULTRA_WIDE_ZOOM_RATIO)
}
private fun backCameraProfile(
manager: CameraManager,
cameraId: String,
): CameraProfile? {
return try {
val characteristics = manager.getCameraCharacteristics(cameraId)
val facing = characteristics.get(CameraCharacteristics.LENS_FACING)
if (facing != CameraCharacteristics.LENS_FACING_BACK) {
return null
}
val focalLengths =
characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
?: return null
val physicalSize =
characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
?: return null
val minFocalLength = focalLengths.minOrNull() ?: return null
val horizontalFov =
2.0 * atan((physicalSize.width / (2.0f * minFocalLength)).toDouble())
CameraProfile(cameraId, minFocalLength, horizontalFov)
} catch (error: Exception) {
Log.w(TAG, "backCameraProfile failed for cameraId=$cameraId", error)
null
}
}
private fun selectorForCurrentLensMode(): CameraSelector {
val cameraId =
if (currentLensMode == LensMode.ULTRA_WIDE) {
ultraWideCameraId
} else {
mainCameraId
}
return if (cameraId != null) {
selectorForCameraId(cameraId)
} else {
CameraSelector.DEFAULT_BACK_CAMERA
}
}
private fun selectorForCameraId(cameraId: String): CameraSelector {
return CameraSelector.Builder()
.addCameraFilter { cameraInfos ->
cameraInfos.filter { Camera2CameraInfo.from(it).cameraId == cameraId }
}
.build()
}
private fun bindUseCases(
provider: ProcessCameraProvider,
lifecycleOwner: LifecycleOwner,
selector: CameraSelector,
) {
val boundPreview = preview ?: throw IllegalStateException("Preview is not ready")
val boundVideoCapture =
videoCapture ?: throw IllegalStateException("Video capture is not ready")
provider.unbindAll()
camera =
provider.bindToLifecycle(
lifecycleOwner,
selector,
boundPreview,
boundVideoCapture,
)
}
private fun switchToUltraWide(
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) {
val ultraWideId = ultraWideCameraId
if (ultraWideId == null) {
onComplete(false, zoomCapabilitiesMap(), "Ultra-wide camera is unavailable")
return
}
if (currentLensMode == LensMode.ULTRA_WIDE) {
currentZoomRatio = ultraWideZoomRatio
onComplete(true, zoomCapabilitiesMap(), null)
return
}
if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording")
return
}
val provider = cameraProvider
val lifecycleOwner = boundLifecycleOwner
if (provider == null || lifecycleOwner == null) {
onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
return
}
try {
currentLensMode = LensMode.ULTRA_WIDE
currentZoomRatio = ultraWideZoomRatio
bindUseCases(provider, lifecycleOwner, selectorForCameraId(ultraWideId))
applyCurrentZoom()
onComplete(true, zoomCapabilitiesMap(), null)
} catch (error: Exception) {
Log.e(TAG, "switchToUltraWide failed", error)
currentLensMode = LensMode.MAIN
currentZoomRatio = 1f
try {
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
applyCurrentZoom()
} catch (restoreError: Exception) {
Log.e(TAG, "restore main camera after ultra-wide failure failed", restoreError)
}
onComplete(false, zoomCapabilitiesMap(), error.message)
}
}
private fun switchToMainAndZoom(
ratio: Double,
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) {
if (activeRecording != null) {
onComplete(false, zoomCapabilitiesMap(), "Cannot switch physical camera while recording")
return
}
val provider = cameraProvider
val lifecycleOwner = boundLifecycleOwner
if (provider == null || lifecycleOwner == null) {
onComplete(false, zoomCapabilitiesMap(), "Camera is not ready")
return
}
try {
currentLensMode = LensMode.MAIN
currentZoomRatio = ratio.toFloat().coerceAtLeast(1f)
bindUseCases(provider, lifecycleOwner, selectorForCurrentLensMode())
setZoomRatio(ratio, onComplete)
} catch (error: Exception) {
Log.e(TAG, "switchToMainAndZoom failed", error)
onComplete(false, zoomCapabilitiesMap(), error.message)
}
}
private fun hasUltraWideCamera(): Boolean {
return ultraWideCameraId != null
}
private fun ProcessCameraProvider.hasCameraSafely(selector: CameraSelector): Boolean {
return try {
hasCamera(selector)
} catch (error: Exception) {
false
}
}
private enum class LensMode {
MAIN,
ULTRA_WIDE,
}
private data class CameraProfile(
val cameraId: String,
val minFocalLength: Float,
val horizontalFov: Double,
)
private data class UltraWideCamera(
val cameraId: String,
val zoomRatio: Float,
)
companion object {
private const val TAG = "RecordingCamera"
private const val DEFAULT_ULTRA_WIDE_ZOOM_RATIO = 0.6f
private const val ULTRA_WIDE_FOV_FACTOR = 1.08
private const val ULTRA_WIDE_FOCAL_FACTOR = 0.92
}
}

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
import android.app.Notification
import android.app.NotificationChannel
@@ -14,8 +14,8 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import com.qxy.dronex.AppConstants
import com.qxy.dronex.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
import android.content.ContentValues
import android.content.Context

View File

@@ -1,12 +1,12 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
import android.os.Handler
import android.os.Looper
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.qxy.dronex.AppConstants
import com.qxy.dronex.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@@ -50,6 +50,11 @@ class RecordingPlatformHandler(
startRecording(withAudio, enableDnd, displayName, result)
}
"stopRecording" -> stopRecording(result)
"getZoomCapabilities" -> result.success(controller.zoomCapabilitiesMap())
"setZoomRatio" -> {
val ratio = call.argument<Double>("zoomRatio") ?: 1.0
setZoomRatio(ratio, result)
}
"disposePreview" -> {
controller.unbind()
result.success(null)
@@ -172,6 +177,18 @@ 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?) {
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
val payload =

View File

@@ -1,9 +1,9 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.qxy.dronex.MainActivity
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
import android.content.Context
import androidx.lifecycle.LifecycleService

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording
package com.dronex.rec.recording
enum class RecordingState {
IDLE,

View File

@@ -1,248 +0,0 @@
package com.qxy.dronex.recording
import android.content.Context
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.Executor
class RecordingCameraController(
private val appContext: Context,
) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
private var cameraProvider: ProcessCameraProvider? = null
private var preview: Preview? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
private set
var statusListener: ((RecordingStatus) -> Unit)? = null
private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val future = ProcessCameraProvider.getInstance(appContext)
future.addListener(
{
try {
val provider = future.get()
cameraProvider = provider
boundLifecycleOwner = lifecycleOwner
preview =
Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val recorder =
Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
onReady(true)
} catch (error: Exception) {
Log.e(TAG, "bindPreview failed", error)
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = error.message,
),
)
onReady(false)
}
},
mainExecutor,
)
}
fun rebindForRecording(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val provider = cameraProvider
if (provider == null) {
bindPreview(lifecycleOwner, previewView, onReady)
return
}
if (
boundLifecycleOwner === lifecycleOwner &&
preview != null &&
videoCapture != null
) {
onReady(true)
return
}
try {
boundLifecycleOwner = lifecycleOwner
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
onReady(true)
} catch (error: Exception) {
Log.e(TAG, "rebindForRecording failed", error)
onReady(false)
}
}
fun startRecording(
withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
onStarted(false, "Camera not ready")
return
}
if (activeRecording != null) {
onStarted(false, "Already recording")
return
}
val outputOptions =
RecordingOutputFactory.buildMediaStoreOutputOptions(
appContext,
displayName,
)
latestOutputPath = null
val pending =
capture.output.prepareRecording(appContext, outputOptions).apply {
if (withAudio) {
val granted =
ContextCompat.checkSelfPermission(
appContext,
android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) {
withAudioEnabled()
}
}
}
recordingStartedAt = System.currentTimeMillis()
updateStatus(
RecordingStatus(
RecordingState.RECORDING,
outputPath = latestOutputPath,
),
)
activeRecording =
pending.start(mainExecutor) { event ->
when (event) {
is VideoRecordEvent.Start -> Unit
is VideoRecordEvent.Finalize -> {
activeRecording = null
if (event.hasError()) {
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = event.cause?.message
?: "Recording failed",
),
)
} else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis =
System.currentTimeMillis() -
recordingStartedAt,
),
)
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
stopCallback?.invoke(latestOutputPath)
}
}
}
onStarted(true, latestOutputPath ?: "recording")
}
fun stopRecording(onStopped: (String?) -> Unit) {
val recording = activeRecording
if (recording == null) {
onStopped(latestOutputPath)
return
}
pendingStopCallback = onStopped
updateStatus(
RecordingStatus(
RecordingState.STOPPING,
outputPath = latestOutputPath,
),
)
recording.stop()
activeRecording = null
}
fun unbind() {
activeRecording?.stop()
activeRecording = null
cameraProvider?.unbindAll()
cameraProvider = null
preview = null
videoCapture = null
boundLifecycleOwner = null
updateStatus(RecordingStatus(RecordingState.IDLE))
}
fun elapsedMillis(): Long {
if (status.state != RecordingState.RECORDING) return 0L
return System.currentTimeMillis() - recordingStartedAt
}
private fun updateStatus(next: RecordingStatus) {
status = next
statusListener?.invoke(next)
}
companion object {
private const val TAG = "RecordingCamera"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -2,9 +2,6 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.4.8):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -19,7 +16,6 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@@ -30,8 +26,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
@@ -44,7 +38,6 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0

View File

@@ -344,10 +344,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -376,10 +380,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -495,16 +503,22 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -678,16 +692,22 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -701,16 +721,22 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";

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">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<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>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" 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="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="1a2-6s-vTC"/>
<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>
</view>
</viewController>
@@ -32,6 +34,6 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="StartupBackground" width="750" height="1624"/>
</resources>
</document>

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
@@ -14,13 +16,28 @@
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
<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"/>
<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>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="139" y="122"/>
</scene>
</scenes>
<resources>
<image name="StartupBackground" width="750" height="1624"/>
</resources>
</document>

View File

@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.qxy.dronex/platform_info",
name: "com.dronex.rec/platform_info",
binaryMessenger: registrar.messenger()
)
let plugin = PlatformInfoPlugin()

View File

@@ -116,6 +116,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
private var recordingStartedAt: Date?
private var elapsedTimer: Timer?
private var pendingStopResult: FlutterResult?
private var currentZoomRatio: CGFloat = 1.0
private(set) var status = RecordingStatus(state: .idle) {
didSet {
@@ -291,6 +292,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
self.session.commitConfiguration()
self.videoInput = nil
self.audioInput = nil
self.currentZoomRatio = 1.0
self.configured = false
self.updateStatus(RecordingStatus(state: .idle))
@@ -312,6 +314,52 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
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 {
let nextZoom = self.clampedZoomRatio(ratio, 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(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
@@ -465,9 +513,43 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
session.commitConfiguration()
configured = true
try applyCurrentZoom()
try configureAudioInput(enabled: withAudio)
}
private func currentZoomCapabilitiesMap() -> [String: Any] {
guard let device = videoInput?.device else {
return [
"zoomRatio": Double(currentZoomRatio),
"minZoomRatio": 1.0,
"maxZoomRatio": 3.0,
]
}
let minZoom = device.minAvailableVideoZoomFactor
let maxZoom = device.maxAvailableVideoZoomFactor
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
currentZoomRatio = zoom
return [
"zoomRatio": Double(zoom),
"minZoomRatio": Double(minZoom),
"maxZoomRatio": Double(maxZoom),
]
}
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 {
session.beginConfiguration()
defer { session.commitConfiguration() }
@@ -544,7 +626,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
private enum RecordingChannelNames {
static let packageName = "com.qxy.dronex"
static let packageName = "com.dronex.rec"
static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events"
}
@@ -587,6 +669,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
case "stopRecording":
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":
controller.disposePreview(result: result)
case "getStatus":

View File

@@ -37,12 +37,12 @@ class AppConfig {
),
AppEnvironment.staging => const EnvironmentValues(
environment: AppEnvironment.staging,
baseUrl: 'https://staging.example.com/api',
baseUrl: 'https://example.com/api',
enableNetworkLog: true,
),
AppEnvironment.prod => const EnvironmentValues(
environment: AppEnvironment.prod,
baseUrl: 'https://api.example.com',
baseUrl: 'https://example.com/api',
enableNetworkLog: false,
),
};

View File

@@ -60,7 +60,7 @@ class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
'com.qxy.dronex/platform_info',
'com.dronex.rec/platform_info',
);
static Future<AppPackageInfo> packageInfo() async {

View File

@@ -11,6 +11,9 @@ class RecordingSessionState {
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
this.isMicrophoneGranted = false,
this.zoomRatio = 1.0,
this.minZoomRatio = 1.0,
this.maxZoomRatio = 3.0,
this.lastOutputPath,
this.lastSavedDisplayName,
this.errorMessage,
@@ -26,6 +29,9 @@ class RecordingSessionState {
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
final bool isMicrophoneGranted;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final String? lastOutputPath;
final String? lastSavedDisplayName;
final String? errorMessage;
@@ -51,6 +57,9 @@ class RecordingSessionState {
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
bool? isMicrophoneGranted,
double? zoomRatio,
double? minZoomRatio,
double? maxZoomRatio,
String? lastOutputPath,
String? lastSavedDisplayName,
String? errorMessage,
@@ -69,6 +78,9 @@ class RecordingSessionState {
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
zoomRatio: zoomRatio ?? this.zoomRatio,
minZoomRatio: minZoomRatio ?? this.minZoomRatio,
maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio,
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved
? null

View File

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

View File

@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames {
static const packageName = 'com.qxy.dronex';
static const packageName = 'com.dronex.rec';
static const method = '$packageName/recording';
static const events = '$packageName/recording_events';
}

View File

@@ -81,6 +81,21 @@ class RecordingPlatform {
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({
bool withAudio = 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 {
const RecordingStartResult({this.outputPath, required this.status});

View File

@@ -193,6 +193,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
await _listenStatus();
try {
final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession(
(s) => s.copyWith(
status: status,
@@ -239,6 +240,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
);
try {
final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession(
(s) => s.copyWith(
status: status,
@@ -309,6 +311,47 @@ class RecordingViewModel extends Notifier<RecordingModel> {
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 {
final session = state.session;

View File

@@ -21,11 +21,15 @@ class RecordingHudWidget extends StatelessWidget {
required this.isTouchLocked,
this.showClipboardHint = false,
this.clipboardAddress = '',
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
required this.onStart,
required this.onStop,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onToggleTouchLock,
required this.onZoomSelected,
});
final String? errorMessage;
@@ -38,16 +42,21 @@ class RecordingHudWidget extends StatelessWidget {
final bool isTouchLocked;
final bool showClipboardHint;
final String clipboardAddress;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final Future<void> Function() onStart;
final Future<void> Function() onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
final ValueChanged<double> onZoomSelected;
static double get _recordButtonSize => 70.r;
static double get _recordButtonBottom => 63.r;
static double get _overlayInfoLeft => 13.r;
static double get _overlayInfoBottom => 10.r;
static const List<double> _zoomPresets = [0.6, 1.0];
@override
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(
left: 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

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+2002
version: 1.0.0
environment:
sdk: ^3.9.0

View File

@@ -2,6 +2,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() {
@@ -24,6 +26,11 @@ void main() {
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
null,
);
});
group('RecordingViewModel', () {
@@ -36,9 +43,197 @@ void main() {
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
expect(model.session.isPreviewReady, 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('RecordingViewModel.setZoomRatio', () {
test('updates zoom ratio from native response', () async {
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('recordingGalleryPermissionsForHost', () {
test('requests only add-only photo permission on iOS', () {
final permissions = recordingGalleryPermissionsForHost(

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);
});
}