18 changed files with 1081 additions and 3249 deletions
@ -1,11 +1,11 @@ |
|||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../controllers/translation_history_controller.dart'; |
import '../controllers/translation_audiohistory_controller.dart'; |
||||
|
|
||||
class TranslationHistoryBinding extends Bindings { |
class TranslationHistoryBinding extends Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
Get.lazyPut<TranslationHistoryController>( |
Get.lazyPut<TranslationAudioHistoryController>( |
||||
() => TranslationHistoryController(), |
() => TranslationAudioHistoryController(), |
||||
); |
); |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,93 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
|
||||
|
/// 录音计时器管理类 |
||||
|
/// 负责管理录音过程中的计时功能 |
||||
|
class RecordTimerController extends GetxController { |
||||
|
static const String TAG = 'RecordTimerController'; |
||||
|
|
||||
|
// 计时器相关状态 |
||||
|
final RxString recordDurationText = '00:00'.obs; |
||||
|
Timer? _recordTimer; |
||||
|
int _seconds = 0; |
||||
|
bool _isTimerPaused = false; |
||||
|
|
||||
|
/// 开始计时器 |
||||
|
/// 从00:00开始计时,每秒更新一次显示 |
||||
|
void startTimer() { |
||||
|
_seconds = 0; |
||||
|
recordDurationText.value = '00:00'; |
||||
|
_recordTimer = Timer.periodic(Duration(seconds: 1), (_) { |
||||
|
_seconds++; |
||||
|
final minutes = (_seconds ~/ 60).toString().padLeft(2, '0'); |
||||
|
final seconds = (_seconds % 60).toString().padLeft(2, '0'); |
||||
|
recordDurationText.value = '$minutes:$seconds'; |
||||
|
}); |
||||
|
Logger.info('录音计时器已开始'); |
||||
|
} |
||||
|
|
||||
|
/// 暂停计时器 |
||||
|
/// 保持当前时间显示,停止计时 |
||||
|
void pauseTimer() { |
||||
|
if (_recordTimer != null && !_isTimerPaused) { |
||||
|
_recordTimer?.cancel(); |
||||
|
_recordTimer = null; |
||||
|
_isTimerPaused = true; |
||||
|
Logger.info('录音计时器已暂停'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 恢复计时器 |
||||
|
/// 从暂停的时间点继续计时 |
||||
|
void resumeTimer() { |
||||
|
if (_isTimerPaused) { |
||||
|
_recordTimer = Timer.periodic(Duration(seconds: 1), (_) { |
||||
|
_seconds++; |
||||
|
final minutes = (_seconds ~/ 60).toString().padLeft(2, '0'); |
||||
|
final seconds = (_seconds % 60).toString().padLeft(2, '0'); |
||||
|
recordDurationText.value = '$minutes:$seconds'; |
||||
|
}); |
||||
|
_isTimerPaused = false; |
||||
|
Logger.info('录音计时器已恢复'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 重置计时器 |
||||
|
/// 停止计时并重置为00:00 |
||||
|
void resetTimer() { |
||||
|
_recordTimer?.cancel(); |
||||
|
_recordTimer = null; |
||||
|
_seconds = 0; |
||||
|
_isTimerPaused = false; |
||||
|
recordDurationText.value = '00:00'; |
||||
|
Logger.info('录音计时器已重置'); |
||||
|
} |
||||
|
|
||||
|
/// 停止计时器 |
||||
|
/// 完全停止计时器并重置状态 |
||||
|
void stopTimer() { |
||||
|
_recordTimer?.cancel(); |
||||
|
_recordTimer = null; |
||||
|
_isTimerPaused = false; |
||||
|
recordDurationText.value = '00:00'; |
||||
|
_seconds = 0; |
||||
|
Logger.info('录音计时器已停止'); |
||||
|
} |
||||
|
|
||||
|
/// 获取当前计时秒数 |
||||
|
int get currentSeconds => _seconds; |
||||
|
|
||||
|
/// 获取当前是否暂停状态 |
||||
|
bool get isPaused => _isTimerPaused; |
||||
|
|
||||
|
/// 获取当前是否正在计时 |
||||
|
bool get isRunning => _recordTimer != null && !_isTimerPaused; |
||||
|
|
||||
|
/// 释放资源 |
||||
|
void dispose() { |
||||
|
_recordTimer?.cancel(); |
||||
|
_recordTimer = null; |
||||
|
Logger.info('录音计时器管理器已释放'); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,179 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:intl/intl.dart'; |
||||
|
import '../controllers/translation_controller.dart'; |
||||
|
import 'dart:async'; |
||||
|
|
||||
|
import '../models/translation_models.dart'; // 添加异步支持 |
||||
|
|
||||
|
class TranslationAudioHistoryController extends GetxController { |
||||
|
// 引用主翻译控制器,以访问历史记录数据 |
||||
|
final TranslationController translationController = |
||||
|
Get.find<TranslationController>(); |
||||
|
|
||||
|
// 按日期分组的历史记录 |
||||
|
final Rx<Map<String, List<TranslationItem>>> groupedHistory = |
||||
|
Rx<Map<String, List<TranslationItem>>>({}); |
||||
|
|
||||
|
// 排序后的日期列表 |
||||
|
final RxList<String> sortedDates = <String>[].obs; |
||||
|
|
||||
|
// 会话映射表,用于存储每个会话的所有项目 |
||||
|
final Rx<Map<String, List<TranslationItem>>> sessionMap = |
||||
|
Rx<Map<String, List<TranslationItem>>>({}); |
||||
|
|
||||
|
// 加载状态 |
||||
|
final isLoading = true.obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
// 确保所有翻译操作都已停止 |
||||
|
translationController.stopAll(); |
||||
|
// 异步加载数据,避免阻塞主线程 |
||||
|
loadHistoryData(); |
||||
|
} |
||||
|
|
||||
|
// 异步加载历史数据 |
||||
|
Future<void> loadHistoryData() async { |
||||
|
isLoading.value = true; |
||||
|
|
||||
|
// 使用 compute 或 Future.microtask 在后台处理数据 |
||||
|
await Future.microtask(() { |
||||
|
_groupHistoryByDate(); |
||||
|
_groupHistoryBySession(); |
||||
|
}); |
||||
|
|
||||
|
isLoading.value = false; |
||||
|
} |
||||
|
|
||||
|
// 按日期对历史记录进行分组 |
||||
|
void _groupHistoryByDate() { |
||||
|
final Map<String, List<TranslationItem>> newGroupedHistory = {}; |
||||
|
|
||||
|
// 获取完整的历史记录,而不仅仅是当前显示的记录 |
||||
|
final List<TranslationItem> allHistory = |
||||
|
translationController.getAllHistory(); |
||||
|
|
||||
|
// 筛选出每个会话的第一条记录 |
||||
|
final List<TranslationItem> firstItemsOfSessions = |
||||
|
allHistory.where((item) => item.isFirstInSession).toList(); |
||||
|
|
||||
|
for (var item in firstItemsOfSessions) { |
||||
|
final DateTime date = item.timestamp; |
||||
|
String key; |
||||
|
|
||||
|
final now = DateTime.now(); |
||||
|
final today = DateTime(now.year, now.month, now.day); |
||||
|
final itemDate = DateTime(date.year, date.month, date.day); |
||||
|
|
||||
|
if (itemDate == today) { |
||||
|
key = 'TODAY'; |
||||
|
} else { |
||||
|
key = DateFormat('yyyy-MM-dd').format(date); |
||||
|
} |
||||
|
|
||||
|
if (!newGroupedHistory.containsKey(key)) { |
||||
|
newGroupedHistory[key] = []; |
||||
|
} |
||||
|
|
||||
|
newGroupedHistory[key]!.add(item); |
||||
|
} |
||||
|
|
||||
|
// 对每个日期组内的项目按时间倒序排序 |
||||
|
newGroupedHistory.forEach((key, items) { |
||||
|
items.sort((a, b) => b.timestamp.compareTo(a.timestamp)); |
||||
|
}); |
||||
|
|
||||
|
// 更新分组后的历史记录 |
||||
|
groupedHistory.value = newGroupedHistory; |
||||
|
|
||||
|
// 按日期排序 |
||||
|
final List<String> dates = newGroupedHistory.keys.toList() |
||||
|
..sort((a, b) { |
||||
|
if (a == 'TODAY') return -1; |
||||
|
if (b == 'TODAY') return 1; |
||||
|
if (a == '1 DAY AGO') return -1; |
||||
|
if (b == '1 DAY AGO') return 1; |
||||
|
if (a == '2 DAYS AGO') return -1; |
||||
|
if (b == '2 DAYS AGO') return 1; |
||||
|
return b.compareTo(a); // 降序排列其他日期 |
||||
|
}); |
||||
|
|
||||
|
sortedDates.value = dates; |
||||
|
} |
||||
|
|
||||
|
// 按会话ID对历史记录进行分组 |
||||
|
void _groupHistoryBySession() { |
||||
|
final Map<String, List<TranslationItem>> newSessionMap = {}; |
||||
|
|
||||
|
// 获取完整的历史记录进行会话分组 |
||||
|
final List<TranslationItem> allHistory = |
||||
|
translationController.getAllHistory(); |
||||
|
|
||||
|
for (var item in allHistory) { |
||||
|
if (!newSessionMap.containsKey(item.sessionId)) { |
||||
|
newSessionMap[item.sessionId] = []; |
||||
|
} |
||||
|
|
||||
|
newSessionMap[item.sessionId]!.add(item); |
||||
|
} |
||||
|
|
||||
|
// 对每个会话内的项目按时间升序排序 |
||||
|
newSessionMap.forEach((key, items) { |
||||
|
items.sort((a, b) => a.timestamp.compareTo(b.timestamp)); |
||||
|
}); |
||||
|
|
||||
|
// 更新会话映射表 |
||||
|
sessionMap.value = newSessionMap; |
||||
|
} |
||||
|
|
||||
|
// 获取指定会话的所有翻译项 |
||||
|
List<TranslationItem> getSessionItems(String sessionId) { |
||||
|
return sessionMap.value[sessionId] ?? []; |
||||
|
} |
||||
|
|
||||
|
// 播放翻译项 |
||||
|
void playTranslationItem(TranslationItem item) { |
||||
|
translationController.playTranslationItem(item); |
||||
|
} |
||||
|
|
||||
|
// 播放指定文本 |
||||
|
void playText(String text) { |
||||
|
translationController.playTranslatedText(text); |
||||
|
} |
||||
|
|
||||
|
// 检查是否有历史记录 |
||||
|
bool get hasHistory => translationController.translationHistory.isNotEmpty; |
||||
|
|
||||
|
// 删除指定会话的历史记录 |
||||
|
Future<void> deleteSessionHistory(String sessionId) async { |
||||
|
try { |
||||
|
// 调用主控制器的删除方法 |
||||
|
translationController.deleteSessionHistory(sessionId); |
||||
|
|
||||
|
// 重新加载和分组历史数据 |
||||
|
await loadHistoryData(); |
||||
|
|
||||
|
// 显示删除成功提示 |
||||
|
Get.snackbar( |
||||
|
'删除成功', |
||||
|
'已删除该会话的所有翻译记录', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Get.isDarkMode ? Colors.grey[800] : Colors.grey[200], |
||||
|
colorText: Get.isDarkMode ? Colors.white : Colors.black, |
||||
|
duration: const Duration(seconds: 2), |
||||
|
); |
||||
|
} catch (e) { |
||||
|
// 显示删除失败提示 |
||||
|
Get.snackbar( |
||||
|
'删除失败', |
||||
|
'删除会话记录时发生错误: ${e.toString()}', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red[400], |
||||
|
colorText: Colors.white, |
||||
|
duration: const Duration(seconds: 3), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -1,174 +1,371 @@ |
|||||
|
import 'dart:convert'; |
||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import 'package:intl/intl.dart'; |
import 'package:get_storage/get_storage.dart'; |
||||
import '../controllers/translation_controller.dart'; |
import '../../../core/utils/logger.dart'; |
||||
import 'dart:async'; // 添加异步支持 |
import '../models/translation_models.dart'; |
||||
|
|
||||
class TranslationHistoryController extends GetxController { |
/// 翻译历史记录管理类 |
||||
// 引用主翻译控制器,以访问历史记录数据 |
/// 负责管理翻译历史的存储、加载、分页等功能 |
||||
final TranslationController translationController = Get.find<TranslationController>(); |
class TranslationHistoryManager extends GetxController { |
||||
|
static const String TAG = 'TranslationHistoryManager'; |
||||
// 按日期分组的历史记录 |
|
||||
final Rx<Map<String, List<TranslationItem>>> groupedHistory = Rx<Map<String, List<TranslationItem>>>({}); |
// 存储相关配置 |
||||
|
static const String _historyKey = 'translation_history'; |
||||
// 排序后的日期列表 |
static const int _maxStorageItems = 1000; // 存储限制 |
||||
final RxList<String> sortedDates = <String>[].obs; |
static const int _maxDisplayItems = 20; // 显示限制 |
||||
|
|
||||
// 会话映射表,用于存储每个会话的所有项目 |
final GetStorage _storage = GetStorage(); |
||||
final Rx<Map<String, List<TranslationItem>>> sessionMap = Rx<Map<String, List<TranslationItem>>>({}); |
|
||||
|
// 翻译历史数据 |
||||
// 加载状态 |
final translationHistory = <TranslationItem>[].obs; |
||||
final isLoading = true.obs; |
|
||||
|
// 分页加载相关状态 |
||||
@override |
final isLoadingMore = false.obs; |
||||
void onInit() { |
final hasMoreHistory = true.obs; |
||||
super.onInit(); |
final currentPage = 1.obs; |
||||
// 确保所有翻译操作都已停止 |
final pageSize = 20; |
||||
translationController.stopAll(); |
final isLoadingMoreError = false.obs; |
||||
// 异步加载数据,避免阻塞主线程 |
bool _scrollListenerSetup = false; |
||||
loadHistoryData(); |
|
||||
|
// 滚动控制器 |
||||
|
final ScrollController scrollController = ScrollController(); |
||||
|
|
||||
|
/// 初始化历史管理器 |
||||
|
void initialize() { |
||||
|
loadTranslationHistory(); |
||||
|
_setupScrollListener(); |
||||
} |
} |
||||
|
|
||||
// 异步加载历史数据 |
/// 加载翻译历史记录 |
||||
Future<void> loadHistoryData() async { |
/// 初始化时只加载最近的显示条数,提高性能 |
||||
isLoading.value = true; |
void loadTranslationHistory() { |
||||
|
final historyJson = _storage.read(_historyKey); |
||||
// 使用 compute 或 Future.microtask 在后台处理数据 |
if (historyJson != null) { |
||||
await Future.microtask(() { |
try { |
||||
_groupHistoryByDate(); |
final List<dynamic> historyList = jsonDecode(historyJson); |
||||
_groupHistoryBySession(); |
final List<TranslationItem> allHistory = |
||||
}); |
historyList.map((item) => TranslationItem.fromJson(item)).toList(); |
||||
|
|
||||
isLoading.value = false; |
// 初始化分页状态 |
||||
|
currentPage.value = 1; |
||||
|
isLoadingMore.value = false; |
||||
|
isLoadingMoreError.value = false; |
||||
|
|
||||
|
// 主页面只显示最近的记录,保证界面性能 |
||||
|
if (allHistory.length > _maxDisplayItems) { |
||||
|
translationHistory.value = |
||||
|
allHistory.sublist(allHistory.length - _maxDisplayItems); |
||||
|
hasMoreHistory.value = true; |
||||
|
} else { |
||||
|
translationHistory.value = allHistory; |
||||
|
hasMoreHistory.value = false; |
||||
|
} |
||||
|
|
||||
|
Logger.info('翻译历史加载成功: ${translationHistory.length}条记录'); |
||||
|
} catch (e) { |
||||
|
_storage.remove(_historyKey); |
||||
|
Logger.error('加载翻译历史失败: ${e.toString()}'); |
||||
|
} |
||||
|
} else { |
||||
|
// 没有历史数据时初始化分页状态 |
||||
|
hasMoreHistory.value = false; |
||||
|
currentPage.value = 1; |
||||
|
isLoadingMore.value = false; |
||||
|
isLoadingMoreError.value = false; |
||||
|
} |
||||
} |
} |
||||
|
|
||||
// 按日期对历史记录进行分组 |
/// 保存翻译历史记录 |
||||
void _groupHistoryByDate() { |
/// 只保存完成态记录,过滤中间态记录 |
||||
final Map<String, List<TranslationItem>> newGroupedHistory = {}; |
void saveTranslationHistory() { |
||||
|
try { |
||||
// 获取完整的历史记录,而不仅仅是当前显示的记录 |
// 只保存完成态记录 |
||||
final List<TranslationItem> allHistory = translationController.getAllHistory(); |
final List<TranslationItem> finalRecords = |
||||
|
translationHistory.where((item) => !item.isIntermediate).toList(); |
||||
// 筛选出每个会话的第一条记录 |
|
||||
final List<TranslationItem> firstItemsOfSessions = allHistory |
// 应用存储限制 |
||||
.where((item) => item.isFirstInSession) |
List<TranslationItem> historyToSave = finalRecords; |
||||
.toList(); |
if (finalRecords.length > _maxStorageItems) { |
||||
|
historyToSave = |
||||
for (var item in firstItemsOfSessions) { |
finalRecords.sublist(finalRecords.length - _maxStorageItems); |
||||
final DateTime date = item.timestamp; |
|
||||
String key; |
|
||||
|
|
||||
final now = DateTime.now(); |
|
||||
final today = DateTime(now.year, now.month, now.day); |
|
||||
final itemDate = DateTime(date.year, date.month, date.day); |
|
||||
|
|
||||
if (itemDate == today) { |
|
||||
key = 'TODAY'; |
|
||||
} |
|
||||
else { |
|
||||
key = DateFormat('yyyy-MM-dd').format(date); |
|
||||
} |
} |
||||
|
|
||||
if (!newGroupedHistory.containsKey(key)) { |
// 保存到本地存储 |
||||
newGroupedHistory[key] = []; |
final List<Map<String, dynamic>> historyJson = |
||||
|
historyToSave.map((item) => item.toJson()).toList(); |
||||
|
_storage.write(_historyKey, jsonEncode(historyJson)); |
||||
|
|
||||
|
Logger.info('翻译历史保存成功: 存储${historyToSave.length}条记录'); |
||||
|
} catch (e) { |
||||
|
Logger.error('保存翻译历史失败: ${e.toString()}'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 添加新的翻译记录 |
||||
|
/// [item] 要添加的翻译记录 |
||||
|
/// [scrollToBottom] 是否滚动到底部 |
||||
|
void addTranslationItem(TranslationItem item, {bool scrollToBottom = true}) { |
||||
|
translationHistory.add(item); |
||||
|
if (scrollToBottom) { |
||||
|
_scrollToBottom(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新翻译记录 |
||||
|
/// [timestamp] 记录的时间戳,用于定位记录 |
||||
|
/// [updater] 更新函数 |
||||
|
bool updateTranslationItem( |
||||
|
DateTime timestamp, Function(TranslationItem) updater) { |
||||
|
final index = translationHistory |
||||
|
.indexWhere((item) => item.timestamp.isAtSameMomentAs(timestamp)); |
||||
|
if (index != -1) { |
||||
|
updater(translationHistory[index]); |
||||
|
translationHistory.refresh(); |
||||
|
return true; |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/// 加载更多历史记录(分页加载) |
||||
|
Future<void> loadMoreHistory() async { |
||||
|
if (isLoadingMore.value || !hasMoreHistory.value) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
isLoadingMore.value = true; |
||||
|
isLoadingMoreError.value = false; |
||||
|
|
||||
|
final nextPage = currentPage.value + 1; |
||||
|
Logger.i(TAG, '正在加载第 $nextPage 页翻译历史...'); |
||||
|
|
||||
|
// 从存储中获取所有历史记录 |
||||
|
final historyJson = _storage.read(_historyKey); |
||||
|
if (historyJson != null) { |
||||
|
final List<dynamic> historyList = jsonDecode(historyJson); |
||||
|
final List<TranslationItem> allHistory = |
||||
|
historyList.map((item) => TranslationItem.fromJson(item)).toList(); |
||||
|
|
||||
|
// 计算分页参数 |
||||
|
final totalItems = allHistory.length; |
||||
|
final endIndex = totalItems - ((nextPage - 1) * pageSize); |
||||
|
final startIndex = totalItems - (nextPage * pageSize); |
||||
|
|
||||
|
if (endIndex > 0) { |
||||
|
final actualStartIndex = startIndex < 0 ? 0 : startIndex; |
||||
|
final actualEndIndex = endIndex > totalItems ? totalItems : endIndex; |
||||
|
|
||||
|
final pageItems = |
||||
|
allHistory.sublist(actualStartIndex, actualEndIndex); |
||||
|
|
||||
|
if (pageItems.isNotEmpty) { |
||||
|
await Future.delayed(const Duration(milliseconds: 300)); |
||||
|
|
||||
|
// reverse模式下:将新记录插入到现有记录列表的开头 |
||||
|
final updatedHistory = [...pageItems, ...translationHistory]; |
||||
|
translationHistory.assignAll(updatedHistory); |
||||
|
|
||||
|
// 更新分页状态 |
||||
|
currentPage.value = nextPage; |
||||
|
hasMoreHistory.value = startIndex > 0; |
||||
|
|
||||
|
Logger.i(TAG, '成功加载 ${pageItems.length} 条历史记录'); |
||||
|
} else { |
||||
|
hasMoreHistory.value = false; |
||||
|
} |
||||
|
} else { |
||||
|
hasMoreHistory.value = false; |
||||
|
} |
||||
|
} else { |
||||
|
hasMoreHistory.value = false; |
||||
} |
} |
||||
|
} catch (e) { |
||||
newGroupedHistory[key]!.add(item); |
Logger.e(TAG, '加载更多历史记录失败: $e'); |
||||
|
isLoadingMoreError.value = true; |
||||
|
} finally { |
||||
|
isLoadingMore.value = false; |
||||
} |
} |
||||
|
|
||||
// 对每个日期组内的项目按时间倒序排序 |
|
||||
newGroupedHistory.forEach((key, items) { |
|
||||
items.sort((a, b) => b.timestamp.compareTo(a.timestamp)); |
|
||||
}); |
|
||||
|
|
||||
// 更新分组后的历史记录 |
|
||||
groupedHistory.value = newGroupedHistory; |
|
||||
|
|
||||
// 按日期排序 |
|
||||
final List<String> dates = newGroupedHistory.keys.toList() |
|
||||
..sort((a, b) { |
|
||||
if (a == 'TODAY') return -1; |
|
||||
if (b == 'TODAY') return 1; |
|
||||
if (a == '1 DAY AGO') return -1; |
|
||||
if (b == '1 DAY AGO') return 1; |
|
||||
if (a == '2 DAYS AGO') return -1; |
|
||||
if (b == '2 DAYS AGO') return 1; |
|
||||
return b.compareTo(a); // 降序排列其他日期 |
|
||||
}); |
|
||||
|
|
||||
sortedDates.value = dates; |
|
||||
} |
} |
||||
|
|
||||
// 按会话ID对历史记录进行分组 |
|
||||
void _groupHistoryBySession() { |
|
||||
final Map<String, List<TranslationItem>> newSessionMap = {}; |
|
||||
|
|
||||
// 获取完整的历史记录进行会话分组 |
/// 获取所有完成态历史记录 |
||||
final List<TranslationItem> allHistory = translationController.getAllHistory(); |
List<TranslationItem> getAllHistory() { |
||||
|
try { |
||||
|
final List<TranslationItem> result = <TranslationItem>[]; |
||||
|
final Set<String> existingIds = <String>{}; |
||||
|
|
||||
|
// 添加存储的历史记录 |
||||
|
final historyJson = _storage.read(_historyKey); |
||||
|
if (historyJson != null) { |
||||
|
final List<dynamic> historyList = jsonDecode(historyJson); |
||||
|
final List<TranslationItem> storedHistory = |
||||
|
historyList.map((item) => TranslationItem.fromJson(item)).toList(); |
||||
|
|
||||
for (var item in allHistory) { |
for (final item in storedHistory) { |
||||
if (!newSessionMap.containsKey(item.sessionId)) { |
final id = |
||||
newSessionMap[item.sessionId] = []; |
'${item.sessionId}_${item.timestamp.millisecondsSinceEpoch}'; |
||||
|
if (!existingIds.contains(id)) { |
||||
|
existingIds.add(id); |
||||
|
result.add(item); |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
|
|
||||
newSessionMap[item.sessionId]!.add(item); |
// 添加当前内存中的完成态记录 |
||||
|
for (final item in translationHistory) { |
||||
|
if (!item.isIntermediate) { |
||||
|
final id = |
||||
|
'${item.sessionId}_${item.timestamp.millisecondsSinceEpoch}'; |
||||
|
if (!existingIds.contains(id)) { |
||||
|
existingIds.add(id); |
||||
|
result.add(item); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 按时间排序 |
||||
|
result.sort((a, b) => a.timestamp.compareTo(b.timestamp)); |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
Logger.error('获取完整历史记录失败: ${e.toString()}'); |
||||
|
return translationHistory.where((item) => !item.isIntermediate).toList(); |
||||
} |
} |
||||
|
|
||||
// 对每个会话内的项目按时间升序排序 |
|
||||
newSessionMap.forEach((key, items) { |
|
||||
items.sort((a, b) => a.timestamp.compareTo(b.timestamp)); |
|
||||
}); |
|
||||
|
|
||||
// 更新会话映射表 |
|
||||
sessionMap.value = newSessionMap; |
|
||||
} |
|
||||
|
|
||||
// 获取指定会话的所有翻译项 |
|
||||
List<TranslationItem> getSessionItems(String sessionId) { |
|
||||
return sessionMap.value[sessionId] ?? []; |
|
||||
} |
} |
||||
|
|
||||
// 播放翻译项 |
/// 清除所有翻译历史 |
||||
void playTranslationItem(TranslationItem item) { |
void clearTranslationHistory() { |
||||
translationController.playTranslationItem(item); |
translationHistory.clear(); |
||||
|
_storage.remove(_historyKey); |
||||
|
Logger.info('翻译历史已清除'); |
||||
} |
} |
||||
|
|
||||
// 播放指定文本 |
/// 删除指定会话的所有翻译记录 |
||||
void playText(String text) { |
void deleteSessionHistory(String sessionId) { |
||||
translationController.playTranslatedText(text); |
try { |
||||
|
// 从内存中删除 |
||||
|
final originalCount = translationHistory.length; |
||||
|
translationHistory.removeWhere((item) => item.sessionId == sessionId); |
||||
|
final removedFromMemory = originalCount - translationHistory.length; |
||||
|
|
||||
|
// 从存储中删除 |
||||
|
final historyJson = _storage.read(_historyKey); |
||||
|
int removedFromStorage = 0; |
||||
|
|
||||
|
if (historyJson != null) { |
||||
|
final List<dynamic> historyList = jsonDecode(historyJson); |
||||
|
final List<TranslationItem> storedHistory = |
||||
|
historyList.map((item) => TranslationItem.fromJson(item)).toList(); |
||||
|
|
||||
|
final originalStoredCount = storedHistory.length; |
||||
|
storedHistory.removeWhere((item) => item.sessionId == sessionId); |
||||
|
removedFromStorage = originalStoredCount - storedHistory.length; |
||||
|
|
||||
|
// 重新保存 |
||||
|
final List<Map<String, dynamic>> updatedHistoryJson = |
||||
|
storedHistory.map((item) => item.toJson()).toList(); |
||||
|
_storage.write(_historyKey, jsonEncode(updatedHistoryJson)); |
||||
|
} |
||||
|
|
||||
|
translationHistory.refresh(); |
||||
|
|
||||
|
Logger.info( |
||||
|
'删除会话历史成功: sessionId=$sessionId, 内存删除${removedFromMemory}条, 存储删除${removedFromStorage}条'); |
||||
|
} catch (e) { |
||||
|
Logger.error('删除会话历史失败: ${e.toString()}'); |
||||
|
rethrow; |
||||
|
} |
||||
} |
} |
||||
|
|
||||
// 检查是否有历史记录 |
|
||||
bool get hasHistory => translationController.translationHistory.isNotEmpty; |
|
||||
|
|
||||
// 删除指定会话的历史记录 |
/// 删除指定的单条翻译记录 |
||||
Future<void> deleteSessionHistory(String sessionId) async { |
void deleteTranslationItem(String sessionId, DateTime timestamp) { |
||||
try { |
try { |
||||
// 调用主控制器的删除方法 |
// 从内存中删除 |
||||
translationController.deleteSessionHistory(sessionId); |
final originalCount = translationHistory.length; |
||||
|
translationHistory.removeWhere((item) => |
||||
// 重新加载和分组历史数据 |
item.sessionId == sessionId && |
||||
await loadHistoryData(); |
item.timestamp.millisecondsSinceEpoch == |
||||
|
timestamp.millisecondsSinceEpoch); |
||||
// 显示删除成功提示 |
final removedFromMemory = originalCount - translationHistory.length; |
||||
Get.snackbar( |
|
||||
'删除成功', |
// 从存储中删除 |
||||
'已删除该会话的所有翻译记录', |
final historyJson = _storage.read(_historyKey); |
||||
snackPosition: SnackPosition.BOTTOM, |
int removedFromStorage = 0; |
||||
backgroundColor: Get.isDarkMode ? Colors.grey[800] : Colors.grey[200], |
|
||||
colorText: Get.isDarkMode ? Colors.white : Colors.black, |
if (historyJson != null) { |
||||
duration: const Duration(seconds: 2), |
final List<dynamic> historyList = jsonDecode(historyJson); |
||||
); |
final List<TranslationItem> storedHistory = |
||||
|
historyList.map((item) => TranslationItem.fromJson(item)).toList(); |
||||
|
|
||||
|
final originalStoredCount = storedHistory.length; |
||||
|
storedHistory.removeWhere((item) => |
||||
|
item.sessionId == sessionId && |
||||
|
item.timestamp.millisecondsSinceEpoch == |
||||
|
timestamp.millisecondsSinceEpoch); |
||||
|
removedFromStorage = originalStoredCount - storedHistory.length; |
||||
|
|
||||
|
// 重新保存 |
||||
|
final List<Map<String, dynamic>> updatedHistoryJson = |
||||
|
storedHistory.map((item) => item.toJson()).toList(); |
||||
|
_storage.write(_historyKey, jsonEncode(updatedHistoryJson)); |
||||
|
} |
||||
|
|
||||
|
translationHistory.refresh(); |
||||
|
|
||||
|
Logger.info( |
||||
|
'删除单条翻译记录成功: sessionId=$sessionId, 内存删除${removedFromMemory}条, 存储删除${removedFromStorage}条'); |
||||
} catch (e) { |
} catch (e) { |
||||
// 显示删除失败提示 |
Logger.error('删除单条翻译记录失败: ${e.toString()}'); |
||||
Get.snackbar( |
rethrow; |
||||
'删除失败', |
|
||||
'删除会话记录时发生错误: ${e.toString()}', |
|
||||
snackPosition: SnackPosition.BOTTOM, |
|
||||
backgroundColor: Colors.red[400], |
|
||||
colorText: Colors.white, |
|
||||
duration: const Duration(seconds: 3), |
|
||||
); |
|
||||
} |
} |
||||
} |
} |
||||
} |
|
||||
|
/// 滚动到底部 |
||||
|
void scrollToBottom({bool animate = true}) { |
||||
|
_scrollToBottom(animate: animate); |
||||
|
} |
||||
|
|
||||
|
/// 设置滚动监听器 |
||||
|
void _setupScrollListener() { |
||||
|
if (_scrollListenerSetup) return; |
||||
|
|
||||
|
scrollController.addListener(() { |
||||
|
// reverse模式下:检查是否滚动到底部 |
||||
|
if (scrollController.position.pixels >= |
||||
|
scrollController.position.maxScrollExtent - 100) { |
||||
|
if (hasMoreHistory.value && !isLoadingMore.value) { |
||||
|
Logger.d(TAG, '触发加载更多翻译历史记录'); |
||||
|
loadMoreHistory(); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
_scrollListenerSetup = true; |
||||
|
} |
||||
|
|
||||
|
/// 内部滚动到底部方法 |
||||
|
void _scrollToBottom({bool animate = true}) { |
||||
|
if (scrollController.hasClients && translationHistory.isNotEmpty) { |
||||
|
try { |
||||
|
const targetPosition = 0.0; // reverse模式下最新消息在顶部 |
||||
|
|
||||
|
if (animate) { |
||||
|
scrollController.animateTo( |
||||
|
targetPosition, |
||||
|
duration: const Duration(milliseconds: 300), |
||||
|
curve: Curves.easeOut, |
||||
|
); |
||||
|
} else { |
||||
|
scrollController.jumpTo(targetPosition); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Logger.error('滚动到顶部失败: ${e.toString()}'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
void dispose() { |
||||
|
scrollController.dispose(); |
||||
|
Logger.info('翻译历史管理器已释放'); |
||||
|
} |
||||
|
} |
||||
|
|||||
@ -0,0 +1,76 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../data/services/language_manager.dart'; |
||||
|
|
||||
|
/// 翻译项模型 |
||||
|
/// 表示一条翻译记录的完整信息 |
||||
|
class TranslationItem { |
||||
|
String sourceText; |
||||
|
String translatedText; |
||||
|
String sourceLanguageCode; |
||||
|
String targetLanguageCode; |
||||
|
final DateTime timestamp; |
||||
|
String sessionId; |
||||
|
final bool isFirstInSession; |
||||
|
bool isIntermediate; // 是否为中间结果 |
||||
|
|
||||
|
TranslationItem({ |
||||
|
required this.sourceText, |
||||
|
required this.translatedText, |
||||
|
required this.sourceLanguageCode, |
||||
|
required this.targetLanguageCode, |
||||
|
required this.timestamp, |
||||
|
required this.sessionId, |
||||
|
this.isFirstInSession = false, |
||||
|
this.isIntermediate = false, |
||||
|
}); |
||||
|
|
||||
|
/// 从JSON创建TranslationItem实例 |
||||
|
factory TranslationItem.fromJson(Map<String, dynamic> json) { |
||||
|
DateTime timestamp; |
||||
|
try { |
||||
|
timestamp = DateTime.parse(json['timestamp']); |
||||
|
} catch (e) { |
||||
|
timestamp = DateTime.now(); |
||||
|
} |
||||
|
|
||||
|
return TranslationItem( |
||||
|
sourceText: json['sourceText'] ?? '', |
||||
|
translatedText: json['translatedText'] ?? '', |
||||
|
sourceLanguageCode: json['sourceLanguageCode'] ?? 'zh-CN', |
||||
|
targetLanguageCode: json['targetLanguageCode'] ?? 'en-US', |
||||
|
timestamp: timestamp, |
||||
|
sessionId: |
||||
|
json['sessionId'] ?? DateTime.now().millisecondsSinceEpoch.toString(), |
||||
|
isFirstInSession: json['isFirstInSession'] ?? false, |
||||
|
isIntermediate: false, // 从存储加载的都是最终结果 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 转换为JSON格式 |
||||
|
Map<String, dynamic> toJson() { |
||||
|
return { |
||||
|
'sourceText': sourceText, |
||||
|
'translatedText': translatedText, |
||||
|
'sourceLanguageCode': sourceLanguageCode, |
||||
|
'targetLanguageCode': targetLanguageCode, |
||||
|
'timestamp': timestamp.toIso8601String(), |
||||
|
'sessionId': sessionId, |
||||
|
'isFirstInSession': isFirstInSession, |
||||
|
// 不保存isIntermediate,因为存储的都是最终结果 |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// 获取源语言的中文名称 |
||||
|
String get sourceLanguageName { |
||||
|
final languageManager = Get.find<LanguageManager>(); |
||||
|
return languageManager.getChineseNameByAsrCode(sourceLanguageCode) ?? |
||||
|
'未知语言'; |
||||
|
} |
||||
|
|
||||
|
/// 获取目标语言的中文名称 |
||||
|
String get targetLanguageName { |
||||
|
final languageManager = Get.find<LanguageManager>(); |
||||
|
return languageManager.getChineseNameByAsrCode(targetLanguageCode) ?? |
||||
|
'未知语言'; |
||||
|
} |
||||
|
} |
||||
@ -1,305 +0,0 @@ |
|||||
import Foundation |
|
||||
import CoreBluetooth |
|
||||
import os.log |
|
||||
import AVFoundation |
|
||||
import AccessorySetupKit |
|
||||
import UIKit |
|
||||
|
|
||||
class BleAccessorySetup: NSObject { |
|
||||
// MARK: - 属性 |
|
||||
static let shared = BleAccessorySetup() |
|
||||
|
|
||||
private var bleService: BleService? |
|
||||
private var session: ASAccessorySession? |
|
||||
private var isSessionActivated = false |
|
||||
private var isInitializing = false |
|
||||
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
|
||||
|
|
||||
// 杰理蓝牙设备的公司标识符 |
|
||||
private let deviceCompanyIdentifier: UInt16 = 0xEFAB |
|
||||
private let deviceDisplayName = "语音助理耳机" |
|
||||
|
|
||||
// 用于存储配对设备UUID的UserDefaults键 |
|
||||
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" |
|
||||
|
|
||||
typealias DeviceDiscoveredCallback = (UUID) -> Void |
|
||||
private var onDeviceDiscovered: DeviceDiscoveredCallback? |
|
||||
|
|
||||
// MARK: - 初始化 |
|
||||
private override init() { super.init() } |
|
||||
|
|
||||
// MARK: - 公共方法 |
|
||||
func setBleService(_ service: Any) { |
|
||||
self.bleService = service as? BleService |
|
||||
} |
|
||||
|
|
||||
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
|
||||
self.onDeviceDiscovered = callback |
|
||||
} |
|
||||
|
|
||||
// 处理发现的设备并连接 |
|
||||
func connectToAccessory(uuid: UUID) { |
|
||||
guard let service = bleService else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
service.connectToAccessory(uuid: uuid) |
|
||||
} |
|
||||
|
|
||||
func addToReconnectQueue(uuid: UUID) { |
|
||||
guard let service = bleService else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
service.addToReconnectQueue(uuid: uuid) |
|
||||
} |
|
||||
|
|
||||
// 从视图控制器显示设备选择器 - 主要入口点 |
|
||||
func showDevicePickerFromViewController(_ viewController: UIViewController) { |
|
||||
initializeSession() |
|
||||
} |
|
||||
|
|
||||
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) |
|
||||
func resetBluetoothState() { |
|
||||
if let service = bleService { |
|
||||
service.disconnect() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 会话管理 |
|
||||
func initializeSession() { |
|
||||
if isInitializing { |
|
||||
os_log("会话正在初始化中,跳过", log: logger, type: .info) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 如果会话已激活,检查是否有配对设备 |
|
||||
if session != nil && isSessionActivated { |
|
||||
os_log("会话已激活,检查配对设备", log: logger, type: .info) |
|
||||
// 会话已激活,检查是否有配对设备 |
|
||||
if let accessories = session?.accessories, !accessories.isEmpty { |
|
||||
os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) |
|
||||
// 有配对设备,尝试连接 |
|
||||
if let service = bleService { |
|
||||
let _ = service.getCentralManager() |
|
||||
|
|
||||
for accessory in accessories { |
|
||||
if let uuid = accessory.bluetoothIdentifier { |
|
||||
onDeviceDiscovered?(uuid) |
|
||||
connectToAccessory(uuid: uuid) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} else { |
|
||||
os_log("没有已配对设备,显示配对框", log: logger, type: .info) |
|
||||
// 没有配对设备,直接显示配对框 |
|
||||
showDevicePicker() |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
os_log("开始初始化新会话", log: logger, type: .info) |
|
||||
isInitializing = true |
|
||||
|
|
||||
do { |
|
||||
session = try ASAccessorySession() |
|
||||
session?.activate(on: .main) { [weak self] event in |
|
||||
guard let self = self else { return } |
|
||||
self.handleSessionEvent(event) |
|
||||
} |
|
||||
} catch { |
|
||||
os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
|
||||
handleSessionInitError(error) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
func resetSession() { |
|
||||
session?.invalidate() |
|
||||
session = nil |
|
||||
isSessionActivated = false |
|
||||
isInitializing = false |
|
||||
} |
|
||||
|
|
||||
// MARK: - 设备选择器显示 |
|
||||
private func showDevicePicker() { |
|
||||
guard let session = session, isSessionActivated else { |
|
||||
os_log("无法显示配对框:会话未激活", log: logger, type: .error) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
os_log("准备显示设备配对框", log: logger, type: .info) |
|
||||
|
|
||||
let descriptor = ASDiscoveryDescriptor() |
|
||||
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) |
|
||||
|
|
||||
let deviceImage = UIImage(systemName: "headphones") ?? UIImage() |
|
||||
let displayItem = ASPickerDisplayItem( |
|
||||
name: deviceDisplayName, |
|
||||
productImage: deviceImage, |
|
||||
descriptor: descriptor |
|
||||
) |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
os_log("正在显示设备配对框", log: self.logger, type: .info) |
|
||||
session.showPicker(for: [displayItem]) { [weak self] error in |
|
||||
guard let self = self else { return } |
|
||||
|
|
||||
if let error = error { |
|
||||
let nsError = error as NSError |
|
||||
os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|
||||
} else { |
|
||||
os_log("设备选择器完成", log: self.logger, type: .info) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 私有辅助方法 |
|
||||
private func handleSessionEvent(_ event: ASAccessoryEvent) { |
|
||||
switch event.eventType { |
|
||||
case .activated: |
|
||||
os_log("会话已激活", log: logger, type: .info) |
|
||||
handleSessionActivated() |
|
||||
|
|
||||
case .accessoryAdded: |
|
||||
os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|
||||
handleAccessoryAdded(event.accessory) |
|
||||
|
|
||||
case .accessoryRemoved: |
|
||||
os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|
||||
handleAccessoryRemoved(event.accessory) |
|
||||
|
|
||||
case .accessoryChanged: |
|
||||
os_log("设备状态已改变", log: logger, type: .info) |
|
||||
break |
|
||||
|
|
||||
case .pickerDidPresent: |
|
||||
os_log("配对框已显示", log: logger, type: .info) |
|
||||
break |
|
||||
|
|
||||
case .pickerDidDismiss: |
|
||||
os_log("配对框已关闭", log: logger, type: .info) |
|
||||
break |
|
||||
|
|
||||
case .pickerSetupFailed: |
|
||||
os_log("选择器设置失败: %{public}@", log: logger, type: .error, |
|
||||
event.error?.localizedDescription ?? "未知错误") |
|
||||
|
|
||||
default: |
|
||||
os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) |
|
||||
break |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleSessionActivated() { |
|
||||
isSessionActivated = true |
|
||||
isInitializing = false |
|
||||
|
|
||||
if let accessories = session?.accessories, !accessories.isEmpty { |
|
||||
if let service = bleService { |
|
||||
let _ = service.getCentralManager() |
|
||||
|
|
||||
for accessory in accessories { |
|
||||
if let uuid = accessory.bluetoothIdentifier { |
|
||||
onDeviceDiscovered?(uuid) |
|
||||
connectToAccessory(uuid: uuid) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} else { |
|
||||
showDevicePicker() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleAccessoryAdded(_ accessory: ASAccessory?) { |
|
||||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 保存配对设备的UUID |
|
||||
savePairedMacAddress(uuid) |
|
||||
|
|
||||
onDeviceDiscovered?(uuid) |
|
||||
connectToAccessory(uuid: uuid) |
|
||||
} |
|
||||
|
|
||||
private func handleAccessoryRemoved(_ accessory: ASAccessory?) { |
|
||||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
if let service = bleService { |
|
||||
service.disconnect() |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func handleSessionInitError(_ error: Error) { |
|
||||
isInitializing = false |
|
||||
session = nil |
|
||||
} |
|
||||
|
|
||||
// MARK: - 蓝牙状态检查 |
|
||||
func isBluetoothAvailable() -> Bool { |
|
||||
do { |
|
||||
let audioSession = AVAudioSession.sharedInstance() |
|
||||
let outputs = audioSession.currentRoute.outputs |
|
||||
|
|
||||
for output in outputs { |
|
||||
if output.portType == .bluetoothA2DP || |
|
||||
output.portType == .bluetoothHFP || |
|
||||
output.portType == .bluetoothLE { |
|
||||
return true |
|
||||
} |
|
||||
} |
|
||||
} catch { |
|
||||
os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
// MARK: - 清除配对关系 |
|
||||
func clearAssociations() { |
|
||||
resetBluetoothState() |
|
||||
|
|
||||
// 清除保存的配对设备UUID |
|
||||
clearPairedMacAddress() |
|
||||
|
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
if #available(iOS 18.0, *) { |
|
||||
resetSession() |
|
||||
initializeSession() |
|
||||
} |
|
||||
#endif |
|
||||
} |
|
||||
|
|
||||
// MARK: - 配对设备UUID管理 |
|
||||
|
|
||||
/// 获取已配对设备的UUID字符串 |
|
||||
/// 返回存储的配对设备UUID,如果没有则返回nil |
|
||||
func getPairedMacAddress() -> String? { |
|
||||
let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) |
|
||||
if let uuidString = uuidString, !uuidString.isEmpty { |
|
||||
os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|
||||
return uuidString |
|
||||
} |
|
||||
os_log("未找到已配对设备UUID", log: logger, type: .info) |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
/// 保存配对设备的UUID |
|
||||
/// - Parameter uuid: 要保存的设备UUID |
|
||||
func savePairedMacAddress(_ uuid: UUID) { |
|
||||
let uuidString = uuid.uuidString |
|
||||
UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) |
|
||||
UserDefaults.standard.synchronize() |
|
||||
os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|
||||
} |
|
||||
|
|
||||
/// 清除已保存的配对设备UUID |
|
||||
private func clearPairedMacAddress() { |
|
||||
UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) |
|
||||
UserDefaults.standard.synchronize() |
|
||||
os_log("已清除配对设备UUID", log: logger, type: .info) |
|
||||
} |
|
||||
} |
|
||||
@ -1,93 +0,0 @@ |
|||||
import Foundation |
|
||||
import CoreBluetooth |
|
||||
|
|
||||
/** |
|
||||
* BLE服务常量 |
|
||||
*/ |
|
||||
class BleConst { |
|
||||
// ================== 协议相关UUID ================== |
|
||||
/** 主服务UUID - 文档中定义为0000ABC0-0000-1111-2222-123456789ABC */ |
|
||||
static let PRIMARY_SERVICE_UUID = CBUUID(string: "0000abc0-0000-1111-2222-123456789abc") |
|
||||
|
|
||||
/** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */ |
|
||||
static let AUDIO_SERVICE_UUID = CBUUID(string: "0000ae00-0000-1000-8000-00805f9b34fb") |
|
||||
|
|
||||
/** 接收音频特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */ |
|
||||
static let RECEIVE_AUDIO_CHAR_UUID = CBUUID(string: "0000ae02-0000-1000-8000-00805f9b34fb") |
|
||||
|
|
||||
/** 写入特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */ |
|
||||
static let WRITE_CHAR_UUID = CBUUID(string: "0000abc1-0000-1111-2222-123456789abc") |
|
||||
|
|
||||
/** 通知特征UUID - 文档中定义为0000ABC2-0000-1111-2222-123456789ABC */ |
|
||||
static let NOTIFY_CHAR_UUID = CBUUID(string: "0000abc2-0000-1111-2222-123456789abc") |
|
||||
|
|
||||
/** 客户端特征配置描述符UUID */ |
|
||||
static let CLIENT_CHAR_CONFIG_UUID = CBUUID(string: "00002902-0000-1000-8000-00805f9b34fb") |
|
||||
|
|
||||
/** BLE扫描Action */ |
|
||||
static let ACTION_SCAN_RESULT = "com.yunqiinnovation.ble_service.ACTION_BLE_SCAN" |
|
||||
|
|
||||
// ================== 连接状态 ================== |
|
||||
static let STATE_DISCONNECTED = 0 |
|
||||
static let STATE_CONNECTING = 1 |
|
||||
static let STATE_CONNECTED = 2 |
|
||||
static let STATE_DISCONNECTING = 3 |
|
||||
static let STATE_ERROR = 4 |
|
||||
|
|
||||
// ================== 广播厂商信息 ================== |
|
||||
/** 厂商ID - 文档中定义为0xABEF */ |
|
||||
static let MANUFACTURER_ID: UInt16 = 0xABEF |
|
||||
/** 客户ID - 文档中定义为0xA001 */ |
|
||||
static let CUSTOMER_ID: UInt16 = 0xA001 |
|
||||
/** 产品ID - 文档中定义为0xA001 */ |
|
||||
static let PRODUCT_ID: UInt16 = 0xA001 |
|
||||
/** 蓝牙状态-未连接 */ |
|
||||
static let BT_STATE_DISCONNECTED: UInt8 = 0x00 |
|
||||
/** 蓝牙状态-已连接 */ |
|
||||
static let BT_STATE_CONNECTED: UInt8 = 0x0C |
|
||||
|
|
||||
// ================== 协议格式 ================== |
|
||||
/** APP请求帧头 */ |
|
||||
static let APP_REQUEST_HEADER: UInt8 = 0xAA |
|
||||
/** 设备响应帧头 */ |
|
||||
static let DEVICE_RESPONSE_HEADER: UInt8 = 0xBB |
|
||||
/** 设备主动上报帧头 */ |
|
||||
static let DEVICE_NOTIFY_HEADER: UInt8 = 0xCC |
|
||||
|
|
||||
// ================== 协议指令 (CMD) ================== |
|
||||
/** 获取耳机版本号 */ |
|
||||
static let CMD_GET_VERSION: UInt8 = 0x01 |
|
||||
/** 获取耳机产品ID */ |
|
||||
static let CMD_GET_PRODUCT_ID: UInt8 = 0x02 |
|
||||
/** 获取耳机颜色ID */ |
|
||||
static let CMD_GET_COLOR_ID: UInt8 = 0x03 |
|
||||
/** 获取耳机电量ID */ |
|
||||
static let CMD_GET_BATTERY_INFO: UInt8 = 0x04 |
|
||||
/** APP控制编解码指令 */ |
|
||||
static let CMD_CONTROL_CODEC: UInt8 = 0x05 |
|
||||
/** 音量增大 */ |
|
||||
static let CMD_VOLUME_UP: UInt8 = 0x06 |
|
||||
/** 音量减小 */ |
|
||||
static let CMD_VOLUME_DOWN: UInt8 = 0x07 |
|
||||
/** A2DP播放 */ |
|
||||
static let CMD_PLAY_A2DP: UInt8 = 0x08 |
|
||||
|
|
||||
/** 唤醒指令 */ |
|
||||
static let CMD_WAKE_UP: UInt8 = 0x11 |
|
||||
|
|
||||
// ================== 编解码控制值 ================== |
|
||||
/** 关闭编解码指令 */ |
|
||||
static let CODEC_CONTROL_CLOSE: UInt8 = 0x00 |
|
||||
/** 音乐或者通话远端声音 */ |
|
||||
static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1 |
|
||||
/** mic和dac(音乐或者通话远端)声音 */ |
|
||||
static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2 |
|
||||
/** 打开编码指令 */ |
|
||||
static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1 |
|
||||
/** 左声道 */ |
|
||||
static let AUDIO_CHANNEL_LEFT: UInt8 = 0x01 |
|
||||
/** 右声道 */ |
|
||||
static let AUDIO_CHANNEL_RIGHT: UInt8 = 0x02 |
|
||||
/** 立体声 */ |
|
||||
static let AUDIO_CHANNEL_STEREO: UInt8 = 0x03 |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -1,4 +0,0 @@ |
|||||
#import <Flutter/Flutter.h> |
|
||||
|
|
||||
@interface BleServicePlugin : NSObject<FlutterPlugin> |
|
||||
@end |
|
||||
@ -1,12 +0,0 @@ |
|||||
#import "BleServicePlugin.h" |
|
||||
#if __has_include(<ble_service/ble_service-Swift.h>) |
|
||||
#import <ble_service/ble_service-Swift.h> |
|
||||
#else |
|
||||
#import "ble_service-Swift.h" |
|
||||
#endif |
|
||||
|
|
||||
@implementation BleServicePlugin |
|
||||
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar { |
|
||||
[SwiftBleServicePlugin registerWithRegistrar:registrar]; |
|
||||
} |
|
||||
@end |
|
||||
@ -1,374 +0,0 @@ |
|||||
import Flutter |
|
||||
import UIKit |
|
||||
import CoreBluetooth |
|
||||
import os |
|
||||
|
|
||||
@available(iOS 13.0, *) |
|
||||
public class SwiftBleServicePlugin: NSObject, FlutterPlugin { |
|
||||
// MARK: - 属性 |
|
||||
private var methodChannel: FlutterMethodChannel |
|
||||
internal var dataEventSink: FlutterEventSink? |
|
||||
internal var statusEventSink: FlutterEventSink? |
|
||||
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleServicePlugin") |
|
||||
|
|
||||
// 标记是否已初始化所有服务 |
|
||||
private var servicesInitialized = false |
|
||||
|
|
||||
// MARK: - 初始化方法 |
|
||||
init(_ channel: FlutterMethodChannel) { |
|
||||
self.methodChannel = channel |
|
||||
super.init() |
|
||||
|
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
if #available(iOS 18.0, *) { |
|
||||
|
|
||||
// iOS 18环境下,只设置回调但不主动初始化会话 |
|
||||
let bleAccessorySetup = BleAccessorySetup.shared |
|
||||
|
|
||||
// 在accessoryAdded回调中延迟设置BleService |
|
||||
bleAccessorySetup.setDeviceDiscoveredCallback { [weak self] uuid in |
|
||||
// 确保服务已初始化 |
|
||||
self?.ensureServicesInitialized() |
|
||||
// 然后处理发现的设备 |
|
||||
self?.handleDiscoveredDevice(uuid: uuid) |
|
||||
} |
|
||||
|
|
||||
// 注释掉自动初始化,改为等待用户主动调用startDeviceAssociation |
|
||||
// bleAccessorySetup.initializeSession() |
|
||||
|
|
||||
os_log("iOS 18环境:已设置设备发现回调,等待用户主动调用关联", log: logger, type: .info) |
|
||||
} else { |
|
||||
// 低于iOS 18,直接初始化所有服务 |
|
||||
initializeAllServices() |
|
||||
} |
|
||||
#else |
|
||||
// 非AccessorySetupKit环境,直接初始化所有服务 |
|
||||
initializeAllServices() |
|
||||
#endif |
|
||||
} |
|
||||
|
|
||||
|
|
||||
// 确保所有服务都已初始化 |
|
||||
private func ensureServicesInitialized() { |
|
||||
if !servicesInitialized { |
|
||||
// 设置BLE服务回调 |
|
||||
BleService.shared.addCallback(self) |
|
||||
|
|
||||
// 设置BleService到AccessorySetup |
|
||||
BleAccessorySetup.shared.setBleService(BleService.shared) |
|
||||
|
|
||||
servicesInitialized = true |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 初始化所有服务(iOS 17及以下版本的标准行为) |
|
||||
private func initializeAllServices() { |
|
||||
// 设置BLE服务回调 |
|
||||
BleService.shared.addCallback(self) |
|
||||
|
|
||||
// 初始化AccessorySetup,但不自动开始会话 |
|
||||
BleAccessorySetup.shared.setBleService(BleService.shared) |
|
||||
BleAccessorySetup.shared.setDeviceDiscoveredCallback { [weak self] uuid in |
|
||||
self?.handleDiscoveredDevice(uuid: uuid) |
|
||||
} |
|
||||
// 移除自动初始化,统一由startDeviceAssociation方法触发 |
|
||||
// BleAccessorySetup.shared.initializeSession() |
|
||||
|
|
||||
os_log("非iOS 18环境:已设置BLE服务但不自动初始化会话", log: logger, type: .info) |
|
||||
servicesInitialized = true |
|
||||
} |
|
||||
|
|
||||
// MARK: - FlutterPlugin Protocol |
|
||||
public static func register(with registrar: FlutterPluginRegistrar) { |
|
||||
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.ble_service", binaryMessenger: registrar.messenger()) |
|
||||
let instance = SwiftBleServicePlugin(channel) |
|
||||
registrar.addMethodCallDelegate(instance, channel: channel) |
|
||||
|
|
||||
// 注册事件通道 - 为每个通道创建独立的handler |
|
||||
let dataEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/data_events", binaryMessenger: registrar.messenger()) |
|
||||
let statusEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/status_events", binaryMessenger: registrar.messenger()) |
|
||||
|
|
||||
// 创建独立的StreamHandler |
|
||||
let dataHandler = BleDataStreamHandler(instance) |
|
||||
let statusHandler = BleStatusStreamHandler(instance) |
|
||||
|
|
||||
dataEventChannel.setStreamHandler(dataHandler) |
|
||||
statusEventChannel.setStreamHandler(statusHandler) |
|
||||
} |
|
||||
|
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|
||||
// 检查是否需要初始化服务 |
|
||||
checkAndInitializeServices() |
|
||||
|
|
||||
switch call.method { |
|
||||
case "isConnected": |
|
||||
result(BleService.shared.isConnected()) |
|
||||
|
|
||||
case "getBatteryLevel": |
|
||||
// 获取电池信息,通过回调返回结果 |
|
||||
if BleService.shared.getBatteryInfo() { |
|
||||
result(0) // 通过回调返回实际值 |
|
||||
} else { |
|
||||
result(FlutterError(code: "UNAVAILABLE", |
|
||||
message: "无法获取电池信息", |
|
||||
details: nil)) |
|
||||
} |
|
||||
|
|
||||
case "isCompanionSupported": |
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
if #available(iOS 18.0, *) { |
|
||||
result(true) |
|
||||
} else { |
|
||||
result(false) |
|
||||
} |
|
||||
#else |
|
||||
result(false) |
|
||||
#endif |
|
||||
|
|
||||
case "getPairedMacAddress": |
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
if #available(iOS 18.0, *) { |
|
||||
let pairedUUID = BleAccessorySetup.shared.getPairedMacAddress() |
|
||||
result(pairedUUID) |
|
||||
} else { |
|
||||
result(nil) |
|
||||
} |
|
||||
#else |
|
||||
result(nil) |
|
||||
#endif |
|
||||
|
|
||||
|
|
||||
|
|
||||
case "isBleConnected": |
|
||||
result(BleService.shared.isConnected()) |
|
||||
|
|
||||
case "registerBackgroundScan", "unregisterBackgroundScan": |
|
||||
// iOS上禁用,直接返回成功以避免Flutter端报错 |
|
||||
result(true) |
|
||||
|
|
||||
case "getDeviceVersion": |
|
||||
result(BleService.shared.getDeviceVersion()) |
|
||||
|
|
||||
case "getProductId": |
|
||||
result(BleService.shared.getProductId()) |
|
||||
|
|
||||
case "getBatteryInfo": |
|
||||
result(BleService.shared.getBatteryInfo()) |
|
||||
|
|
||||
case "openEncoder": |
|
||||
result(BleService.shared.openEncoder()) |
|
||||
|
|
||||
case "openDecoder": |
|
||||
result(BleService.shared.openDecoder()) |
|
||||
|
|
||||
case "openA2DPDecoder": |
|
||||
result(BleService.shared.openA2DPDecoder()) |
|
||||
|
|
||||
case "closeCodec": |
|
||||
result(BleService.shared.closeCodec()) |
|
||||
|
|
||||
case "playA2DP": |
|
||||
result(BleService.shared.playA2DP()) |
|
||||
|
|
||||
// iOS 18+ AccessorySetupKit相关方法 |
|
||||
case "clearAssociations": |
|
||||
// 清除设备配对关系 |
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
if #available(iOS 18.0, *) { |
|
||||
os_log("清除设备配对关系", log: logger, type: .info) |
|
||||
BleAccessorySetup.shared.clearAssociations() |
|
||||
result(true) |
|
||||
} else { |
|
||||
result(true) |
|
||||
} |
|
||||
#else |
|
||||
result(true) |
|
||||
#endif |
|
||||
|
|
||||
case "startDeviceAssociation": |
|
||||
// 用户主动触发设备关联流程 |
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
if #available(iOS 18.0, *) { |
|
||||
os_log("用户主动触发设备关联流程", log: logger, type: .info) |
|
||||
|
|
||||
// 确保服务已初始化 |
|
||||
ensureServicesInitialized() |
|
||||
|
|
||||
// 重新初始化会话 |
|
||||
BleAccessorySetup.shared.initializeSession() |
|
||||
|
|
||||
// 返回成功,实际配对结果会通过回调通知 |
|
||||
result(true) |
|
||||
} else { |
|
||||
os_log("当前iOS版本不支持AccessorySetupKit", log: logger, type: .info) |
|
||||
result(false) |
|
||||
} |
|
||||
#else |
|
||||
os_log("当前环境不支持AccessorySetupKit", log: logger, type: .info) |
|
||||
result(false) |
|
||||
#endif |
|
||||
|
|
||||
default: |
|
||||
result(FlutterMethodNotImplemented) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 私有方法 |
|
||||
// 检查并按需初始化服务 |
|
||||
private func checkAndInitializeServices() { |
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|
||||
if #available(iOS 18.0, *) { |
|
||||
ensureServicesInitialized() |
|
||||
} |
|
||||
#endif |
|
||||
} |
|
||||
|
|
||||
// AccessorySetupKit发现设备的处理 |
|
||||
private func handleDiscoveredDevice(uuid: UUID) { |
|
||||
// BleAccessorySetup内部已进行连接,此处不再重复调用 |
|
||||
// os_log("收到设备发现回调: %{public}@", log: logger, type: .info, uuid.uuidString) |
|
||||
// 可以在这里添加设备记录或其他操作 |
|
||||
} |
|
||||
|
|
||||
private func sendEvent<T>(_ eventSink: FlutterEventSink?, data: T, errorPrefix: String) { |
|
||||
if let eventSink = eventSink { |
|
||||
DispatchQueue.main.async { |
|
||||
eventSink(data) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 获取顶层视图控制器的辅助方法 |
|
||||
private func getTopViewController(_ viewController: UIViewController) -> UIViewController { |
|
||||
if let presented = viewController.presentedViewController { |
|
||||
return getTopViewController(presented) |
|
||||
} |
|
||||
|
|
||||
if let tabBarController = viewController as? UITabBarController, |
|
||||
let selected = tabBarController.selectedViewController { |
|
||||
return getTopViewController(selected) |
|
||||
} |
|
||||
|
|
||||
if let navigationController = viewController as? UINavigationController, |
|
||||
let visibleViewController = navigationController.visibleViewController { |
|
||||
return getTopViewController(visibleViewController) |
|
||||
} |
|
||||
|
|
||||
return viewController |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - BleService.Callback |
|
||||
@available(iOS 13.0, *) |
|
||||
extension SwiftBleServicePlugin: BleService.Callback { |
|
||||
public func onScanResult(devices: [[String: Any]]) { |
|
||||
if let eventSink = statusEventSink { |
|
||||
DispatchQueue.main.async { |
|
||||
eventSink([ |
|
||||
"type": "scanResult", |
|
||||
"devices": devices |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public func onConnectionStateChanged(state: Int) { |
|
||||
if let eventSink = statusEventSink { |
|
||||
var stateStr = "Unknown" |
|
||||
switch state { |
|
||||
case BleConst.STATE_DISCONNECTED: |
|
||||
stateStr = "Disconnected" |
|
||||
case BleConst.STATE_CONNECTING: |
|
||||
stateStr = "Connecting" |
|
||||
case BleConst.STATE_CONNECTED: |
|
||||
stateStr = "Connected" |
|
||||
case BleConst.STATE_DISCONNECTING: |
|
||||
stateStr = "Disconnecting" |
|
||||
case BleConst.STATE_ERROR: |
|
||||
stateStr = "Connection Error" |
|
||||
default: |
|
||||
stateStr = "Unknown State(\(state))" |
|
||||
} |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
eventSink([ |
|
||||
"type": "connectionStateChanged", |
|
||||
"state": state, |
|
||||
"stateStr": stateStr |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public func onAudioDataReceived(data: Data) { |
|
||||
if let eventSink = dataEventSink { |
|
||||
DispatchQueue.main.async { |
|
||||
eventSink([ |
|
||||
"type": "audioData", |
|
||||
"data": FlutterStandardTypedData(bytes: data) |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public func onWakeupSignalReceived() { |
|
||||
if let eventSink = statusEventSink { |
|
||||
DispatchQueue.main.async { |
|
||||
eventSink([ |
|
||||
"type": "wakeup" |
|
||||
]) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) { |
|
||||
if let eventSink = statusEventSink { |
|
||||
var resultMap = infoData |
|
||||
resultMap["type"] = "deviceInfo" |
|
||||
resultMap["result"] = infoType |
|
||||
|
|
||||
DispatchQueue.main.async { |
|
||||
eventSink(resultMap) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// MARK: - 独立的StreamHandler类 |
|
||||
@available(iOS 13.0, *) |
|
||||
class BleDataStreamHandler: NSObject, FlutterStreamHandler { |
|
||||
private weak var plugin: SwiftBleServicePlugin? |
|
||||
|
|
||||
init(_ plugin: SwiftBleServicePlugin) { |
|
||||
self.plugin = plugin |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
plugin?.dataEventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
plugin?.dataEventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@available(iOS 13.0, *) |
|
||||
class BleStatusStreamHandler: NSObject, FlutterStreamHandler { |
|
||||
private weak var plugin: SwiftBleServicePlugin? |
|
||||
|
|
||||
init(_ plugin: SwiftBleServicePlugin) { |
|
||||
self.plugin = plugin |
|
||||
} |
|
||||
|
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|
||||
plugin?.statusEventSink = events |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|
||||
plugin?.statusEventSink = nil |
|
||||
return nil |
|
||||
} |
|
||||
} |
|
||||
@ -1,239 +0,0 @@ |
|||||
import Foundation |
|
||||
import AVFoundation |
|
||||
import YbridOpus |
|
||||
|
|
||||
@available(iOS 13.0, *) |
|
||||
class SwiftOpusAudioProcessor: NSObject { |
|
||||
|
|
||||
protocol AudioDataCallback: AnyObject { |
|
||||
func onAudioDataReceived(data: Data) |
|
||||
} |
|
||||
|
|
||||
private var isDecodingActive = false |
|
||||
private weak var callback: AudioDataCallback? |
|
||||
|
|
||||
// Opus 解码器相关 - 使用 YbridOpus C API |
|
||||
private var decoder: OpaquePointer? |
|
||||
private var sampleRate: Int32 = 16000 |
|
||||
private var channels: Int32 = 1 |
|
||||
private let maxFrameSize: Int32 = 5760 // 120 ms @48 kHz 的最大值 |
|
||||
|
|
||||
override init() { |
|
||||
super.init() |
|
||||
} |
|
||||
|
|
||||
deinit { |
|
||||
cleanup() |
|
||||
} |
|
||||
|
|
||||
private func initializeOpusDecoder() -> Bool { |
|
||||
if decoder != nil { |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
var error: Int32 = 0 |
|
||||
decoder = opus_decoder_create(sampleRate, channels, &error) |
|
||||
|
|
||||
guard error == OPUS_OK, decoder != nil else { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
private func destroyOpusDecoder() { |
|
||||
if let decoder = decoder { |
|
||||
opus_decoder_destroy(decoder) |
|
||||
self.decoder = nil |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
func setCallback(_ callback: AudioDataCallback?) { |
|
||||
self.callback = callback |
|
||||
} |
|
||||
|
|
||||
func startDecoding(sampleRate: Int32 = 16000, channels: Int32 = 1, frameSize: Int32 = 640) -> Bool { |
|
||||
if isDecodingActive { |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
self.sampleRate = sampleRate |
|
||||
self.channels = channels |
|
||||
|
|
||||
guard initializeOpusDecoder() else { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
isDecodingActive = true |
|
||||
|
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
func stopDecoding() -> Bool { |
|
||||
if !isDecodingActive { |
|
||||
return false |
|
||||
} |
|
||||
|
|
||||
isDecodingActive = false |
|
||||
destroyOpusDecoder() |
|
||||
|
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
func processAudioData(_ data: Data) { |
|
||||
guard isDecodingActive, let decoder = decoder, data.count > 0 else { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 增强的数据包验证 |
|
||||
guard data.count <= 1275 else { |
|
||||
print("音频数据包太大: \(data.count) bytes,跳过处理") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 检查最小数据包大小(Opus最小包通常至少几个字节) |
|
||||
guard data.count >= 3 else { |
|
||||
print("音频数据包太小: \(data.count) bytes,跳过处理") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 简单的 Opus 头部验证(检查是否是有效的 Opus 数据包格式) |
|
||||
let firstByte = data[0] |
|
||||
// Opus packets start with a TOC (Table of Contents) byte |
|
||||
// 检查TOC字节是否在合理范围内 |
|
||||
if firstByte == 0x00 || firstByte == 0xFF { |
|
||||
print("可疑的Opus数据包头部: 0x\(String(format: "%02X", firstByte)),跳过处理") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|
||||
self?.decodeOpusPacket(data) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 简化的 Opus 解码方法,重点是容错处理 |
|
||||
private func decodeOpusPacket(_ packet: Data) { |
|
||||
guard let decoder = decoder else { return } |
|
||||
|
|
||||
let maxSamples = Int(maxFrameSize) |
|
||||
var pcmBuffer = [opus_int16](repeating: 0, count: maxSamples * Int(channels)) |
|
||||
|
|
||||
let decodedSamples = packet.withUnsafeBytes { packetPtr in |
|
||||
let uint8Ptr = packetPtr.bindMemory(to: UInt8.self).baseAddress |
|
||||
return opus_decode( |
|
||||
decoder, |
|
||||
uint8Ptr, |
|
||||
opus_int32(packet.count), |
|
||||
&pcmBuffer, |
|
||||
Int32(maxSamples), |
|
||||
0 |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
// 增强的错误处理:检查各种错误情况 |
|
||||
if decodedSamples <= 0 { |
|
||||
let errorCode = decodedSamples |
|
||||
print("Opus解码失败,错误代码: \(errorCode),重新初始化解码器") |
|
||||
|
|
||||
// 根据错误类型进行不同的处理 |
|
||||
if errorCode == -1 { // OPUS_BAD_ARG |
|
||||
print("Opus解码参数错误,执行完全重置") |
|
||||
performCompleteReset() |
|
||||
} else if errorCode == -2 { // OPUS_BUFFER_TOO_SMALL |
|
||||
print("Opus缓冲区太小") |
|
||||
resetDecoder() |
|
||||
} else if errorCode == -3 { // OPUS_INTERNAL_ERROR |
|
||||
print("Opus内部错误,执行完全重置") |
|
||||
performCompleteReset() |
|
||||
} else { |
|
||||
print("Opus其他错误,尝试简单重置") |
|
||||
resetDecoder() |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 验证解码结果的合理性 |
|
||||
guard decodedSamples <= maxFrameSize else { |
|
||||
print("Opus解码样本数超出预期: \(decodedSamples) > \(maxFrameSize)") |
|
||||
resetDecoder() |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 提取解码的音频数据 |
|
||||
let samplesPerChannel = Int(decodedSamples) |
|
||||
let totalSamples = samplesPerChannel * Int(channels) |
|
||||
|
|
||||
// 验证总样本数的合理性 |
|
||||
guard totalSamples <= pcmBuffer.count else { |
|
||||
print("计算的总样本数超出缓冲区: \(totalSamples) > \(pcmBuffer.count)") |
|
||||
resetDecoder() |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
let outputPCM = Array(pcmBuffer[0..<totalSamples]) |
|
||||
|
|
||||
if !outputPCM.isEmpty { |
|
||||
processPCMData(outputPCM) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 重置解码器的简单方法 |
|
||||
private func resetDecoder() { |
|
||||
print("执行简单解码器重置") |
|
||||
destroyOpusDecoder() |
|
||||
_ = initializeOpusDecoder() |
|
||||
} |
|
||||
|
|
||||
// 执行完全重置(包括停止和重启解码) |
|
||||
private func performCompleteReset() { |
|
||||
print("执行完全解码器重置") |
|
||||
let wasActive = isDecodingActive |
|
||||
let currentSampleRate = self.sampleRate |
|
||||
let currentChannels = self.channels |
|
||||
|
|
||||
// 完全停止解码 |
|
||||
if wasActive { |
|
||||
isDecodingActive = false |
|
||||
destroyOpusDecoder() |
|
||||
} |
|
||||
|
|
||||
// 重新初始化所有状态 |
|
||||
self.sampleRate = currentSampleRate |
|
||||
self.channels = currentChannels |
|
||||
|
|
||||
// 如果之前是活跃状态,重新启动 |
|
||||
if wasActive { |
|
||||
guard initializeOpusDecoder() else { |
|
||||
print("完全重置后重新初始化失败") |
|
||||
return |
|
||||
} |
|
||||
isDecodingActive = true |
|
||||
print("完全重置成功,解码器已重新启动") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private func processPCMData(_ pcmData: [opus_int16]) { |
|
||||
// 转换为 Data |
|
||||
let data = Data(bytes: pcmData, count: pcmData.count * MemoryLayout<opus_int16>.size) |
|
||||
|
|
||||
// 回调通知 |
|
||||
DispatchQueue.main.async { [weak self] in |
|
||||
self?.callback?.onAudioDataReceived(data: data) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
func isDecoding() -> Bool { |
|
||||
return isDecodingActive |
|
||||
} |
|
||||
|
|
||||
func cleanup() { |
|
||||
stopDecoding() |
|
||||
} |
|
||||
|
|
||||
// MARK: - 公共接口 |
|
||||
|
|
||||
/// 获取当前配置信息 |
|
||||
func getCurrentConfig() -> (sampleRate: Int32, channels: Int32) { |
|
||||
return (sampleRate, channels) |
|
||||
} |
|
||||
} |
|
||||
@ -1 +0,0 @@ |
|||||
|
|
||||
Loading…
Reference in new issue