Author SHA1 Message Date
linfeng 26574808df Merge branch 'linfeng/run/dev/20260630' into linfeng/drone/dev/2026630 2026-07-01 12:18:58 +08:00
linfeng d4c7d7180e 删除AndroidManifest.xml中的READ_MEDIA_VIDEO权限,并更新view_model_recording中的权限逻辑。dart确保文件保存权限仅适用于Android SDK版本28及以下。添加测试以验证Android 13和未知SDK版本的权限行为。 2026-07-01 12:18:12 +08:00
linfeng 4a85b9f83e Merge run recording updates into drone branch 2026-07-01 11:54:06 +08:00
linfeng e4057c3d6a 添加 CLAUDE.md 到 .gitignore 文件 2026-07-01 11:32:22 +08:00
linfeng 90e8966528 重构通道名称,在Android和iOS实现中使用统一的命名空间‘app.record_tool’。相应地更新相关的常量和方法通道。增强在录制HUD中的缩放比例显示格式。 2026-07-01 11:19:30 +08:00
linfeng c8a7494344 1.广角/主摄切换按钮已上移:
2.新增 isSwitchingLens 状态、避免切换镜头    stop 按钮禁用,不会触发 stopRecording
倍距按钮禁用,避免连续切换
ViewModel 层也兜底:isSwitchingLens=true 时直接拒绝 stop
3.合并失败兜底提示
2026-07-01 10:59:28 +08:00
linfeng 02614c2817 更新 .gitignore 文件以排除 CLAUDE.md,并注释掉录制头部小部件中的 mock 按钮 2026-07-01 10:38:50 +08:00
linfeng 9f49f73a31 删除 agent 说明书 2026-07-01 10:37:09 +08:00
linfeng da2e69b014 支持录制中切换广角/主摄 2026-07-01 09:59:21 +08:00
linfeng f6347e99cd 兼容 IOS 2026-06-30 17:28:03 +08:00
linfeng d40c13d802 兼容 IOS 2026-06-30 17:27:54 +08:00
linfeng 23319cfddb 修复部分机型无法切换广角和主摄功能 2026-06-30 17:06:35 +08:00
linfeng 6d93c1c8dd 解决安卓端广角无法读取问题 2026-06-30 16:30:24 +08:00
linfeng c9323f26a7 启动图修改 2026-06-30 14:19:02 +08:00
linfeng 7dafd61314 修改图标 2026-06-30 13:57:18 +08:00
33 changed files with 1904 additions and 853 deletions
+1
View File
@@ -48,3 +48,4 @@ app.*.map.json
/android/app/release
/android/.kotlin
CLAUDE.md
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val appPackageName = "com.run.sportsx"
val appPackageName = "com.dronex.rec"
android {
namespace = appPackageName
+3 -4
View File
@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.run.sportsx">
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" />
@@ -10,7 +10,6 @@
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
@@ -20,7 +19,7 @@
android:required="true" />
<application
android:label="酷跑录像工作台"
android:label="飞行极控录像工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -57,4 +56,4 @@
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>
@@ -0,0 +1,10 @@
package com.dronex.rec
object AppConstants {
private const val CHANNEL_NAMESPACE = "app.record_tool"
const val PLATFORM_INFO_CHANNEL = "$CHANNEL_NAMESPACE/platform_info"
const val RECORDING_METHOD_CHANNEL = "$CHANNEL_NAMESPACE/recording"
const val RECORDING_EVENT_CHANNEL = "$CHANNEL_NAMESPACE/recording_events"
const val RECORDING_ACTION_START = "$CHANNEL_NAMESPACE.recording.START"
const val RECORDING_ACTION_STOP = "$CHANNEL_NAMESPACE.recording.STOP"
}
@@ -1,4 +1,4 @@
package com.run.sportsx
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.run.sportsx.recording.RecordingPlatformHandler
import com.run.sportsx.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
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.content.Intent
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.app.NotificationManager
import android.content.Context
@@ -1,4 +1,4 @@
package com.run.sportsx.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.run.sportsx.AppConstants
import com.run.sportsx.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
@@ -0,0 +1,106 @@
package com.dronex.rec.recording
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import androidx.camera.video.FileOutputOptions
import java.io.File
import java.io.FileInputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/飞行极控录像工作台"
private const val MIME_TYPE = "video/mp4"
fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions {
return FileOutputOptions.Builder(segmentFile).build()
}
fun createSegmentFile(
context: Context,
displayName: String?,
index: Int,
): File {
val directory = File(context.cacheDir, "recording_segments")
if (!directory.exists()) {
directory.mkdirs()
}
val baseName = resolveFileName(displayName).removeSuffix(".mp4")
return File(directory, "${baseName}_${System.currentTimeMillis()}_part$index.mp4")
}
fun createMergeFile(context: Context, displayName: String?): File {
val directory = File(context.cacheDir, "recording_segments")
if (!directory.exists()) {
directory.mkdirs()
}
val baseName = resolveFileName(displayName).removeSuffix(".mp4")
return File(directory, "${baseName}_${System.currentTimeMillis()}_merged.mp4")
}
fun publishToMediaStore(
context: Context,
displayName: String?,
sourceFile: File,
): String? {
val fileName = resolveFileName(displayName)
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
put(MediaStore.Video.Media.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri =
resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues)
?: return null
try {
val outputStream =
resolver.openOutputStream(uri)
?: throw IllegalStateException("Cannot open MediaStore output stream")
outputStream.use { output ->
FileInputStream(sourceFile).use { input -> input.copyTo(output) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val publishedValues =
ContentValues().apply { put(MediaStore.Video.Media.IS_PENDING, 0) }
resolver.update(uri, publishedValues, null, null)
}
return uri.toString()
} catch (error: Exception) {
resolver.delete(uri, null, null)
throw error
}
}
fun publishPartToMediaStore(
context: Context,
displayName: String?,
sourceFile: File,
partIndex: Int,
): String? {
val resolvedName = resolveFileName(displayName)
val partName = resolvedName.replace(".mp4", "_part$partIndex.mp4")
return publishToMediaStore(context, partName, sourceFile)
}
fun resolveFileName(displayName: String?): String {
val trimmed = displayName?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return if (trimmed.lowercase(Locale.US).endsWith(".mp4")) {
trimmed
} else {
"$trimmed.mp4"
}
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4"
}
}
@@ -1,12 +1,12 @@
package com.run.sportsx.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.run.sportsx.AppConstants
import com.run.sportsx.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
@@ -196,6 +196,7 @@ class RecordingPlatformHandler(
"outputPath" to path,
"status" to controller.status.toMap(),
"fileSaved" to fileSaved,
"segmentOutputPaths" to controller.segmentOutputPaths(),
)
if (!fileSaved) {
payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
@@ -1,9 +1,9 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.run.sportsx.MainActivity
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
@@ -0,0 +1,174 @@
package com.dronex.rec.recording
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.ByteBuffer
object RecordingSegmentMuxer {
fun mergeOrCopy(
segments: List<File>,
outputFile: File,
) {
val validSegments = segments.filter { it.exists() && it.length() > 0L }
require(validSegments.isNotEmpty()) { "No recording segments were generated" }
if (validSegments.size == 1) {
copyFile(validSegments.first(), outputFile)
return
}
MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4).use {
muxer ->
val firstTracks = readTrackFormats(validSegments.first())
val videoTrack = firstTracks.video?.let { muxer.addTrack(it) }
val audioTrack = firstTracks.audio?.let { muxer.addTrack(it) }
require(videoTrack != null) { "No video track in recording segment" }
muxer.start()
var offsetUs = 0L
validSegments.forEach { segment ->
val durationUs =
copySegmentTracks(
segment = segment,
muxer = muxer,
videoOutputTrack = videoTrack,
audioOutputTrack = audioTrack,
offsetUs = offsetUs,
)
offsetUs += durationUs + 1L
}
}
}
private fun copyFile(source: File, target: File) {
FileInputStream(source).use { input ->
FileOutputStream(target).use { output -> input.copyTo(output) }
}
}
private fun readTrackFormats(file: File): TrackFormats {
val extractor = MediaExtractor()
extractor.setDataSource(file.absolutePath)
try {
var video: MediaFormat? = null
var audio: MediaFormat? = null
for (index in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(index)
val mime = format.getString(MediaFormat.KEY_MIME).orEmpty()
when {
mime.startsWith("video/") && video == null -> video = format
mime.startsWith("audio/") && audio == null -> audio = format
}
}
return TrackFormats(video = video, audio = audio)
} finally {
extractor.release()
}
}
private fun copySegmentTracks(
segment: File,
muxer: MediaMuxer,
videoOutputTrack: Int,
audioOutputTrack: Int?,
offsetUs: Long,
): Long {
var segmentDurationUs = 0L
segmentDurationUs =
maxOf(
segmentDurationUs,
copyTrack(segment, muxer, "video/", videoOutputTrack, offsetUs),
)
if (audioOutputTrack != null) {
segmentDurationUs =
maxOf(
segmentDurationUs,
copyTrack(segment, muxer, "audio/", audioOutputTrack, offsetUs),
)
}
return segmentDurationUs
}
private fun copyTrack(
segment: File,
muxer: MediaMuxer,
mimePrefix: String,
outputTrack: Int,
offsetUs: Long,
): Long {
val extractor = MediaExtractor()
extractor.setDataSource(segment.absolutePath)
try {
val inputTrack = findTrack(extractor, mimePrefix) ?: return 0L
extractor.selectTrack(inputTrack)
val bufferSize = trackBufferSize(extractor.getTrackFormat(inputTrack))
val buffer = ByteBuffer.allocate(bufferSize)
val info = MediaCodec.BufferInfo()
var firstSampleTimeUs: Long? = null
var lastSampleTimeUs = 0L
while (true) {
buffer.clear()
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) break
val sampleTimeUs = extractor.sampleTime
val baseTimeUs = firstSampleTimeUs ?: sampleTimeUs.also { firstSampleTimeUs = it }
val normalizedTimeUs = (sampleTimeUs - baseTimeUs).coerceAtLeast(0L)
info.set(
0,
sampleSize,
offsetUs + normalizedTimeUs,
extractor.sampleFlags,
)
muxer.writeSampleData(outputTrack, buffer, info)
lastSampleTimeUs = normalizedTimeUs
extractor.advance()
}
return lastSampleTimeUs
} finally {
extractor.release()
}
}
private fun findTrack(extractor: MediaExtractor, mimePrefix: String): Int? {
for (index in 0 until extractor.trackCount) {
val mime = extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME).orEmpty()
if (mime.startsWith(mimePrefix)) {
return index
}
}
return null
}
private fun trackBufferSize(format: MediaFormat): Int {
return if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE).coerceAtLeast(DEFAULT_BUFFER_SIZE)
} else {
DEFAULT_BUFFER_SIZE
}
}
private data class TrackFormats(
val video: MediaFormat?,
val audio: MediaFormat?,
)
private const val DEFAULT_BUFFER_SIZE = 2 * 1024 * 1024
}
private inline fun MediaMuxer.use(block: (MediaMuxer) -> Unit) {
try {
block(this)
} finally {
try {
stop()
} catch (_: Exception) {
}
release()
}
}
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import androidx.lifecycle.LifecycleService
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
enum class RecordingState {
IDLE,
@@ -1,10 +0,0 @@
package com.run.sportsx
object AppConstants {
const val PACKAGE_NAME = "com.run.sportsx"
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"
const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START"
const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP"
}
@@ -1,588 +0,0 @@
package com.run.sportsx.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 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() {
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
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 {
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
}
}
@@ -1,50 +0,0 @@
package com.run.sportsx.recording
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import androidx.camera.video.MediaStoreOutputOptions
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/酷跑录像工作台"
private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions(
context: Context,
displayName: String?,
): MediaStoreOutputOptions {
val fileName = resolveFileName(displayName)
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
}
}
return MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
)
.setContentValues(contentValues)
.build()
}
fun resolveFileName(displayName: String?): String {
val trimmed = displayName?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return if (trimmed.lowercase(Locale.US).endsWith(".mp4")) {
trimmed
} else {
"$trimmed.mp4"
}
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+71 -71
View File
@@ -1,78 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>飞行极控录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>飞行极控录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>酷跑录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>酷跑录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>NSCameraUsageDescription</key>
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>UIFileSharingEnabled</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<true />
<key>UIApplicationSceneManifest</key>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false />
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.dronex.rec/platform_info",
name: "app.record_tool/platform_info",
binaryMessenger: registrar.messenger()
)
let plugin = PlatformInfoPlugin()
+3 -3
View File
@@ -633,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
private enum RecordingChannelNames {
static let packageName = "com.dronex.rec"
static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events"
static let namespace = "app.record_tool"
static let method = "\(namespace)/recording"
static let events = "\(namespace)/recording_events"
}
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
+1 -1
View File
@@ -21,7 +21,7 @@ class AppConfig {
static late EnvironmentValues current;
static AppPackageInfo? packageInfo;
static const appName = '酷跑录像工作台';
static const appName = '飞行极控录像工作台';
static const designSize = Size(375, 812);
static void configure({
+1 -1
View File
@@ -60,7 +60,7 @@ class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
'com.run.sportsx/platform_info',
'app.record_tool/platform_info',
);
static Future<AppPackageInfo> packageInfo() async {
@@ -7,6 +7,7 @@ class RecordingSessionState {
this.isTouchLocked = true,
this.isPreviewReady = false,
this.isStartingRecording = false,
this.isSwitchingLens = false,
this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
@@ -19,12 +20,14 @@ class RecordingSessionState {
this.errorMessage,
this.permissionWarning,
this.fileSaveFailed = false,
this.segmentOutputPaths = const [],
});
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool isStartingRecording;
final bool isSwitchingLens;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
@@ -37,6 +40,7 @@ class RecordingSessionState {
final String? errorMessage;
final String? permissionWarning;
final bool fileSaveFailed;
final List<String> segmentOutputPaths;
bool get isRecording => status.isRecording;
@@ -53,6 +57,7 @@ class RecordingSessionState {
bool? isTouchLocked,
bool? isPreviewReady,
bool? isStartingRecording,
bool? isSwitchingLens,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
@@ -65,6 +70,7 @@ class RecordingSessionState {
String? errorMessage,
String? permissionWarning,
bool? fileSaveFailed,
List<String>? segmentOutputPaths,
bool clearPermissionWarning = false,
bool clearLastSaved = false,
}) {
@@ -73,6 +79,7 @@ class RecordingSessionState {
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -90,6 +97,7 @@ class RecordingSessionState {
? null
: (permissionWarning ?? this.permissionWarning),
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
segmentOutputPaths: segmentOutputPaths ?? this.segmentOutputPaths,
);
}
}
@@ -173,7 +173,11 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session;
if (latest.fileSaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
if (latest.segmentOutputPaths.isNotEmpty) {
AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看');
} else {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
}
return;
}
await _showRecordingSavedDialogIfNeeded();
@@ -374,6 +378,7 @@ class _RecordingHudLayer extends ConsumerWidget {
m.session.notificationsGranted,
m.session.isRecording,
m.session.isStartingRecording,
m.session.isSwitchingLens,
m.session.isTouchLocked,
m.session.zoomRatio,
m.session.minZoomRatio,
@@ -391,6 +396,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted,
isRecording,
isStartingRecording,
isSwitchingLens,
isTouchLocked,
zoomRatio,
minZoomRatio,
@@ -408,6 +414,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted: notificationsGranted,
isRecording: isRecording,
isStartingRecording: isStartingRecording,
isSwitchingLens: isSwitchingLens,
isTouchLocked: isTouchLocked,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames {
static const packageName = 'com.run.sportsx';
static const method = '$packageName/recording';
static const events = '$packageName/recording_events';
static const namespace = 'app.record_tool';
static const method = '$namespace/recording';
static const events = '$namespace/recording_events';
}
@@ -207,12 +207,14 @@ class RecordingStopResult {
required this.status,
this.fileSaved = true,
this.fileErrorMessage,
this.segmentOutputPaths = const [],
});
final String? outputPath;
final RecordingStatus status;
final bool fileSaved;
final String? fileErrorMessage;
final List<String> segmentOutputPaths;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult(
@@ -222,6 +224,11 @@ class RecordingStopResult {
),
fileSaved: result?['fileSaved'] as bool? ?? true,
fileErrorMessage: result?['fileErrorMessage'] as String?,
segmentOutputPaths:
(result?['segmentOutputPaths'] as List?)?.whereType<String>().toList(
growable: false,
) ??
const [],
);
}
}
@@ -41,10 +41,10 @@ List<Permission> recordingFileSavePermissionsForHost({
return const [];
}
if (isAndroid) {
if (androidSdkInt != null && androidSdkInt >= 29) {
return const [];
if (androidSdkInt != null && androidSdkInt <= 28) {
return [Permission.storage];
}
return [Permission.storage];
return const [];
}
return const [];
}
@@ -349,10 +349,14 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
Future<void> setZoomRatio(double ratio) async {
final session = state.session;
if (session.isSwitchingLens) {
return;
}
final clamped = ratio
.clamp(session.minZoomRatio, session.maxZoomRatio)
.toDouble();
_updateSession((s) => s.copyWith(isSwitchingLens: true));
try {
final zoom = await RecordingPlatform.setZoomRatio(clamped);
_updateSession(
@@ -364,8 +368,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
),
);
} on PlatformException catch (error) {
final message = error.code == 'ZOOM_FAILED'
? '切换镜头失败,请重试'
: (error.message ?? '相机倍距设置失败');
_updateSession((s) => s.copyWith(errorMessage: message));
} finally {
_updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'),
(s) => s.copyWith(isSwitchingLens: false, errorMessage: s.errorMessage),
);
}
}
@@ -373,7 +382,9 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session;
if (session.isRecording || session.isStartingRecording) {
if (session.isRecording ||
session.isStartingRecording ||
session.isSwitchingLens) {
return;
}
if (!session.isPreviewReady) {
@@ -400,6 +411,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
isTouchLocked: true,
errorMessage: null,
fileSaveFailed: false,
segmentOutputPaths: const [],
clearLastSaved: true,
),
);
@@ -414,7 +426,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 停止录制、保存到文件夹,并恢复相机预览。
Future<void> stopRecording() async {
if (!state.session.isRecording) return;
if (!state.session.isRecording || state.session.isSwitchingLens) return;
try {
final result = await RecordingPlatform.stopRecording();
@@ -431,6 +443,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
: null,
fileSaveFailed: fileFailed,
segmentOutputPaths: result.segmentOutputPaths,
),
);
} on PlatformException catch (error) {
@@ -18,6 +18,7 @@ class RecordingHudWidget extends StatelessWidget {
required this.notificationsGranted,
required this.isRecording,
required this.isStartingRecording,
required this.isSwitchingLens,
required this.isTouchLocked,
this.showClipboardHint = false,
this.clipboardAddress = '',
@@ -39,6 +40,7 @@ class RecordingHudWidget extends StatelessWidget {
final bool notificationsGranted;
final bool isRecording;
final bool isStartingRecording;
final bool isSwitchingLens;
final bool isTouchLocked;
final bool showClipboardHint;
final String clipboardAddress;
@@ -56,7 +58,6 @@ class RecordingHudWidget extends StatelessWidget {
static double get _recordButtonBottom => 63.r;
static double get _overlayInfoLeft => 13.r;
static double get _overlayInfoBottom => 10.r;
static const List<double> _zoomPresets = [0.6, 1.0];
@override
Widget build(BuildContext context) {
@@ -144,13 +145,13 @@ class RecordingHudWidget extends StatelessWidget {
),
Positioned(
right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h,
bottom: 260.r,
child: _ZoomPresetControl(
isRecording: isRecording,
enabled: !isSwitchingLens,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
presets: _zoomPresets,
presets: _zoomPresetsForRange(minZoomRatio),
onSelected: onZoomSelected,
),
),
@@ -162,7 +163,7 @@ class RecordingHudWidget extends StatelessWidget {
child: RecordingControlButton(
isRecording: isRecording,
isStartingRecording: isStartingRecording,
enabled: !isStartingRecording,
enabled: !isStartingRecording && !isSwitchingLens,
size: _recordButtonSize,
onTap: () {
if (isRecording) {
@@ -191,11 +192,15 @@ class RecordingHudWidget extends StatelessWidget {
],
);
}
List<double> _zoomPresetsForRange(double minZoomRatio) {
return [if (minZoomRatio < 1.0) minZoomRatio, 1.0];
}
}
class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({
required this.isRecording,
required this.enabled,
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
@@ -203,7 +208,7 @@ class _ZoomPresetControl extends StatelessWidget {
required this.onSelected,
});
final bool isRecording;
final bool enabled;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
@@ -215,7 +220,6 @@ class _ZoomPresetControl extends StatelessWidget {
final availablePresets = presets
.where(_isPresetAvailable)
.toList(growable: false);
if (availablePresets.isEmpty) {
return const SizedBox.shrink();
}
@@ -236,7 +240,7 @@ class _ZoomPresetControl extends StatelessWidget {
displayRatio: preset,
requestRatio: preset,
selected: _isPresetSelected(preset),
enabled: !_wouldSwitchPhysicalCamera(preset),
enabled: enabled,
onSelected: onSelected,
),
],
@@ -258,15 +262,6 @@ class _ZoomPresetControl extends StatelessWidget {
}
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 {
@@ -289,7 +284,17 @@ class _ZoomPresetButton extends StatelessWidget {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 1.r),
child: TextButton(
onPressed: selected || !enabled ? null : () => onSelected(requestRatio),
onPressed: selected || !enabled
? null
: () => RateLimit.instance.debounce<void>(
key:
'recording.session.zoom.${requestRatio.toStringAsFixed(1)}',
value: null,
duration: Duration(milliseconds: 300),
onCallback: (_) async {
onSelected(requestRatio);
},
),
style: TextButton.styleFrom(
minimumSize: Size(38.r, 32.r),
padding: EdgeInsets.zero,
@@ -303,7 +308,7 @@ class _ZoomPresetButton extends StatelessWidget {
),
),
child: Text(
'${_formatZoomRatio(displayRatio)}x',
_formatZoomRatio(displayRatio),
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
@@ -315,9 +320,12 @@ class _ZoomPresetButton extends StatelessWidget {
}
String _formatZoomRatio(double ratio) {
if (ratio == ratio.roundToDouble()) {
return ratio.toStringAsFixed(0);
if (ratio < 1.0) {
return '广角';
}
return ratio.toStringAsFixed(1);
if (ratio == ratio.roundToDouble()) {
return '${ratio.toStringAsFixed(0)}x';
}
return '${ratio.toStringAsFixed(1)}x';
}
}
@@ -1,7 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
void main() {
group('RecordingChannelNames', () {
test('uses stable bridge names without package binding', () {
expect(RecordingChannelNames.method, 'app.record_tool/recording');
expect(RecordingChannelNames.events, 'app.record_tool/recording_events');
});
});
group('RecordingPlatform support', () {
test('supports Android and iOS hosts only', () {
expect(
@@ -1,9 +1,12 @@
import 'dart:async';
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/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() {
@@ -76,45 +79,42 @@ void main() {
expect(session.errorMessage, isNull);
});
test(
'clamps legacy 0.5x request to 0.6x ultra-wide ratio',
() async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 0.6,
'minZoomRatio': 0.6,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
// ignore: invalid_use_of_protected_member
notifier.state = container
.read(recordingViewModelProvider)
.copyWith(
session: const RecordingSessionState(
zoomRatio: 1.0,
minZoomRatio: 0.6,
maxZoomRatio: 3.0,
),
);
test('passes 0.5x 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.5,
'minZoomRatio': 0.5,
'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.5,
maxZoomRatio: 3.0,
),
);
await notifier.setZoomRatio(0.5);
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);
},
);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.5});
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 0.5);
expect(session.minZoomRatio, 0.5);
expect(session.maxZoomRatio, 3.0);
});
test('passes 0.6x to native when camera capabilities allow it', () async {
final calls = <MethodCall>[];
@@ -229,7 +229,152 @@ void main() {
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0);
expect(session.errorMessage, 'Zoom is unavailable');
expect(session.errorMessage, '切换镜头失败,请重试');
},
);
test(
'maps native zoom failure to user friendly lens switch message',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
throw PlatformException(
code: 'ZOOM_FAILED',
message: 'Cannot switch physical camera while recording',
);
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(0.6);
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0);
expect(session.errorMessage, '切换镜头失败,请重试');
},
);
test('sets switching lens while native zoom request is pending', () async {
final completer = Completer<Map<String, dynamic>>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) => completer.future,
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
final future = notifier.setZoomRatio(2);
await Future<void>.delayed(Duration.zero);
expect(
container.read(recordingViewModelProvider).session.isSwitchingLens,
isTrue,
);
completer.complete(<String, dynamic>{
'zoomRatio': 2.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
});
await future;
expect(
container.read(recordingViewModelProvider).session.isSwitchingLens,
isFalse,
);
});
});
group('RecordingViewModel.stopRecording', () {
test('does not call native stop while switching lens', () async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{};
},
);
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(
status: RecordingStatus(state: RecordingState.recording),
isSwitchingLens: true,
),
);
await notifier.stopRecording();
expect(calls, isEmpty);
});
test(
'stores segment output paths when native save falls back to parts',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
switch (call.method) {
case 'stopRecording':
return <String, dynamic>{
'outputPath': 'content://recordings/part1',
'status': <String, dynamic>{
'state': 'error',
'message': 'Merge failed',
},
'fileSaved': false,
'fileErrorMessage': 'Merge failed',
'segmentOutputPaths': <String>[
'content://recordings/part1',
'content://recordings/part2',
],
};
case 'initializePreview':
return <String, dynamic>{'state': 'previewing'};
case 'getZoomCapabilities':
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
}
return <String, dynamic>{};
},
);
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(
status: RecordingStatus(state: RecordingState.recording),
),
);
await notifier.stopRecording();
final session = container.read(recordingViewModelProvider).session;
expect(session.fileSaveFailed, isTrue);
expect(session.segmentOutputPaths, <String>[
'content://recordings/part1',
'content://recordings/part2',
]);
},
);
});
@@ -266,6 +411,25 @@ void main() {
expect(permissions, isEmpty);
});
test('does not request file save permission on Android 13 and above', () {
final permissions = recordingFileSavePermissionsForHost(
isIOS: false,
isAndroid: true,
androidSdkInt: 33,
);
expect(permissions, isEmpty);
});
test('does not request file save permission when Android SDK is unknown', () {
final permissions = recordingFileSavePermissionsForHost(
isIOS: false,
isAndroid: true,
);
expect(permissions, isEmpty);
});
});
group('RecordingViewModel.getClipboardContent', () {
@@ -10,6 +10,8 @@ void main() {
double minZoomRatio = 1.0,
double maxZoomRatio = 3.0,
bool isRecording = false,
bool isSwitchingLens = false,
Future<void> Function()? onStop,
ValueChanged<double>? onZoomSelected,
}) async {
await tester.pumpWidget(
@@ -25,12 +27,13 @@ void main() {
notificationsGranted: true,
isRecording: isRecording,
isStartingRecording: false,
isSwitchingLens: isSwitchingLens,
isTouchLocked: false,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
onStart: () async {},
onStop: () async {},
onStop: onStop ?? () async {},
onOpenDnd: () {},
onOpenBattery: () {},
onToggleTouchLock: () {},
@@ -47,50 +50,50 @@ void main() {
testWidgets('shows preset zoom buttons', (tester) async {
await pumpHud(tester);
expect(find.text('0.5x'), findsNothing);
expect(find.text('0.6x'), findsNothing);
expect(find.text('广角'), 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', (
testWidgets('shows wide angle when ultra-wide camera capability is 0.5', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.5);
expect(find.text('0.5x'), findsNothing);
expect(find.text('0.6x'), findsOneWidget);
expect(find.text('广角'), 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', (
testWidgets('shows wide angle when 0.6x camera capability supports it', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.6);
expect(find.text('0.6x'), findsOneWidget);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
});
testWidgets('marks current ultra-wide zoom ratio as selected on 0.6x UI', (
testWidgets('marks current 0.5x wide angle ratio as selected', (
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)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
testWidgets('marks current 0.6x zoom ratio as selected', (tester) async {
testWidgets('marks current 0.6x wide angle 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)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
@@ -98,11 +101,11 @@ void main() {
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('广角'), findsOneWidget);
expect(find.text('1x'), findsNothing);
});
testWidgets('tapping 0.6x reports 0.6 when camera capability is below 0.6', (
testWidgets('tapping 0.5x reports 0.5 when camera capability is 0.5', (
tester,
) async {
double? selected;
@@ -112,10 +115,10 @@ void main() {
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.6x'));
await tester.pump();
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6);
expect(selected, 0.5);
});
testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', (
@@ -128,39 +131,101 @@ void main() {
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.6x'));
await tester.pump();
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
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', (
testWidgets('allows 0.6x while recording on main camera after unlock', (
tester,
) async {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5, isRecording: true);
double? selected;
await pumpHud(
tester,
minZoomRatio: 0.6,
isRecording: true,
onZoomSelected: (ratio) => selected = ratio,
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
final mainButton = tester.widget<TextButton>(
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
);
expect(ultraWideButton.enabled, isTrue);
expect(mainButton.enabled, isFalse);
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6);
});
testWidgets('allows 1x while recording on ultra-wide after unlock', (
tester,
) async {
double? selected;
await pumpHud(
tester,
zoomRatio: 0.5,
minZoomRatio: 0.5,
isRecording: true,
onZoomSelected: (ratio) => selected = ratio,
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('广角'), 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);
expect(mainButton.enabled, isTrue);
await tester.tap(find.text('1x'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 1.0);
});
testWidgets('disables stop button while switching lens', (tester) async {
var stopped = false;
await pumpHud(
tester,
minZoomRatio: 0.6,
isRecording: true,
isSwitchingLens: true,
onStop: () async => stopped = true,
);
await tester.tap(find.byType(GestureDetector).last);
await tester.pump();
expect(stopped, isFalse);
});
testWidgets('disables zoom buttons while switching lens', (tester) async {
double? selected;
await pumpHud(
tester,
minZoomRatio: 0.6,
isRecording: true,
isSwitchingLens: true,
onZoomSelected: (ratio) => selected = ratio,
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(ultraWideButton.enabled, isFalse);
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, isNull);
});
}