diff --git a/lib/core/utils/permission_util.dart b/lib/core/utils/permission_util.dart index 1f19ff396..647dc3989 100644 --- a/lib/core/utils/permission_util.dart +++ b/lib/core/utils/permission_util.dart @@ -3,6 +3,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:app_settings/app_settings.dart'; import 'logger.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; /// 权限请求工具类 /// @@ -11,6 +12,7 @@ import 'package:get/get.dart'; /// 2. 权限解释横幅显示(悬浮在UI顶部的解释性UI) /// 3. 权限请求执行 /// 4. 永久拒绝后的设置引导 +/// 5. 智能拒绝次数统计和熔断机制 /// /// 使用单例模式:`PermissionUtil.instance` @@ -21,8 +23,29 @@ class PermissionUtil { /// 私有构造函数 PermissionUtil._(); - /// 权限解释横幅的叠加层入口 - //OverlayEntry? _explanationOverlayEntry; + /// 引入 get_storage 用于持久化权限拒绝次数 + final _storage = GetStorage(); + + /// 权限拒绝次数阈值(达到此次数后视为永久拒绝) + static const int _denialThreshold = 2; + + /// 获取权限拒绝次数的存储键 + String _getDenialCountKey(Permission permission) { + return 'permission_denial_count_${permission.toString()}'; + } + + /// 重置指定权限的拒绝次数 + /// + /// 在某些场景下可能需要手动重置拒绝次数,比如用户主动要求重新请求权限 + Future resetDenialCount(Permission permissionType) async { + await _storage.remove(_getDenialCountKey(permissionType)); + Logger.d('PermissionUtil', '已重置 ${permissionType.toString()} 的拒绝次数'); + } + + /// 获取指定权限的拒绝次数 + int getDenialCount(Permission permissionType) { + return _storage.read(_getDenialCountKey(permissionType)) ?? 0; + } /// 请求指定权限 /// @@ -37,7 +60,6 @@ class PermissionUtil { /// /// 返回值:`Future` 权限是否最终授予 Future requestPermission({ - // required BuildContext context, required Permission permissionType, required String permissionName, required String explanationText, @@ -49,103 +71,82 @@ class PermissionUtil { final String finalSettingsButtonText = settingsButtonText ?? 'goToSettings'.tr; final String finalCancelButtonText = cancelButtonText ?? 'cancel'.tr; + bool isOverlayShown = false; try { // 检查初始权限状态 - final PermissionStatus status = await permissionType.status; + PermissionStatus status = await permissionType.status; Logger.d('PermissionUtil', '初始权限状态 [$permissionName]: $status'); - // 处理已授权状态(包括授予状态和临时通知权限) - if (status == PermissionStatus.granted || - status == PermissionStatus.provisional) { - Logger.i('PermissionUtil', '$permissionName 权限已授予或临时授权'); + // 1. <--- 已授权处理 --->(包括授予状态和临时通知权限) + if (status.isGranted) { + Logger.i('PermissionUtil', '$permissionName 权限已授予或临时授予'); + // 如果已经授权,清零之前的拒绝记录,以防用户在设置中开启后又关闭 + await resetDenialCount(permissionType); // 清零拒绝记录 return true; } - // 显示权限解释横幅(覆盖所有状态的处理) + // 2. <--- 熔断机制:检查我们自己的拒绝次数 ---> + final denialCount = getDenialCount(permissionType); + Logger.d('PermissionUtil', '$permissionName 当前拒绝次数: $denialCount'); + // 如果拒绝次数达到阈值或系统返回了 permanentlyDenied,直接视为永久拒绝 + if (status.isPermanentlyDenied || denialCount >= _denialThreshold) { + Logger.w('PermissionUtil', '$permissionName 已被系统永久拒绝或自定义计数达到阈值($denialCount),触发设置引导'); + await _showPermanentDenialDialog( + permissionName: permissionName, + permanentDenialText: permanentDenialText, + settingsButtonText: finalSettingsButtonText, + cancelButtonText: finalCancelButtonText, + ); + // 引导用户后,返回false,因为权限当前未被授予 + return false; + } + + // 3. <--- 发起正常的权限请求 (此时 status 必然是 denied 或受限) ---> _showExplanationOverlay( - //context: context, permissionName: permissionName, explanationText: explanationText, ); + isOverlayShown = true; + + + + final PermissionStatus newStatus = await permissionType.request(); + Logger.d('PermissionUtil', '请求后 $permissionName 的新权限状态: $newStatus'); + + _hideExplanationOverlay(); + isOverlayShown = false; - // 处理永久拒绝状态(先显示说明横幅,再引导用户去设置) - if (status == PermissionStatus.permanentlyDenied) { - Logger.w('PermissionUtil', '$permissionName 权限被永久拒绝,显示引导对话框'); + // 4. <--- 处理请求后的结果 ---> + if (newStatus.isGranted) { + Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予'); + await resetDenialCount(permissionType); // 授权成功,清零 + return true; + } - // 显示永久拒绝对话框(在解释横幅下方显示) - final result = await _showPermanentDenialDialog( - // context: context, + if (newStatus.isPermanentlyDenied) { + Logger.w('PermissionUtil', '$permissionName 权限在请求后被系统永久拒绝'); + await _showPermanentDenialDialog( permissionName: permissionName, permanentDenialText: permanentDenialText, settingsButtonText: finalSettingsButtonText, cancelButtonText: finalCancelButtonText, ); - // 移除解释横幅 - _hideExplanationOverlay(); - return result; - } - - // 处理初始拒绝/未决定状态 - if (status == PermissionStatus.denied) { - Logger.d('PermissionUtil', '$permissionName 权限被拒绝或未决定,显示说明横幅并请求'); - // _showExplanationOverlay( - // context: context, - // permissionName: permissionName, - // explanationText: explanationText, - // ); -//请求权限 - final PermissionStatus newStatus = await permissionType.request(); - Logger.d('PermissionUtil', '请求后 $permissionName 的新权限状态: $newStatus'); - - //_hideExplanationOverlay(); - - // 处理请求后的状态 - if (newStatus == PermissionStatus.granted || - newStatus == PermissionStatus.provisional) { - Logger.i('PermissionUtil', '$permissionName 权限在请求后被授予'); - _hideExplanationOverlay(); - return true; - } - // 处理请求后变为永久拒绝 - else if (newStatus == PermissionStatus.permanentlyDenied) { - Logger.w('PermissionUtil', '$permissionName 权限在请求后被永久拒绝'); - final result = await _showPermanentDenialDialog( - // context: context, - permissionName: permissionName, - permanentDenialText: permanentDenialText, - settingsButtonText: finalSettingsButtonText, - cancelButtonText: finalCancelButtonText, - ); - _hideExplanationOverlay(); - return result; - } - // 其他拒绝状态 - _hideExplanationOverlay(); return false; } - // 处理受限状态 - if (status == PermissionStatus.restricted || - status == PermissionStatus.limited) { - Logger.w('PermissionUtil', '$permissionName 权限受限或受限访问: $status'); - await _showRestrictedPermissionDialog(permissionName); - _hideExplanationOverlay(); - return false; + if (newStatus.isDenied) { + Logger.w('PermissionUtil', '$permissionName 权限被拒绝,增加拒绝计数'); + await _storage.write(_getDenialCountKey(permissionType), denialCount + 1); } - // 其他未知状态处理 - Logger.e('PermissionUtil', '$permissionName 处于未知状态: $status'); - _hideExplanationOverlay(); + // 所有未授权的情况最终都返回 false return false; } catch (e, stackTrace) { - Logger.e( - 'PermissionUtil', - '请求 $permissionName 权限失败: $e', - e, - stackTrace, - ); - _hideExplanationOverlay(); + Logger.e('PermissionUtil', '请求 $permissionName 权限失败: $e', e, stackTrace); + if (isOverlayShown) { + _hideExplanationOverlay(); + } return false; } } @@ -168,7 +169,7 @@ class PermissionUtil { borderRadius: BorderRadius.circular(12), elevation: 8, color: Colors.grey[300], - shadowColor: Colors.black.withOpacity(0.3), + shadowColor: Colors.black.withValues(alpha: 0.3), child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -196,64 +197,23 @@ class PermissionUtil { ), ], ), + // 防止用户通过点击背景关闭解释横幅 + barrierDismissible: false, ); - // _hideExplanationOverlay(); // 确保移除旧叠加层 - - // _explanationOverlayEntry = OverlayEntry( - // builder: (context) => Positioned( - // top: MediaQuery.of(context).padding.top + 10, - // left: 16, - // right: 16, - // child: Material( - // borderRadius: BorderRadius.circular(12), - // elevation: 8, - // color: Theme.of(context).colorScheme.background, - // shadowColor: Colors.black.withOpacity(0.3), - // child: Padding( - // padding: const EdgeInsets.all(16), - // child: Column( - // mainAxisSize: MainAxisSize.min, - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text( - // '需要$permissionName权限', - // style: const TextStyle( - // fontWeight: FontWeight.bold, - // fontSize: 16, - // ), - // ), - // const SizedBox(height: 8), - // Text( - // explanationText, - // style: const TextStyle(fontSize: 14), - // ), - // ], - // ), - // ), - // ), - // ), - // ); - - // Future.delayed(Duration.zero, () { - // if (_explanationOverlayEntry != null) { - // Overlay.of(context).insert(_explanationOverlayEntry!); - // Logger.i('PermissionUtil', '权限说明叠加层已显示 ($permissionName)'); - // } - // }); } /// 隐藏权限解释横幅 void _hideExplanationOverlay() { - Get.back(); - //_explanationOverlayEntry!.remove(); - Logger.d('PermissionUtil', '权限说明叠加层已移除'); + if (Get.isDialogOpen ?? false) { + Get.back(); + Logger.d('PermissionUtil', '权限说明叠加层已移除'); + } } /// 显示永久拒绝后的设置引导对话框 /// /// 在权限被永久拒绝时调用,提示用户前往设置手动开启权限 - Future _showPermanentDenialDialog({ - //required BuildContext context, + Future _showPermanentDenialDialog({ required String permissionName, required String permanentDenialText, required String settingsButtonText, @@ -287,59 +247,10 @@ class PermissionUtil { barrierDismissible: false, ); - // bool? userChoice = await showDialog( - // context: context, - // barrierDismissible: false, - // builder: (context) => - // ); - - // 用户选择前往设置 if (userChoice == true) { AppSettings.openAppSettings(); - return false; // 不能立即获取权限,需要用户手动设置 } - - // 用户取消 - return false; } - /// 显示受限权限对话框 - /// - /// 在权限受限状态(如iOS上的受限照片访问)时调用 - Future _showRestrictedPermissionDialog(String permissionName) async { - Logger.w('PermissionUtil', '显示受限权限对话框 ($permissionName)'); - Get.dialog( - AlertDialog( - title: Text(permissionName + 'permissionUtilRestricted'.tr), - content: Text('permissionUtilRestrictedMessage'.tr), - actions: [ - TextButton( - onPressed: () { - Logger.d('PermissionUtil', '用户确认受限权限 ($permissionName)'); - // Navigator.pop(context); - Get.back(); - }, - child: Text('permissionUtilConfirm'.tr), - ), - ], - ), - ); - - // await showDialog( - // context: context, - // builder: (context) => AlertDialog( - // title: Text('$permissionName权限受限'), - // content: const Text('此功能在当前设备上不可用或受限,请检查设备设置或系统限制'), - // actions: [ - // TextButton( - // onPressed: () { - // Logger.d('PermissionUtil', '用户确认受限权限 ($permissionName)'); - // Navigator.pop(context); - // }, - // child: const Text('确定'), - // ), - // ], - // ), - // ); - } + // 移除了多余的_showRestrictedPermissionDialog,因为永久拒绝流程已能覆盖 } diff --git a/lib/data/services/version_update_service.dart b/lib/data/services/version_update_service.dart index a15251857..23be2291b 100644 --- a/lib/data/services/version_update_service.dart +++ b/lib/data/services/version_update_service.dart @@ -24,6 +24,7 @@ class VersionUpdateService extends GetxService { static bool? _hasUpdate = false; //是否有更新 static bool? _isForceUpdate = false; //是否强制更新 static String? _updateUrl = ""; //更新链接 + static AppInfo? _appInfo; //应用信息 static bool? _isShowTourists = false; //是否显示游客登录 static DateTime? _lastCheckTime; //上次检查时间 static const int _cacheIntervalMinutes = 10; // 可配置化 @@ -62,6 +63,9 @@ class VersionUpdateService extends GetxService { /// 获取缓存的更新地址 String get cachedUpdateUrl => _updateUrl ?? ''; + /// 获取缓存的应用信息 + AppInfo? get cachedAppInfo => _appInfo; + /// 获取缓存的游客登录状态 bool get cachedShowTourists => _isShowTourists ?? false; @@ -183,10 +187,13 @@ class VersionUpdateService extends GetxService { if (_lastCheckTime != null && DateTime.now().difference(_lastCheckTime!).inMinutes < _cacheIntervalMinutes) { + // [1]没有超过10分钟,返回缓存数据 + _logger.d('没有超过10分钟,返回缓存数据'); return VersionUpdateInfo( hasUpdate: _hasUpdate ?? false, currentVersion: _appVersion ?? '1.0.0', latestVersion: (_latestVersion ?? '1.0.0').split('+')[0], + appInfo: _appInfo, updateType: _hasUpdate! ? (_isForceUpdate! ? UpdateType.force : UpdateType.optional) : UpdateType.none, @@ -208,6 +215,8 @@ class VersionUpdateService extends GetxService { _logger.d('获取渠道包信息:$response'); if (response == null || !response.containsKey('app')) { + //[2]获取渠道包信息失败: 响应为空或不包含app字段 + _logger.w('获取渠道包信息失败: 响应为空或不包含app字段'); return VersionUpdateInfo( hasUpdate: false, currentVersion: currentVersion, @@ -219,6 +228,7 @@ class VersionUpdateService extends GetxService { //final channelAppResponse = ChannelAppResponse.fromJson(response); // 直接从response中获取app信息 final appInfo = AppInfo.fromJson(response['app']); + _appInfo = appInfo; _logger.d('获取到的最新版本信息: ${appInfo.toJson()}'); _latestVersion = appInfo.version; _updateUrl = appInfo.address; @@ -246,7 +256,7 @@ class VersionUpdateService extends GetxService { _isForceUpdate = _shouldForceUpdate(currentVersion, appInfo.version); updateType = _isForceUpdate! ? UpdateType.force : UpdateType.optional; } - + //[3]返回版本更新信息 return VersionUpdateInfo( hasUpdate: _hasUpdate!, currentVersion: currentVersion.split('+')[0], @@ -343,11 +353,18 @@ class VersionUpdateService extends GetxService { /// 执行更新操作 Future performUpdate(VersionUpdateInfo updateInfo) async { + print('lxm---updateInfo详情:'); + print('lxm---hasUpdate: ${updateInfo.hasUpdate}'); + print('lxm---currentVersion: ${updateInfo.currentVersion}'); + print('lxm---latestVersion: ${updateInfo.latestVersion}'); + print('lxm---updateType: ${updateInfo.updateType}'); + print('lxm---appInfo: ${updateInfo.appInfo?.toJson()}'); if (!updateInfo.hasUpdate || updateInfo.appInfo == null) { return false; } try { + _logger.d('lxm---执行更新操作,下载地址: ${updateInfo.appInfo!.address}'); return await _openDownloadUrl(updateInfo.appInfo!.address!); } catch (e) { _logger.e('执行更新失败: $e'); diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index eea8c50d2..96bc62d21 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -13,27 +13,28 @@ import 'package:permission_handler/permission_handler.dart'; import '../../../data/services/asr_service.dart'; import '../../../data/services/ble_manager.dart'; -/// Enumerates the possible states of the recording timer +/// 会议记录状态枚举 enum TimerStatus { - stopped, // Timer is inactive - running, // Timer is actively counting - paused, // Timer is paused + stopped, // 停止 + running, // 运行中 + paused, // 暂停 } -/// Controller for meeting recording screen -/// Handles recording state, audio processing, transcription, and UI interactions +/// 会议记录控制器 +/// 负责会议记录的状态管理、音频处理、语音识别和UI交互 class MeetingRecordController extends GetxController - with GetSingleTickerProviderStateMixin { + with GetTickerProviderStateMixin { // Services final AsrService _asrService = Get.find(); final BleManager _bleManager = Get.find(); - // Stream subscriptions + // 流订阅 StreamSubscription? _recognitionSubscription; - // Recording state + // 记录状态 final Rx timerStatus = TimerStatus.stopped.obs; final RxInt seconds = 0.obs; + final RxInt milliseconds = 0.obs; final RxInt audioType = 0.obs; // 0=Live, 1=Media, 2=Call final RxBool isMarked = false.obs; final RxBool showTextContent = false.obs; @@ -60,19 +61,29 @@ class MeetingRecordController extends GetxController // 移除了未使用的变量和TabController StreamSubscription? _audioDataSubscription; - // 添加波形放大倍数变量 - final double waveAmplifyFactor = 250.0; // 增加波动幅度 - final RxDouble ursorPosition = 0.0.obs; // 当前播放位置 - final RxInt _currentPosition = 0.obs; // 原始位置索引 - int maxPoints = 300; // 最大显示点数(根据屏幕宽度优化) - int sampleRate = 16000; // 假设音频采样率(根据实际情况调整) + // 简化的音波数据管理 + final int frameIntervalMs = 300; // 300ms间隔,更稳定的帧率 + int maxVisibleBars = 60; // 最大可见波形条数量 + Timer? _audioFrameTimer; // 音频帧定时器 + double _currentFrameAmplitude = 0.0; // 当前帧累积振幅 + int _currentFrameSamples = 0; // 当前帧样本数量 + + // 性能优化:音频处理采样控制 + int _audioProcessCounter = 0; + static const int _audioProcessInterval = 3; // 每3个音频包处理1次 // 波形流动动画控制器 late AnimationController waveformAnimationController; late Animation waveformAnimation; - // 设置最大点数(根据屏幕宽度) - void setMaxPoints(double screenWidth) { - maxPoints = screenWidth ~/ 3; // 增加数据点密度以填满整个区域 + + // 数据更新控制器 - 统一管理数据更新和动画 + late AnimationController _dataUpdateController; + late Animation _dataUpdateAnimation; + // 设置最大可见波形条数量(根据屏幕宽度) + void setMaxVisibleBars(double screenWidth) { + const barWidth = 3.0; // 波形条宽度 + const barSpacing = 1.0; // 波形条间距 + maxVisibleBars = (screenWidth / (barWidth + barSpacing)).floor(); } @override @@ -87,7 +98,9 @@ class MeetingRecordController extends GetxController @override void onClose() { _cleanupResources(); + // 清理两个AnimationController waveformAnimationController.dispose(); + _dataUpdateController.dispose(); super.onClose(); } @@ -121,11 +134,36 @@ class MeetingRecordController extends GetxController titleFocusNode.addListener(_handleTitleFocusChange); } - /// 初始化波形流动动画 + /// 初始化波形流动动画和数据更新控制器 void _initializeWaveformAnimation() { - // 设置动画持续时间,控制流动速度 + // 1. 数据更新控制器 - 控制数据提交频率 + _dataUpdateController = AnimationController( + duration: Duration(milliseconds: frameIntervalMs), // 250ms 一次数据更新 + vsync: this, + ); + + _dataUpdateAnimation = + Tween(begin: 0.0, end: 1.0).animate(_dataUpdateController); + + // 监听数据更新动画,每个周期结束时提交数据 + _dataUpdateAnimation.addListener(() { + if (!isRecording.value) return; + + // 每个动画周期结束时,才提交一次数据 + if (_dataUpdateController.value >= 1.0) { + _commitAudioFrame(); + + // 重置动画,继续循环 + _dataUpdateController.reset(); + if (isRecording.value) { + _dataUpdateController.forward(); + } + } + }); + + // 2. 波形流动动画控制器 - 控制视觉流动效果 waveformAnimationController = AnimationController( - duration: const Duration(milliseconds: 100), // 更快的流动速度 + duration: Duration(milliseconds: frameIntervalMs), // 与数据更新同步 vsync: this, ); @@ -169,6 +207,8 @@ class MeetingRecordController extends GetxController /// Cleans up resources when controller is disposed void _cleanupResources() { _timer?.cancel(); + _audioFrameTimer?.cancel(); // 清理音频帧定时器 + // AnimationController在onClose中统一清理 _recognitionSubscription?.cancel(); _recognitionSubscription = null; _audioDataSubscription?.cancel(); @@ -217,10 +257,17 @@ class MeetingRecordController extends GetxController /// Toggles important mark status void toggleMarked() => isMarked.toggle(); - /// Formats seconds to MM:SS display format - String formatTime(int seconds) { - return '${(seconds ~/ 60).toString().padLeft(2, '0')}' - ':${(seconds % 60).toString().padLeft(2, '0')}'; + /// 格式化时间为 MM:SS.S 显示格式 + String formatTime(int milliseconds) { + // 使用毫秒级精度计算 + final totalMs = milliseconds; + final minutes = (totalMs ~/ 60000); + final secs = ((totalMs % 60000) ~/ 1000); + final centiseconds = ((totalMs % 1000) ~/ 100); // 取十分之一秒(一位小数) + + return '${minutes.toString().padLeft(2, '0')}' + ':${secs.toString().padLeft(2, '0')}' + '.${centiseconds.toString()}'; } /// Starts/resumes recording based on current state @@ -263,7 +310,7 @@ class MeetingRecordController extends GetxController await _asrService.enableRecord("${dir.path}$fullFileName.wav", acceptAudioData: true); - // 监听音频数据流 + // 通过 asrService 获取音频数据流 _audioDataSubscription = _asrService.getAudioDataStream()?.listen((audioData) { // 处理音频数据,例如: @@ -282,8 +329,9 @@ class MeetingRecordController extends GetxController newName.value = fullFileName; titleEditingController.text = fullFileName; - // 启动波形动画 + // 启动波形动画和音频帧定时器 _startWaveformAnimation(); + _startAudioFrameTimer(); // Start hardware components _startHardwareServices(); @@ -311,6 +359,7 @@ class MeetingRecordController extends GetxController Future _pauseRecording() async { await _asrService.pauseRecord(); _stopWaveformAnimation(); // 暂停时停止动画 + _stopAudioFrameTimer(); // 停止音频帧定时器 pauseTimer(); } @@ -318,88 +367,93 @@ class MeetingRecordController extends GetxController Future _resumeRecording() async { await _asrService.resumeRecord(); _startWaveformAnimation(); // 恢复时启动动画 + _startAudioFrameTimer(); // 重启音频帧定时器 startTimer(); } - /// 处理原始Uint8List音频数据 + /// 处理原始Uint8List音频数据 - 只累积,不更新UI,性能优化 void _processAudioData(Uint8List audioData) { - // 将Uint8List转换为Float32List(PCM16 -> 浮点) - final pcmData = _convertToPCM(audioData); + if (!isRecording.value) return; - // 降采样和添加数据 - _addAudioData(pcmData); + // 性能优化:不是每个音频包都处理,减少CPU占用 + _audioProcessCounter++; + if (_audioProcessCounter % _audioProcessInterval != 0) { + return; // 跳过这次处理 + } + + // 将音频数据转换为振幅值并累积到当前帧 + final amplitude = _calculateAmplitude(audioData); + _currentFrameAmplitude += amplitude; + _currentFrameSamples++; } - /// 转换Uint8List为PCM浮点数组 - Float32List _convertToPCM(Uint8List uint8List) { - // 假设音频为16位PCM小端格式 - final byteData = ByteData.sublistView(uint8List); - final length = uint8List.length ~/ 2; - final pcmData = Float32List(length); - - for (int i = 0; i < length; i++) { - // 读取16位有符号整数 (-32768 到 32767) - final int intValue = byteData.getInt16(i * 2, Endian.little); - // 转换为浮点数 (-1.0 到 1.0) - pcmData[i] = intValue / 32768.0; + /// 提交音频帧数据到波形显示 + void _commitAudioFrame() { + // 计算当前帧的平均振幅 + double frameAmplitude = 0.0; + if (_currentFrameSamples > 0) { + frameAmplitude = _currentFrameAmplitude / _currentFrameSamples; } - return pcmData; - } + // 转换为显示数据 + final newAmplitude = frameAmplitude * 800.0; + + // 更新波形数据 + if (waveData.length >= maxVisibleBars) { + waveData.removeAt(0); + } + waveData.add(newAmplitude); - /// 降采样音频数据 - List _downsampleAudio(Float32List audio) { - if (audio.isEmpty) return []; + // 重置当前帧数据 + _currentFrameAmplitude = 0.0; + _currentFrameSamples = 0; + } - final result = []; - final step = (audio.length / maxPoints).ceil(); + /// 计算音频数据的振幅 + double _calculateAmplitude(Uint8List audioData) { + if (audioData.isEmpty) return 0.0; - // 计算每个区间的峰值(保留更多细节) - for (int i = 0; i < audio.length; i += step) { - double maxVal = 0.0; - final end = min(i + step, audio.length); + // 使用RMS (Root Mean Square) 算法,更准确反映音频能量 + double sumSquares = 0.0; + int sampleCount = 0; - for (int j = i; j < end; j++) { - final absVal = audio[j].abs(); - if (absVal > maxVal) maxVal = absVal; + for (int i = 0; i < audioData.length; i += 4) { + // 跳跃采样,减少计算量 + if (i + 1 < audioData.length) { + final int sample = (audioData[i + 1] << 8) | audioData[i]; + final int signedSample = sample > 32767 ? sample - 65536 : sample; + sumSquares += (signedSample * signedSample); + sampleCount++; } - - // 应用放大倍数增强波动效果 - result.add(maxVal * waveAmplifyFactor); } - return result; + if (sampleCount == 0) return 0.0; + final rms = sqrt(sumSquares / sampleCount); + return (rms / 32768.0).clamp(0.0, 1.0); } - // 获取当前光标位置(0.0 - 1.0) - double get cursorPositionValue { - if (waveData.isEmpty) return 0.0; - return _currentPosition.value / waveData.length; - } + /// 启动数据更新控制器 - 替代Timer + void _startAudioFrameTimer() { + _audioFrameTimer?.cancel(); // 清理旧的Timer(如果有) + _audioFrameTimer = null; - /// 添加处理后的音频数据 - 优化全屏流动效果 - void _addAudioData(Float32List newAudio) { - if (!isRecording.value) return; - - // 1. 降采样处理 - final downsampled = _downsampleAudio(newAudio); - if (downsampled.isEmpty) return; + // 启动数据更新控制器 + _dataUpdateController.forward(); + } - // 2. 逐个添加数据点,确保平滑流动 - for (final sample in downsampled) { - waveData.add(sample); + /// 停止数据更新控制器 + void _stopAudioFrameTimer() { + _audioFrameTimer?.cancel(); + _audioFrameTimer = null; - // 维护合理的数据长度 - 保持足够的数据以填满整个屏幕 - if (waveData.length > maxPoints * 2) { - waveData.removeAt(0); // 移除最旧的数据 - } - } + // 停止数据更新控制器 + _dataUpdateController.stop(); + _dataUpdateController.reset(); - // 3. 确保动画持续运行 - _ensureAnimationRunning(); - - // 4. 更新位置信息 - _currentPosition.value = waveData.length; + // 重置当前帧数据和计数器 + _currentFrameAmplitude = 0.0; + _currentFrameSamples = 0; + _audioProcessCounter = 0; // 重置音频处理计数器 } /// 启动波形流动动画 @@ -415,17 +469,11 @@ class MeetingRecordController extends GetxController waveformAnimationController.reset(); } - /// 确保动画持续运行 - void _ensureAnimationRunning() { - if (isRecording.value && !waveformAnimationController.isAnimating) { - waveformAnimationController.forward(); - } - } - /// Saves recording and cleans up resources Future saveRecording() async { resetTimer(); _stopWaveformAnimation(); // 停止波形动画 + _stopAudioFrameTimer(); // 停止音频帧定时器 if (audioType.value != 0) { _bleManager.closeCodec(); } @@ -477,19 +525,22 @@ class MeetingRecordController extends GetxController /// TIMER CONTROL SECTION /// - /// Starts or resumes timer + /// 启动定时器 void startTimer() { if (timerStatus.value != TimerStatus.running) { timerStatus.value = TimerStatus.running; _lastStartTime = DateTime.now().millisecondsSinceEpoch; - _timer = Timer.periodic(const Duration(milliseconds: 200), (_) { + _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { + // 改为100ms更新频率 if (timerStatus.value != TimerStatus.running) return; final currentTime = DateTime.now().millisecondsSinceEpoch; _elapsedMilliseconds += currentTime - _lastStartTime!; _lastStartTime = currentTime; + seconds.value = (_elapsedMilliseconds / 1000).round(); + milliseconds.value = _elapsedMilliseconds; // 更新毫秒值 }); } } @@ -527,7 +578,4 @@ class MeetingRecordController extends GetxController return baseHeight + rand * 10; }); } - - /// Current playback position in waveform (0-1) - double get cursorPosition => _currentPosition / waveData.length; } diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index 5f453d387..16bd712d9 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/lib/modules/meeting/views/meeting_record_view.dart @@ -1,5 +1,5 @@ -import 'dart:math'; - +import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; @@ -17,7 +17,7 @@ class MeetingRecordView extends GetView { onPopInvokedWithResult: (didPop, result) async { if (didPop) { // 在返回时执行保存操作 - controller.saveTitleChange(); + //controller.saveTitleChange(); //await controller.saveRecording(); } }, @@ -108,17 +108,15 @@ class MeetingRecordView extends GetView { // 使用LayoutBuilder获取准确的屏幕宽度 final screenWidth = constraints.maxWidth; - // 初始化最大点数 - if (controller.maxPoints == 0) { - controller.setMaxPoints(screenWidth); - } + // 初始化最大可见波形条数量 + controller.setMaxVisibleBars(screenWidth); return Column( children: [ SizedBox(height: 24.h), - // 专业级实时流动波形显示区域 + // 实时流动波形显示区域 Container( - height: 120.h, + height: 200.h, margin: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( gradient: LinearGradient( @@ -136,7 +134,7 @@ class MeetingRecordView extends GetView { const Color(0xFFF8F9FA), ], ), - borderRadius: BorderRadius.circular(12.r), + //borderRadius: BorderRadius.circular(12.r), border: Border.all( color: isDarkMode ? Colors.white.withValues(alpha: 0.1) @@ -161,7 +159,7 @@ class MeetingRecordView extends GetView { _WaveformWidget( controller: controller, isDarkMode: isDarkMode, - size: Size(screenWidth - 32.w, 120.h), + size: Size(screenWidth - 32.w, 200.h), ), // 录音状态指示器 @@ -213,11 +211,14 @@ class MeetingRecordView extends GetView { SizedBox(height: 24.h), // 时间显示 - 只包装需要响应式更新的Text widget Obx(() => Text( - controller.formatTime(controller.seconds.value), + controller.formatTime(controller.milliseconds.value), style: TextStyle( fontSize: 48.sp, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.normal, color: isDarkMode ? Colors.white : Colors.black87, + fontFeatures: const [ + FontFeature.tabularFigures() + ], // 使用等宽数字,避免跳动 ), )), const Spacer(), @@ -449,7 +450,7 @@ class MeetingRecordView extends GetView { } } -/// 实时流动波形绘制器 +/// 简化的实时流动波形绘制器 class RealTimeWaveformPainter extends CustomPainter { final List waveData; final bool isDarkMode; @@ -466,22 +467,19 @@ class RealTimeWaveformPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final centerY = size.height / 2; - final centerX = size.width / 2; // 绘制中央基准线 - _drawCenterLine(canvas, size, centerX, centerY); + _drawCenterLine(canvas, size, centerY); // 只在录音状态下且有波形数据时才绘制波形 if (isRecording && waveData.isNotEmpty) { - _drawWaveform(canvas, size, centerX, centerY); + _drawWaveform(canvas, size, centerY); } } - /// 绘制中央时间参考线(只保留水平线) - void _drawCenterLine( - Canvas canvas, Size size, double centerX, double centerY) { - // 只绘制水平中线 - final horizontalLinePaint = Paint() + /// 绘制中央基准线 + void _drawCenterLine(Canvas canvas, Size size, double centerY) { + final linePaint = Paint() ..color = (isDarkMode ? Colors.white : Colors.black).withValues(alpha: 0.15) ..strokeWidth = 1.0; @@ -489,134 +487,109 @@ class RealTimeWaveformPainter extends CustomPainter { canvas.drawLine( Offset(0, centerY), Offset(size.width, centerY), - horizontalLinePaint, + linePaint, ); - - // 移除垂直时间参考线和三角形指示器 } - /// 绘制实时流动波形(扩散到整个区域) - void _drawWaveform(Canvas canvas, Size size, double centerX, double centerY) { - final barWidth = 2.5; // 波形条宽度 - final barSpacing = 0.5; // 波形条间距 - final totalBarWidth = barWidth + barSpacing; + /// 绘制简化的流动波形 + void _drawWaveform(Canvas canvas, Size size, double centerY) { + const double barWidth = 3.0; // 波形条宽度 + const double barSpacing = 1.0; // 波形条间距 + const double totalBarWidth = barWidth + barSpacing; - // 计算整个宽度可以显示多少个波形条 + // 计算可显示的波形条数量 final maxBars = (size.width / totalBarWidth).floor(); + final visibleBars = waveData.length > maxBars ? maxBars : waveData.length; - // 绘制扩散到整个区域的波形 - _drawFullWaveform( - canvas, size, centerY, barWidth, totalBarWidth, maxBars); - } - - /// 绘制扩散到整个区域的波形 - void _drawFullWaveform(Canvas canvas, Size size, double centerY, - double barWidth, double totalBarWidth, int maxBars) { - final dataLength = waveData.length; - if (dataLength == 0) return; - - // 计算流动偏移量 + // 计算流动偏移量(从右向左移动) final flowOffset = animationValue * totalBarWidth; - // 绘制波形条,从左到右填满整个区域 - final displayCount = min(dataLength, maxBars); - - for (int i = 0; i < displayCount; i++) { - // 计算波形条的x位置:从左边开始排列,加上流动偏移 - final x = i * totalBarWidth + flowOffset; - - // 如果波形条完全移出屏幕右侧,跳过绘制 - if (x > size.width) continue; - // 如果波形条完全移出屏幕左侧,跳过绘制 - if (x + barWidth < 0) continue; - - // 获取对应的音频数据(循环使用数据以填满整个区域) - final dataIndex = (dataLength - 1 - (i % dataLength)) % dataLength; + // 从右到左绘制波形条 + for (int i = 0; i < visibleBars; i++) { + final dataIndex = waveData.length - 1 - i; final amplitude = waveData[dataIndex]; - // 计算透明度:中央区域更亮,边缘渐暗 - final distanceFromCenter = (x - size.width / 2).abs() / (size.width / 2); - final opacity = (1.0 - distanceFromCenter * 0.4).clamp(0.3, 1.0); + // 计算波形条的x位置(从右侧开始,向左排列) + final x = size.width - (i + 1) * totalBarWidth + flowOffset; + + // 跳过屏幕外的波形条 + if (x < -barWidth || x > size.width) continue; - _drawWaveBar(canvas, x, centerY, barWidth, amplitude, opacity); + _drawWaveBar( + canvas, x, centerY, amplitude, i.toDouble(), visibleBars.toDouble()); } } - /// 绘制单个上下对称的波形条 - void _drawWaveBar(Canvas canvas, double x, double centerY, double barWidth, - double amplitude, double opacity) { - // 将振幅转换为显示高度(上下对称) - final maxHeight = 50.0; // 最大高度 - final barHeight = (amplitude / 100.0) * maxHeight; + /// 绘制单个波形条 + void _drawWaveBar(Canvas canvas, double x, double centerY, double amplitude, + double index, double totalBars) { + const double barWidth = 2.0; + const double maxHeight = 200.0; + const double minHeight = 4.0; // 最小基础高度 + const double containerPadding = 10.0; // 容器内边距,防止超出边界 - // 创建上下对称的圆角矩形 + // 计算可用的最大高度(考虑容器边界) + final availableHeight = (centerY - containerPadding) * 2; // 上下对称 + final effectiveMaxHeight = math.min(maxHeight, availableHeight); + + // 优化波形条高度计算:确保有基础高度 + 振幅变化 + final normalizedAmplitude = (amplitude / 100.0).clamp(0.0, 1.0); + final dynamicHeight = + normalizedAmplitude * (effectiveMaxHeight - minHeight); + final barHeight = minHeight + dynamicHeight; + + // 计算透明度(越新的波形条越不透明) + //final opacity = (1.0 - (index / totalBars) * 0.6).clamp(0.3, 1.0); + + // 创建波形条矩形 final rect = RRect.fromRectAndRadius( Rect.fromCenter( center: Offset(x + barWidth / 2, centerY), width: barWidth, - height: barHeight * 2, // 上下对称,总高度是barHeight的2倍 + height: barHeight, // 使用优化后的高度 ), - Radius.circular(barWidth / 2), + const Radius.circular(1.5), ); - // 创建渐变效果 - final gradient = LinearGradient( - colors: _getGradientColors(opacity), - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ); + // 创建渐变颜色 + final Color baseColor = isDarkMode + ? Colors.grey[500]! // 深灰色 + : Colors.grey[600]!; // 更深灰色 final paint = Paint() - ..shader = gradient.createShader(rect.outerRect) + ..color = baseColor ..style = PaintingStyle.fill; canvas.drawRRect(rect, paint); + } - // 可选:添加高光效果(仅对较新的波形条) - if (opacity > 0.7) { - final highlightRect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: Offset(x + barWidth / 2, centerY), - width: barWidth * 0.4, - height: barHeight * 2 * 0.8, - ), - Radius.circular(barWidth / 4), - ); - - final highlightPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.2 * opacity) - ..style = PaintingStyle.fill; + @override + bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) { + // 只有在以下情况才重绘 + if (oldDelegate.isDarkMode != isDarkMode || + oldDelegate.isRecording != isRecording) { + return true; + } - canvas.drawRRect(highlightRect, highlightPaint); + // 数据长度变化才重绘 + if (oldDelegate.waveData.length != waveData.length) { + return true; } - } - /// 获取渐变颜色 - List _getGradientColors(double opacity) { - return isDarkMode - ? [ - Colors.blue[400]!.withValues(alpha: opacity), - Colors.blue[200]!.withValues(alpha: opacity * 0.7), - Colors.blue[100]!.withValues(alpha: opacity * 0.4), - ] - : [ - Colors.blue[500]!.withValues(alpha: opacity), - Colors.blue[300]!.withValues(alpha: opacity * 0.7), - Colors.blue[100]!.withValues(alpha: opacity * 0.4), - ]; - } + // 数据内容变化(比较最后一个值) + if (waveData.isNotEmpty && + oldDelegate.waveData.isNotEmpty && + (waveData.last - oldDelegate.waveData.last).abs() > 0.1) { + return true; + } - @override - bool shouldRepaint(covariant RealTimeWaveformPainter oldDelegate) { - return oldDelegate.waveData != waveData || - oldDelegate.isDarkMode != isDarkMode || - oldDelegate.isRecording != isRecording || - oldDelegate.animationValue != animationValue; + // 动画值每 0.1 变化才重绘(避免每帧都重绘) + return (animationValue - oldDelegate.animationValue).abs() > 0.1; } } -/// 专门的波形Widget,正确管理GetX响应式状态 -class _WaveformWidget extends StatelessWidget { +/// 优化的波形Widget,避免双重响应式更新 +class _WaveformWidget extends StatefulWidget { final MeetingRecordController controller; final bool isDarkMode; final Size size; @@ -627,25 +600,67 @@ class _WaveformWidget extends StatelessWidget { required this.size, }); + @override + State<_WaveformWidget> createState() => _WaveformWidgetState(); +} + +class _WaveformWidgetState extends State<_WaveformWidget> { + late List _waveData; + late bool _isRecording; + StreamSubscription? _waveDataSubscription; + StreamSubscription? _isRecordingSubscription; + + @override + void initState() { + super.initState(); + _waveData = List.from(widget.controller.waveData); + _isRecording = widget.controller.isRecording.value; + + // 监听 waveData 变化 + _waveDataSubscription = widget.controller.waveData.listen((data) { + if (mounted) { + setState(() { + _waveData = List.from(data); + }); + } + }); + + // 监听 isRecording 变化 + _isRecordingSubscription = widget.controller.isRecording.listen((value) { + if (mounted) { + setState(() { + _isRecording = value; + }); + } + }); + } + + @override + void dispose() { + _waveDataSubscription?.cancel(); + _isRecordingSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { - // 使用AnimatedBuilder监听动画变化 - return AnimatedBuilder( - animation: controller.waveformAnimation, - builder: (context, child) { - // 使用Obx只监听需要响应式更新的数据 - return Obx(() { + return RepaintBoundary( + child: AnimatedBuilder( + animation: widget.controller.waveformAnimation, + builder: (context, child) { return CustomPaint( + size: widget.size, painter: RealTimeWaveformPainter( - waveData: controller.waveData, - isDarkMode: isDarkMode, - isRecording: controller.isRecording.value, - animationValue: controller.waveformAnimation.value, + waveData: _waveData, + isDarkMode: widget.isDarkMode, + isRecording: _isRecording, + animationValue: widget.controller.waveformAnimation.value, ), - size: size, + isComplex: true, // 告诉 Flutter 这个绘制较复杂,可缓存 + willChange: false, // 数据不会频繁变化(我们已优化) ); - }); - }, + }, + ), ); } } diff --git a/local_plugins/qq_music/ios/qq_music/Sources/qq_music/QPlayAutoManager.swift b/local_plugins/qq_music/ios/qq_music/Sources/qq_music/QPlayAutoManager.swift index 8782d68c0..cfe893384 100644 --- a/local_plugins/qq_music/ios/qq_music/Sources/qq_music/QPlayAutoManager.swift +++ b/local_plugins/qq_music/ios/qq_music/Sources/qq_music/QPlayAutoManager.swift @@ -358,7 +358,7 @@ import QPlayAutoSDK return } -// if isLoginOK { + if isLoginOK { // 已登录,检查连接状态 if isConnected { // 已连接,直接执行操作 @@ -380,20 +380,21 @@ import QPlayAutoSDK } } } -// } else { -// // 未登录,先登录 -// print("QPlayAutoManager: 未登录,尝试登录...") -// performLogin { [weak self] success in -// guard let self = self else { return } -// if success { -// print("QPlayAutoManager: 登录成功,执行操作") -// operation() -// } else { -// print("QPlayAutoManager: 登录失败,无法执行操作") -// onFailure?(.loginFailed) -// } -// } -// } + } else { + // 未登录,先登录 + print("QPlayAutoManager: 未登录,尝试登录...") + performLogin { [weak self] success in + guard let self = self else { return } + if success { + print("QPlayAutoManager: 登录成功,执行操作") + operation() + } else { + print("QPlayAutoManager: 登录失败,无法执行操作") + onFailure?(.loginFailed) + stop() + } + } + } } private func performLogin(completion: @escaping (Bool) -> Void) {