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
linfeng d056d28d83 1.更换包名
2.更换 APP 名字
2026-06-30 13:38:16 +08:00
linfeng 8570486798 兼容 IOS 2026-06-13 19:00:37 +08:00
linfeng 208920dfea Merge branch 'linfeng/dev/20260603' into linfeng/dev/2026612 2026-06-13 16:09:20 +08:00
linfeng 88d8dfda04 更新超广角相机的变焦比例,确保在相机能力允许的情况下使用0.6x的缩放比例,优化相关UI和测试用例。 2026-06-12 19:04:00 +08:00
linfeng d39d85cd99 增强变焦功能 2026-06-12 18:35:18 +08:00
20 changed files with 1740 additions and 135 deletions
+1
View File
@@ -48,3 +48,4 @@ app.*.map.json
/android/app/release
/android/.kotlin
CLAUDE.md
+1 -2
View File
@@ -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" />
@@ -57,4 +56,4 @@
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>
@@ -1,10 +1,10 @@
package com.dronex.rec
object AppConstants {
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"
const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START"
const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP"
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"
}
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,9 @@ import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import androidx.camera.video.MediaStoreOutputOptions
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
@@ -13,10 +15,37 @@ object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/飞行极控录像工作台"
private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions(
fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions {
return FileOutputOptions.Builder(segmentFile).build()
}
fun createSegmentFile(
context: Context,
displayName: String?,
): MediaStoreOutputOptions {
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 {
@@ -24,15 +53,42 @@ object RecordingOutputFactory {
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)
}
}
return MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
)
.setContentValues(contentValues)
.build()
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 {
@@ -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 ?: "保存到文件夹失败"
@@ -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()
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "xcode build server",
"version": "1.3.0",
"bspVersion": "2.2.0",
"languages": [
"c",
"cpp",
"objective-c",
"objective-cpp",
"swift"
],
"argv": [
"/opt/homebrew/bin/xcode-build-server"
],
"workspace": "/Users/ZhuanZ/Documents/gdfw/record-tool/ios/Runner.xcworkspace",
"build_root": "/Users/ZhuanZ/Library/Developer/Xcode/DerivedData/Runner-ckjfuyjdkgumnpbnnftroxddsppq",
"scheme": "Runner",
"kind": "xcode"
}
+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()
+41 -12
View File
@@ -336,7 +336,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
do {
let nextZoom = self.clampedZoomRatio(ratio, for: device)
// (1.0x = ) S zoomFactor
let baseline = self.mainBaselineFactor(for: device)
let nextZoom = self.clampedZoomRatio(ratio * baseline, for: device)
try device.lockForConfiguration()
device.videoZoomFactor = nextZoom
device.unlockForConfiguration()
@@ -427,11 +429,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return
}
guard
let videoDevice = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
else {
guard let videoDevice = Self.preferredVideoDevice() else {
throw NSError(
domain: "RecordingCamera", code: 1,
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
@@ -461,10 +459,39 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
session.commitConfiguration()
configured = true
// ( 1.0x) zoomFactor S
currentZoomRatio = mainBaselineFactor(for: videoDevice)
try applyCurrentZoom()
try configureAudioInput(enabled: withAudio)
}
/// 广使 minAvailableVideoZoomFactor ( 0.6x)
private static func preferredVideoDevice() -> AVCaptureDevice? {
let preferredTypes: [AVCaptureDevice.DeviceType] = [
.builtInTripleCamera,
.builtInDualWideCamera,
.builtInWideAngleCamera,
]
for type in preferredTypes {
if let device = AVCaptureDevice.default(type, for: .video, position: .back) {
return device
}
}
return AVCaptureDevice.default(for: .video)
}
/// ( 1.0x) zoomFactor S
/// ultra-wide wide 1.0()
private func mainBaselineFactor(for device: AVCaptureDevice) -> CGFloat {
if let first = device.virtualDeviceSwitchOverVideoZoomFactors.first {
let value = CGFloat(truncating: first)
if value > 0 {
return value
}
}
return 1.0
}
private func currentZoomCapabilitiesMap() -> [String: Any] {
guard let device = videoInput?.device else {
return [
@@ -474,14 +501,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
]
}
// zoomFactor S App 使(1.0x = )
let baseline = mainBaselineFactor(for: device)
let minZoom = device.minAvailableVideoZoomFactor
let maxZoom = device.maxAvailableVideoZoomFactor
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
currentZoomRatio = zoom
return [
"zoomRatio": Double(zoom),
"minZoomRatio": Double(minZoom),
"maxZoomRatio": Double(maxZoom),
"zoomRatio": Double(zoom / baseline),
"minZoomRatio": Double(minZoom / baseline),
"maxZoomRatio": Double(maxZoom / baseline),
]
}
@@ -604,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
@@ -60,7 +60,7 @@ class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
'com.dronex.rec/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.dronex.rec';
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 = [1.0, 2.0, 3.0];
@override
Widget build(BuildContext context) {
@@ -144,12 +145,13 @@ class RecordingHudWidget extends StatelessWidget {
),
Positioned(
right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h,
bottom: 260.r,
child: _ZoomPresetControl(
enabled: !isSwitchingLens,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
presets: _zoomPresets,
presets: _zoomPresetsForRange(minZoomRatio),
onSelected: onZoomSelected,
),
),
@@ -161,7 +163,7 @@ class RecordingHudWidget extends StatelessWidget {
child: RecordingControlButton(
isRecording: isRecording,
isStartingRecording: isStartingRecording,
enabled: !isStartingRecording,
enabled: !isStartingRecording && !isSwitchingLens,
size: _recordButtonSize,
onTap: () {
if (isRecording) {
@@ -190,10 +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.enabled,
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
@@ -201,6 +208,7 @@ class _ZoomPresetControl extends StatelessWidget {
required this.onSelected,
});
final bool enabled;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
@@ -210,9 +218,8 @@ class _ZoomPresetControl extends StatelessWidget {
@override
Widget build(BuildContext context) {
final availablePresets = presets
.where((preset) => preset >= minZoomRatio && preset <= maxZoomRatio)
.where(_isPresetAvailable)
.toList(growable: false);
if (availablePresets.isEmpty) {
return const SizedBox.shrink();
}
@@ -230,8 +237,10 @@ class _ZoomPresetControl extends StatelessWidget {
children: [
for (final preset in availablePresets)
_ZoomPresetButton(
ratio: preset,
selected: (zoomRatio - preset).abs() < 0.05,
displayRatio: preset,
requestRatio: preset,
selected: _isPresetSelected(preset),
enabled: enabled,
onSelected: onSelected,
),
],
@@ -239,17 +248,35 @@ class _ZoomPresetControl extends StatelessWidget {
),
);
}
bool _isPresetAvailable(double preset) {
if (preset < 1.0) {
return minZoomRatio <= preset && maxZoomRatio >= preset;
}
return preset >= minZoomRatio && preset <= maxZoomRatio;
}
bool _isPresetSelected(double preset) {
if (preset < 1.0) {
return zoomRatio < 1.0;
}
return (zoomRatio - preset).abs() < 0.05;
}
}
class _ZoomPresetButton extends StatelessWidget {
const _ZoomPresetButton({
required this.ratio,
required this.displayRatio,
required this.requestRatio,
required this.selected,
required this.enabled,
required this.onSelected,
});
final double ratio;
final double displayRatio;
final double requestRatio;
final bool selected;
final bool enabled;
final ValueChanged<double> onSelected;
@override
@@ -257,7 +284,17 @@ class _ZoomPresetButton extends StatelessWidget {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 1.r),
child: TextButton(
onPressed: selected ? null : () => onSelected(ratio),
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,
@@ -271,7 +308,7 @@ class _ZoomPresetButton extends StatelessWidget {
),
),
child: Text(
'${ratio.toStringAsFixed(0)}x',
_formatZoomRatio(displayRatio),
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
@@ -281,4 +318,14 @@ class _ZoomPresetButton extends StatelessWidget {
),
);
}
String _formatZoomRatio(double ratio) {
if (ratio < 1.0) {
return '广角';
}
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,8 +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() {
@@ -75,6 +79,80 @@ void main() {
expect(session.errorMessage, isNull);
});
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);
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>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 0.6,
'minZoomRatio': 0.6,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
// ignore: invalid_use_of_protected_member
notifier.state = container
.read(recordingViewModelProvider)
.copyWith(
session: const RecordingSessionState(
zoomRatio: 1.0,
minZoomRatio: 0.6,
maxZoomRatio: 3.0,
),
);
await notifier.setZoomRatio(0.6);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.6});
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 0.6);
expect(session.minZoomRatio, 0.6);
expect(session.maxZoomRatio, 3.0);
});
test('clamps requested zoom ratio before invoking native', () async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -98,6 +176,37 @@ void main() {
expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0);
});
test(
'clamps 0.6x to 1x when camera capabilities do not allow it',
() async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(0.6);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 1.0});
expect(
container.read(recordingViewModelProvider).session.zoomRatio,
1.0,
);
},
);
test(
'keeps previous zoom ratio and stores error when native fails',
() async {
@@ -120,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',
]);
},
);
});
@@ -157,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', () {
@@ -9,6 +9,9 @@ void main() {
double zoomRatio = 1.0,
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(
@@ -22,14 +25,15 @@ void main() {
hasDndAccess: true,
isBatteryOptimizedIgnored: true,
notificationsGranted: true,
isRecording: false,
isRecording: isRecording,
isStartingRecording: false,
isSwitchingLens: isSwitchingLens,
isTouchLocked: false,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
onStart: () async {},
onStop: () async {},
onStop: onStop ?? () async {},
onOpenDnd: () {},
onOpenBattery: () {},
onToggleTouchLock: () {},
@@ -46,35 +50,182 @@ void main() {
testWidgets('shows preset zoom buttons', (tester) async {
await pumpHud(tester);
expect(find.text('广角'), findsNothing);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsOneWidget);
expect(find.text('3x'), findsOneWidget);
expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsNothing);
});
testWidgets('marks current zoom ratio as selected', (tester) async {
await pumpHud(tester, zoomRatio: 2.0);
testWidgets('shows wide angle when ultra-wide camera capability is 0.5', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.5);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsNothing);
});
testWidgets('shows wide angle when 0.6x camera capability supports it', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.6);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
});
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('2x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
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('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
testWidgets('does not expose presets beyond max zoom ratio', (tester) async {
await pumpHud(tester, maxZoomRatio: 2.0);
await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsOneWidget);
expect(find.text('3x'), findsNothing);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsNothing);
});
testWidgets('tapping zoom preset reports selected ratio', (tester) async {
testWidgets('tapping 0.5x reports 0.5 when camera capability is 0.5', (
tester,
) async {
double? selected;
await pumpHud(tester, onZoomSelected: (ratio) => selected = ratio);
await pumpHud(
tester,
minZoomRatio: 0.5,
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('2x'));
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.5);
});
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('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6);
});
testWidgets('allows 0.6x while recording on main camera after unlock', (
tester,
) async {
double? selected;
await pumpHud(
tester,
minZoomRatio: 0.6,
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, 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, 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(selected, 2.0);
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);
});
}