You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2836 lines
94 KiB
2836 lines
94 KiB
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:amap_service/amap_service.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:voitrans/data/models/appconfig.dart';
|
|
import 'package:voitrans/data/models/appconfig_model.dart';
|
|
import 'package:voitrans/data/models/song_model.dart';
|
|
import 'package:voitrans/data/services/ble_manager.dart';
|
|
import 'package:voitrans/data/services/bluetooth_manager.dart';
|
|
import 'package:voitrans/data/services/qq_music.dart';
|
|
import 'package:flutter_intro/flutter_intro.dart';
|
|
import '../../../data/services/novice_guide_service.dart';
|
|
import '/core/utils/upload_oss.dart';
|
|
import '/data/models/user_Info.dart';
|
|
import '/data/services/music_manager.dart';
|
|
import '/data/services/navigation_manager.dart';
|
|
import 'package:ble_service/ble_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:agent_service/agent_service.dart';
|
|
import 'package:chat_storage/chat_storage.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import '../../../core/utils/logger.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:photo_view/photo_view.dart';
|
|
import '../../../core/utils/permission_util.dart';
|
|
import '../../../data/models/message_model.dart';
|
|
|
|
// 语音输入模式枚举
|
|
enum VoiceInputMode {
|
|
text, // 文字输入模式
|
|
pushToTalk, // 按住说话模式
|
|
phoneCall, // 打电话模式
|
|
}
|
|
|
|
class AgentController extends GetxController with WidgetsBindingObserver {
|
|
static AgentController get to => Get.find(); //单例访问模式
|
|
|
|
final GetStorage _storage = GetStorage();
|
|
// 聊天消息列表
|
|
final messages = <Message>[].obs;
|
|
final currsessionid = ''.obs;
|
|
|
|
// UI控制器
|
|
final TextEditingController textController = TextEditingController();
|
|
final ScrollController scrollController = ScrollController();
|
|
|
|
final noviceGuideService = Get.find<NoviceGuideService>();
|
|
// 确保服务已初始化
|
|
// ========== 依赖服务 ==========
|
|
final BluetoothManager _bluetoothManager = Get.find<BluetoothManager>();
|
|
final BleManager _bleManager = Get.find<BleManager>();
|
|
//音乐播放管理
|
|
final _musiceManager = Get.find<MusicManager>();
|
|
final _qqmusicManager = Get.find<QqMusicService>();
|
|
// 导航管理
|
|
final _navigationManager = Get.find<NavigationManager>();
|
|
RxBool isPlaying = false.obs;
|
|
RxInt currentId = 0.obs;
|
|
|
|
// 播放进度相关状态
|
|
RxInt currentPosition = 0.obs; // 当前播放位置(毫秒)
|
|
RxInt duration = 0.obs; // 总时长(毫秒)
|
|
RxDouble progress = 0.0.obs; // 播放进度(0.0-1.0)
|
|
|
|
// 当前播放列表管理
|
|
final currentPlaylist = <Map<String, dynamic>>[].obs; // 当前播放列表
|
|
final currentPlaylistIndex = 0.obs; // 当前播放歌曲在列表中的索引
|
|
|
|
// 各种状态标志
|
|
final isInitialized = false.obs;
|
|
final isListening = false.obs; //是否正在语音识别
|
|
final isSpeaking = false.obs; //是否正在播放tts
|
|
final isProcessing = false.obs;
|
|
// TTS启用状态
|
|
final isTtsEnabled = true.obs;
|
|
// TTS播报打断启用状态
|
|
final isTtsInterruptEnabled = false.obs;
|
|
// 是否显示工具调用
|
|
final isShowToolInterruptEnabled = false.obs;
|
|
// 图片处理状态
|
|
final isImageProcessing = false.obs;
|
|
|
|
// 输入模式控制
|
|
final isTextInputMode = true.obs; //文本输入模式vs语音输入模式
|
|
|
|
// 当前输入的文本内容
|
|
final currentText = ''.obs;
|
|
|
|
// 图片输入相关
|
|
final isImageInputActive = false.obs; // 是否处于图片输入模式
|
|
final selectedImagePath = Rx<String?>(null); // 当前选择的图片路径
|
|
|
|
// 输入状态控制 - 用于动态显示发送按钮
|
|
final hasTextInput = false.obs; // 是否有文本输入
|
|
|
|
// 全屏编辑相关状态
|
|
final isFullScreenEditMode = false.obs; // 是否处于全屏编辑模式
|
|
final shouldShowExpandButton = false.obs; // 是否显示展开按钮
|
|
|
|
// "+"号选项展开状态
|
|
final isPlusOptionsExpanded = false.obs; // 是否展开"+"号选项
|
|
|
|
// 打电话模式状态
|
|
final isPhoneCallMode = false.obs; // 是否处于打电话模式
|
|
|
|
// 按住说话状态
|
|
final isPushToTalkActive = false.obs; // 是否正在按住说话
|
|
|
|
// 按住说话模式状态 - 用于区分是否处于按住说话模式(即使当前没有按住)
|
|
//final isPushToTalkMode = false.obs; // 是否处于按住说话模式
|
|
|
|
// 蓝牙唤醒状态
|
|
final isBleWakeupActive = false.obs; // 是否正在进行蓝牙唤醒对话
|
|
|
|
// 键盘状态
|
|
final isKeyboardVisible = false.obs; // 键盘是否可见
|
|
|
|
// 流事件订阅
|
|
StreamSubscription<AgentServiceEvent>? _eventSubscription;
|
|
|
|
// 导航流事件订阅
|
|
StreamSubscription<AmapServiceEvent>? _amapeventSubscription;
|
|
|
|
// BLE事件订阅
|
|
StreamSubscription<dynamic>? _bleEventSubscription;
|
|
|
|
// 进度更新定时器
|
|
Timer? _progressTimer;
|
|
// 用于跟踪AI回复状态
|
|
bool _isNewAssistantResponse = true;
|
|
RxBool newAIReplyCompleted = true.obs;
|
|
String _currentResponseId = '';
|
|
|
|
final TAG = 'AgentController';
|
|
var AppDir = "".obs;
|
|
// 会话ID
|
|
final String agentId = 'default_agent';
|
|
|
|
// 分页加载相关状态
|
|
final isLoadingMore = false.obs; // 是否正在加载更多消息
|
|
final hasMoreMessages = true.obs; // 是否还有更多消息
|
|
final currentPage = 1.obs; // 当前页码
|
|
final pageSize = 20; // 每页消息数量
|
|
final isLoadingMoreError = false.obs; // 加载更多时是否出错
|
|
|
|
// 是否已加载聊天记录
|
|
final isHistoryLoaded = false.obs;
|
|
|
|
// 添加加载状态跟踪
|
|
final RxBool isLoadingMusic = false.obs;
|
|
final RxInt loadingMusicId = 0.obs;
|
|
|
|
// 设置加载状态的方法
|
|
void setLoadingMusic(int id, bool loading) {
|
|
loadingMusicId.value = loading ? id : 0;
|
|
isLoadingMusic.value = loading;
|
|
}
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_initAppDir();
|
|
_loadTtsPreference(); //加载TTS偏好设置
|
|
_loadTtsInterrupe(); //加载TTS播报打断偏好设置
|
|
_setupScrollListener(); //设置滚动监听器
|
|
_loadChatHistory(); //加载聊天历史记录
|
|
_subscribeToEvents(); //订阅代理服务事件
|
|
// _subscribeToBleEvents(); //订阅BLE事件
|
|
_initMusicStatus(); //初始化音乐状态
|
|
_setupMediaSessionCallbacks(); //设置媒体会话回调
|
|
_setupTextInputListener(); //设置文本输入监听器
|
|
|
|
startVoiceInput(); //开启语音输入
|
|
// 监听应用生命周期:当应用切到后台/失焦时兜底结束按住说话,防止 onPointerUp 未触发
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
// _qqmusicManager.syncCurrentPlayInfo();
|
|
}
|
|
|
|
Future<void> _initAppDir() async {
|
|
AppDir.value = (await getApplicationDocumentsDirectory()).path;
|
|
}
|
|
|
|
// 初始化音乐播放状态
|
|
Future<void> _initMusicStatus() async {
|
|
try {
|
|
await musicStatus();
|
|
Logger.d('AgentController', '音乐状态初始化完成');
|
|
} catch (e) {
|
|
Logger.e('AgentController', '音乐状态初始化失败: $e');
|
|
}
|
|
}
|
|
|
|
// 设置媒体会话回调
|
|
void _setupMediaSessionCallbacks() async {
|
|
try {
|
|
await _musiceManager.setMediaSessionCallbacks(
|
|
onPlay: onMediaSessionPlay,
|
|
onPause: onMediaSessionPause,
|
|
onNext: onMediaSessionNext,
|
|
onPrevious: onMediaSessionPrevious,
|
|
onSeekTo: onMediaSessionSeekTo,
|
|
onStop: onMediaSessionStop,
|
|
onProgress: onMediaSessionProgress,
|
|
);
|
|
Logger.d('AgentController', '媒体会话回调设置完成');
|
|
} catch (e) {
|
|
Logger.e('AgentController', '媒体会话回调设置失败: $e');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
AgentService.stopAiSteam(); //停止AI流
|
|
AgentService.stopConversation(); // 停止当前会话
|
|
AgentService.stopTts(); // 停止当前语音
|
|
textController.dispose();
|
|
scrollController.dispose();
|
|
_eventSubscription?.cancel();
|
|
_amapeventSubscription?.cancel();
|
|
_bleEventSubscription?.cancel();
|
|
Intro.of(Get.context!).dispose();
|
|
|
|
// 取消应用生命周期监听
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
|
|
super.onClose();
|
|
}
|
|
|
|
// 加载TTS偏好设置
|
|
Future<void> _loadTtsPreference() async {
|
|
try {
|
|
final storage = GetStorage();
|
|
isTtsEnabled.value = storage.read('agent_tts_enabled') ?? true;
|
|
} catch (e) {
|
|
// 如果读取失败,使用默认值
|
|
Logger.e(TAG, '加载TTS偏好设置失败: $e');
|
|
isTtsEnabled.value = true;
|
|
}
|
|
}
|
|
|
|
// 切换TTS状态
|
|
Future<void> toggleTts() async {
|
|
isTtsEnabled.toggle();
|
|
|
|
// 如果关闭TTS,停止当前语音
|
|
if (!isTtsEnabled.value && isSpeaking.value) {
|
|
await AgentService.stopTts();
|
|
}
|
|
|
|
// 保存偏好设置
|
|
try {
|
|
final storage = GetStorage();
|
|
await storage.write('agent_tts_enabled', isTtsEnabled.value);
|
|
} catch (e) {
|
|
Logger.e(TAG, '保存TTS偏好设置失败: $e');
|
|
}
|
|
}
|
|
|
|
// 加载TTS偏好设置
|
|
Future<void> _loadTtsInterrupe() async {
|
|
try {
|
|
if (!_storage.hasData("agent_tts_interrupe")) {
|
|
// 获取设备类型以确定检查策略
|
|
String deviceName = getDeviceName();
|
|
DBProduct? product = AppConfig.getproduct(deviceName);
|
|
if (deviceName != "" && product != null) {
|
|
// Logger.d('AgentController',
|
|
// 'liwe----------product $deviceName ${product.toJson()}');
|
|
isTtsInterruptEnabled.value = !product.iscontinuouschat!;
|
|
} else {
|
|
isTtsInterruptEnabled.value = false;
|
|
}
|
|
} else {
|
|
isTtsInterruptEnabled.value =
|
|
_storage.read('agent_tts_interrupe') ?? false;
|
|
}
|
|
await AgentService.isInterrupt(isTtsInterruptEnabled.value);
|
|
} catch (e) {
|
|
// 如果读取失败,使用默认值
|
|
Logger.e(TAG, '加载TTS偏好设置失败: $e');
|
|
isTtsInterruptEnabled.value = false;
|
|
}
|
|
}
|
|
|
|
/// 切换TTS播报打断状态
|
|
/// 当启用播报打断时,用户说话会自动停止当前的TTS播放
|
|
Future<void> toggleTtsInterrupt() async {
|
|
isTtsInterruptEnabled.toggle();
|
|
await _storage.write('agent_tts_interrupe', isTtsInterruptEnabled.value);
|
|
// 保存偏好设置
|
|
try {
|
|
await AgentService.isInterrupt(isTtsInterruptEnabled.value);
|
|
} catch (e) {
|
|
Logger.e(TAG, '保存TTS播报打断偏好设置失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 当启用显示工具调用效果
|
|
Future<void> toggleShowAIToolInterrupt() async {
|
|
isShowToolInterruptEnabled.toggle();
|
|
}
|
|
|
|
// 设置文本输入监听器
|
|
void _setupTextInputListener() {
|
|
textController.addListener(() {
|
|
final text = textController.text.trim();
|
|
hasTextInput.value = text.isNotEmpty;
|
|
|
|
// 检查是否需要显示展开按钮
|
|
_checkShouldShowExpandButton();
|
|
});
|
|
}
|
|
|
|
// 检查是否需要显示展开按钮
|
|
void _checkShouldShowExpandButton() {
|
|
final text = textController.text;
|
|
if (text.isEmpty) {
|
|
shouldShowExpandButton.value = false;
|
|
return;
|
|
}
|
|
|
|
// 计算文本行数
|
|
final lines = text.split('\n');
|
|
final maxLines = isImageInputActive.value ? 3 : 5;
|
|
|
|
// 简单估算:如果换行符数量超过maxLines-1,或者单行文本过长,显示展开按钮
|
|
final hasMultipleLines = lines.length > maxLines;
|
|
final hasLongLine = lines.any((line) => line.length > 50); // 估算单行字符数
|
|
|
|
shouldShowExpandButton.value = hasMultipleLines || hasLongLine;
|
|
}
|
|
|
|
// 打开全屏编辑模式
|
|
void openFullScreenEdit() {
|
|
isFullScreenEditMode.value = true;
|
|
}
|
|
|
|
// 关闭全屏编辑模式
|
|
void closeFullScreenEdit() {
|
|
isFullScreenEditMode.value = false;
|
|
}
|
|
|
|
// 从全屏编辑模式发送消息
|
|
Future<void> sendMessageFromFullScreen() async {
|
|
await sendMessage();
|
|
closeFullScreenEdit();
|
|
}
|
|
|
|
// 加载聊天历史记录
|
|
Future<void> _loadChatHistory() async {
|
|
try {
|
|
Logger.i(TAG, '正在加载聊天历史...');
|
|
//通过本地插件ChatStorage获取聊天记录
|
|
final result = await ChatStorage.getMessages(
|
|
agentId: agentId, //会话ID
|
|
page: 1, //从第一页开始
|
|
pageSize: pageSize, //每页20条记录
|
|
);
|
|
Logger.i(TAG, '加载聊天历史记录: $result');
|
|
if (result.messages.isNotEmpty) {
|
|
// 转换并添加消息到列表
|
|
final historyMessages = _convertChatMessagesToMessages(result.messages);
|
|
|
|
// 消息按时间排序,最新的消息在底部
|
|
historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
messages.assignAll(historyMessages);
|
|
|
|
// 更新分页状态
|
|
currentPage.value = 1;
|
|
hasMoreMessages.value = result.totalPages > 1;
|
|
// print(
|
|
// 'liwei-------------读取历史记录 ${AppDir.value}${historyMessages[0].imagePath}');
|
|
Logger.i(TAG,
|
|
'成功加载 ${historyMessages.length} 条聊天记录,总页数: ${result.totalPages}');
|
|
_logMessageDetails(historyMessages);
|
|
} else {
|
|
// 没有历史记录时添加欢迎消息
|
|
_addWelcomeMessage();
|
|
hasMoreMessages.value = false;
|
|
Logger.i(TAG, '没有历史记录,添加欢迎消息');
|
|
}
|
|
} catch (e) {
|
|
Logger.e(TAG, '加载聊天历史失败: $e');
|
|
_addWelcomeMessage();
|
|
hasMoreMessages.value = false;
|
|
} finally {
|
|
isHistoryLoaded.value = true;
|
|
messages.refresh();
|
|
// 使用reverse:true时不需要手动滚动到底部
|
|
}
|
|
}
|
|
|
|
// 加载更多历史消息(分页加载)
|
|
Future<void> loadMoreMessages() async {
|
|
// 防止重复加载
|
|
if (isLoadingMore.value || !hasMoreMessages.value) {
|
|
Logger.d(TAG,
|
|
'跳过加载更多:isLoadingMore=${isLoadingMore.value}, hasMoreMessages=${hasMoreMessages.value}');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
isLoadingMore.value = true;
|
|
isLoadingMoreError.value = false;
|
|
|
|
final nextPage = currentPage.value + 1;
|
|
Logger.i(TAG, '正在加载第 $nextPage 页历史消息...');
|
|
|
|
final result = await ChatStorage.getMessages(
|
|
agentId: agentId,
|
|
page: nextPage,
|
|
pageSize: pageSize,
|
|
);
|
|
|
|
if (result.messages.isNotEmpty) {
|
|
// 转换新消息
|
|
final newMessages = _convertChatMessagesToMessages(result.messages);
|
|
|
|
// 按时间排序新消息
|
|
newMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
|
|
// 添加短暂延迟,让用户感知到加载过程
|
|
await Future.delayed(const Duration(milliseconds: 300));
|
|
|
|
// 将新消息插入到现有消息列表的开头(因为是历史消息)
|
|
final updatedMessages = [...newMessages, ...messages];
|
|
messages.assignAll(updatedMessages);
|
|
|
|
// 更新分页状态
|
|
currentPage.value = nextPage;
|
|
hasMoreMessages.value = nextPage < result.totalPages;
|
|
|
|
Logger.i(TAG,
|
|
'成功加载 ${newMessages.length} 条历史消息,当前页: $nextPage/${result.totalPages}');
|
|
} else {
|
|
// 没有更多消息
|
|
hasMoreMessages.value = false;
|
|
Logger.i(TAG, '没有更多历史消息');
|
|
}
|
|
} catch (e) {
|
|
Logger.e(TAG, '加载更多消息失败: $e');
|
|
isLoadingMoreError.value = true;
|
|
} finally {
|
|
isLoadingMore.value = false;
|
|
}
|
|
}
|
|
|
|
// 转换ChatMessage到Message的通用方法
|
|
List<Message> _convertChatMessagesToMessages(List<ChatMessage> chatMessages) {
|
|
return chatMessages.map((msg) {
|
|
final meta = msg.metadata != null && msg.metadata != ""
|
|
? jsonDecode(msg.metadata!)
|
|
: null; // 如果为空,使用空对象
|
|
|
|
// 从元数据中提取图片信息
|
|
final hasImage = meta != null && meta.containsKey('hasImage')
|
|
? meta['hasImage'] as bool
|
|
: false;
|
|
final imagePath = meta != null && meta.containsKey('imagePath')
|
|
? meta['imagePath'] as String?
|
|
: null;
|
|
|
|
return Message(
|
|
sessionid: msg.sessionId, // 会话ID
|
|
isUser: msg.sender == 'user',
|
|
text: msg.message,
|
|
meta: meta,
|
|
timestamp: DateTime.parse(msg.timestamp),
|
|
hasImage: hasImage,
|
|
imagePath: imagePath,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
// 打印消息详情的通用方法
|
|
void _logMessageDetails(List<Message> messageList) {
|
|
Logger.d(TAG, '===== 历史消息详情开始 =====');
|
|
for (int i = 0; i < messageList.length; i++) {
|
|
final msg = messageList[i];
|
|
Logger.d(
|
|
TAG,
|
|
'消息[$i]: {'
|
|
'isUser: ${msg.isUser}, '
|
|
'text: "${msg.text.length > 50 ? msg.text.substring(0, 50) + "..." : msg.text}", '
|
|
'time: ${msg.timestamp}, '
|
|
'hasImage: ${msg.hasImage}, '
|
|
'imagePath: ${msg.imagePath ?? "无"}, '
|
|
'meta: ${msg.meta != null ? "有" : "无"}'
|
|
'}');
|
|
}
|
|
Logger.d(TAG, '===== 历史消息详情结束 ==lishi===');
|
|
}
|
|
|
|
// 设置滚动监听器
|
|
void _setupScrollListener() {
|
|
scrollController.addListener(() {
|
|
// 检查是否滚动到顶部(因为使用了reverse: true,顶部实际是最大滚动位置)
|
|
if (scrollController.position.pixels >=
|
|
scrollController.position.maxScrollExtent - 100) {
|
|
// 当距离顶部100像素时触发加载更多
|
|
if (hasMoreMessages.value && !isLoadingMore.value) {
|
|
Logger.d(TAG, '触发加载更多历史消息');
|
|
loadMoreMessages();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 手动触发加载更多(用于测试或手动调用)
|
|
void triggerLoadMore() {
|
|
if (hasMoreMessages.value && !isLoadingMore.value) {
|
|
Logger.i(TAG, '手动触发加载更多历史消息');
|
|
loadMoreMessages();
|
|
} else {
|
|
Logger.w(TAG,
|
|
'无法加载更多:hasMoreMessages=${hasMoreMessages.value}, isLoadingMore=${isLoadingMore.value}');
|
|
}
|
|
}
|
|
|
|
// 重置分页状态(用于刷新聊天记录)
|
|
void resetPagination() {
|
|
currentPage.value = 1;
|
|
hasMoreMessages.value = true;
|
|
isLoadingMore.value = false;
|
|
isLoadingMoreError.value = false;
|
|
Logger.i(TAG, '分页状态已重置');
|
|
}
|
|
|
|
// 添加初始欢迎消息
|
|
void _addWelcomeMessage() {
|
|
messages.add(Message(
|
|
sessionid: "", // 会话ID
|
|
isUser: false,
|
|
text: 'welcomeMessage'.tr, // 你好!我是**小言**,我能帮你:
|
|
));
|
|
}
|
|
|
|
// 订阅代理服务事件
|
|
void _subscribeToEvents() {
|
|
_eventSubscription = AgentService.events.listen((event) {
|
|
switch (event.type) {
|
|
case AgentServiceEventType.recognitionStarted:
|
|
isListening.value = true;
|
|
//isTextInputMode.value = false;
|
|
Logger.i(TAG, '订阅代理服务事件识别开始');
|
|
// isTextInputMode.refresh();
|
|
// 只标记识别开始,不添加消息
|
|
Logger.i(TAG, '识别开始');
|
|
|
|
break;
|
|
case AgentServiceEventType.recognitionStopped:
|
|
isListening.value = false;
|
|
|
|
// 处理蓝牙唤醒结束
|
|
if (isBleWakeupActive.value) {
|
|
isBleWakeupActive.value = false;
|
|
// 蓝牙唤醒结束后,恢复之前的输入模式
|
|
_restorePreviousInputMode();
|
|
Logger.i(TAG, '蓝牙唤醒对话结束,恢复之前的输入模式');
|
|
} else {
|
|
// 只有在非按住说话模式且非打电话模式下才自动切换到文字模式
|
|
// 按住说话模式下应该保持在语音模式,等待下次按住
|
|
// if (!isPhoneCallMode.value && !isPushToTalkActive.value) {
|
|
// Logger.i(TAG, '切换文本输入模式');
|
|
// isTextInputMode.value = false;
|
|
// }
|
|
}
|
|
|
|
Logger.i(TAG, '识别停止');
|
|
break;
|
|
|
|
case AgentServiceEventType.recognizing:
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
final text = event.data['text'] ?? '';
|
|
currentText.value = text; // 保留当前文本,以便其他地方使用
|
|
Logger.i(TAG, '[$sessionid]识别中间结果: $text');
|
|
if (text.isNotEmpty) {
|
|
// 查找是否有正在识别中的消息
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && msg.isUser);
|
|
|
|
if (index >= 0) {
|
|
// 更新已有的识别中消息
|
|
messages[index] = Message(
|
|
sessionid: sessionid, // 会话ID
|
|
isUser: true,
|
|
text: text,
|
|
isRecognizing: true,
|
|
timestamp: messages[index].timestamp,
|
|
);
|
|
} else {
|
|
currsessionid.value = event.data['sessionid'] ?? '';
|
|
// 没有识别中的消息,创建新消息
|
|
messages.add(Message(
|
|
sessionid: sessionid, // 会话ID
|
|
isUser: true,
|
|
text: text,
|
|
isRecognizing: true,
|
|
));
|
|
}
|
|
// messages.refresh();
|
|
}
|
|
break;
|
|
|
|
case AgentServiceEventType.recognitionResult:
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
final text = event.data['text'] ?? '';
|
|
Logger.i(TAG, '[$sessionid]识别最终结果: $text');
|
|
|
|
if (currsessionid.value != "" && currsessionid.value != sessionid) {
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == currsessionid.value && msg.isUser);
|
|
//出现了为识别结束的消息
|
|
if (index >= 0) {
|
|
//异除去当前会话的识别中消息
|
|
messages.removeAt(index);
|
|
}
|
|
}
|
|
currsessionid.value = sessionid;
|
|
// 查找是否有正在识别中的消息
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && msg.isUser);
|
|
if (text.isNotEmpty) {
|
|
if (index >= 0) {
|
|
// 将识别中的消息转换为最终消息
|
|
messages[index] = Message(
|
|
sessionid: sessionid,
|
|
isUser: true,
|
|
text: text,
|
|
isRecognizing: false,
|
|
timestamp: messages[index].timestamp,
|
|
functionCalls: messages[index].functionCalls,
|
|
isToolCallsExpanded: messages[index].isToolCallsExpanded,
|
|
);
|
|
// messages.refresh();
|
|
} else {
|
|
// 如果没有找到识别中的消息,添加新消息
|
|
messages.add(Message(
|
|
sessionid: sessionid,
|
|
isUser: true,
|
|
text: text,
|
|
isRecognizing: false));
|
|
}
|
|
|
|
isProcessing.value = true;
|
|
// 标记为新的AI回复
|
|
_isNewAssistantResponse = true;
|
|
// 使用reverse:true时通常不需要手动滚动
|
|
} else if (index >= 0) {
|
|
// 如果识别结果为空,移除临时的识别消息
|
|
messages.removeAt(index);
|
|
// messages.refresh();
|
|
}
|
|
break;
|
|
//识别取消(网络波动会触发)
|
|
case AgentServiceEventType.recognitionCanceled:
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
// 查找是否有正在识别中的消息
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && msg.isUser);
|
|
currsessionid.value = '';
|
|
if (currentText.value.isNotEmpty) {
|
|
if (index >= 0) {
|
|
// 将识别中的消息转换为最终消息
|
|
messages[index] = Message(
|
|
sessionid: sessionid, // 会话ID
|
|
isUser: true,
|
|
text: currentText.value,
|
|
isRecognizing: false,
|
|
timestamp: messages[index].timestamp,
|
|
functionCalls: messages[index].functionCalls,
|
|
isToolCallsExpanded: messages[index].isToolCallsExpanded,
|
|
);
|
|
} else {
|
|
// 如果没有找到识别中的消息,添加新消息
|
|
messages.add(Message(
|
|
sessionid: sessionid,
|
|
isUser: true,
|
|
text: currentText.value,
|
|
isRecognizing: false,
|
|
));
|
|
}
|
|
|
|
isProcessing.value = true;
|
|
// 标记为新的AI回复
|
|
_isNewAssistantResponse = true;
|
|
// 使用reverse:true时通常不需要手动滚动
|
|
} else if (index >= 0) {
|
|
// 如果识别结果为空,移除临时的识别消息
|
|
messages.removeAt(index);
|
|
}
|
|
messages.refresh();
|
|
break;
|
|
case AgentServiceEventType.ttsStarted:
|
|
isSpeaking.value = true;
|
|
print('TTS开始播放');
|
|
break;
|
|
|
|
case AgentServiceEventType.ttsCompleted:
|
|
case AgentServiceEventType.ttsStopped:
|
|
case AgentServiceEventType.ttsCanceled:
|
|
isSpeaking.value = false;
|
|
|
|
Logger.i(TAG, 'TTS停止播放, $event.type');
|
|
break;
|
|
|
|
case AgentServiceEventType.playbackStarted:
|
|
isSpeaking.value = true;
|
|
Logger.i(TAG, 'TTS播放开始');
|
|
break;
|
|
|
|
case AgentServiceEventType.playbackCompleted:
|
|
isSpeaking.value = false;
|
|
|
|
Logger.i(TAG, 'TTS播放完成');
|
|
break;
|
|
|
|
case AgentServiceEventType.imageProcessing:
|
|
isImageProcessing.value = true;
|
|
break;
|
|
|
|
case AgentServiceEventType.imageReady:
|
|
// 图片准备就绪,但AI还没有开始处理,保持processing状态
|
|
break;
|
|
|
|
case AgentServiceEventType.assistantToken:
|
|
if (!isProcessing.value) isProcessing.value = true;
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
final token = event.data['token'] ?? '';
|
|
// Logger.i(TAG, 'AI回复Token: $token, responseId: $responseId');
|
|
if (token.isNotEmpty) {
|
|
// 如果是新的回复或者响应ID改变,创建新消息
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && !msg.isUser);
|
|
if (index == -1) {
|
|
messages.add(
|
|
Message(sessionid: sessionid, isUser: false, text: token));
|
|
_isNewAssistantResponse = false;
|
|
} else {
|
|
messages[index] = Message(
|
|
sessionid: sessionid,
|
|
isUser: false,
|
|
text: messages[index].text + token,
|
|
meta: messages[index].meta,
|
|
timestamp: messages[index].timestamp,
|
|
functionCalls: messages[index].functionCalls,
|
|
isToolCallsExpanded: messages[index].isToolCallsExpanded,
|
|
);
|
|
}
|
|
// 使用reverse:true时通常不需要手动滚动
|
|
}
|
|
break;
|
|
|
|
case AgentServiceEventType.assistantResponse:
|
|
isProcessing.value = false;
|
|
isImageProcessing.value = false;
|
|
// 标记当前回复完成
|
|
_isNewAssistantResponse = true;
|
|
newAIReplyCompleted.value = true;
|
|
break;
|
|
|
|
case AgentServiceEventType.error:
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
final message = event.data['message'] ?? '';
|
|
|
|
// //验证错误码
|
|
if (event.data['code'] == 1000) {
|
|
isListening.value = false;
|
|
isSpeaking.value = false;
|
|
isProcessing.value = false;
|
|
isImageProcessing.value = false;
|
|
if (sessionid != "") {
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && !msg.isUser);
|
|
if (index >= 0) {
|
|
messages.removeAt(index);
|
|
messages.refresh();
|
|
}
|
|
}
|
|
Get.snackbar('error'.tr, '${event.data['message']}');
|
|
}
|
|
Logger.e(TAG, '代理服务错误: ${event.data['message']}');
|
|
break;
|
|
|
|
case AgentServiceEventType.autoStop:
|
|
isListening.value = false;
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
// 移除临时的识别消息
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && msg.isUser);
|
|
if (index >= 0) {
|
|
// 只有当消息为空或文本太短时才移除
|
|
if (messages[index].text.trim().length < 2) {
|
|
messages.removeAt(index);
|
|
// messages.refresh();
|
|
} else {
|
|
// 否则转换为正式消息
|
|
messages[index] = Message(
|
|
sessionid: sessionid,
|
|
isUser: true,
|
|
text: messages[index].text,
|
|
timestamp: messages[index].timestamp,
|
|
functionCalls: messages[index].functionCalls,
|
|
isToolCallsExpanded: messages[index].isToolCallsExpanded,
|
|
);
|
|
// messages.refresh();
|
|
|
|
// 发送此消息到AI处理
|
|
isProcessing.value = true;
|
|
_isNewAssistantResponse = true;
|
|
_handleAutoStop(sessionid, index);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case AgentServiceEventType.functionCall:
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
final functionName = event.data['name'] ?? '';
|
|
final arguments = event.data['arguments'] ?? '';
|
|
// debugPrint('Fultter 开始调用 mcp: $functionName, $arguments');
|
|
if (functionName == 'exit_interaction') {
|
|
if (isPhoneCallMode.value) {
|
|
endPhoneCall(); //退出语音交互,同时退出打电话模式
|
|
}
|
|
}
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && !msg.isUser);
|
|
if (index == -1) {
|
|
messages.add(Message(
|
|
sessionid: sessionid,
|
|
isUser: false,
|
|
text: '',
|
|
functionCalls: {
|
|
functionName: {'arguments': arguments}
|
|
}));
|
|
_isNewAssistantResponse = false;
|
|
} else {
|
|
// 否则更新最后一条消息
|
|
final lastIndex = messages.length - 1;
|
|
|
|
if (lastIndex >= 0 && !messages[lastIndex].isUser) {
|
|
messages[lastIndex] = Message(
|
|
sessionid: sessionid,
|
|
isUser: false,
|
|
text: messages[lastIndex].text,
|
|
meta: messages[lastIndex].meta,
|
|
timestamp: messages[lastIndex].timestamp,
|
|
functionCalls: messages[lastIndex].functionCalls ?? {}
|
|
..addAll({
|
|
functionName: {'arguments': arguments}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case AgentServiceEventType.functionCallResult:
|
|
final sessionid = event.data['sessionid'] ?? '';
|
|
final function_name = event.data['function_name'] ?? '';
|
|
final function_result = event.data['result'] ?? '';
|
|
final metaStr = event.data['meta'] ?? '';
|
|
final index = messages.lastIndexWhere(
|
|
(msg) => msg.sessionid == sessionid && !msg.isUser);
|
|
if (index != -1) {
|
|
// 获取当前的 functionCalls,如果为空则创建新的 Map
|
|
final functionCalls = Map<String, Map<String, dynamic>>.from(
|
|
messages[index].functionCalls?.map((key, value) => MapEntry(
|
|
key, Map<String, dynamic>.from(value as Map))) ??
|
|
{});
|
|
|
|
// 更新对应工具的调用结果
|
|
if (function_name.isNotEmpty) {
|
|
// 如果该工具已存在,更新其结果;如果不存在,创建新的条目
|
|
if (functionCalls.containsKey(function_name)) {
|
|
functionCalls[function_name]!['result'] = function_result;
|
|
} else {
|
|
functionCalls[function_name] = {
|
|
'result': function_result,
|
|
};
|
|
}
|
|
}
|
|
|
|
messages[index] = Message(
|
|
sessionid: sessionid,
|
|
isUser: false,
|
|
text: messages[index].text,
|
|
meta: messages[index].meta,
|
|
timestamp: messages[index].timestamp,
|
|
functionCalls: functionCalls,
|
|
isToolCallsExpanded: messages[index].isToolCallsExpanded,
|
|
);
|
|
}
|
|
Logger.i(TAG, 'Flutter 结束调用 $sessionid mcp: $metaStr');
|
|
|
|
if (metaStr.isNotEmpty) {
|
|
Map<String, dynamic> metaResult = {};
|
|
metaResult = jsonDecode(metaStr) as Map<String, dynamic>;
|
|
if (metaResult.isNotEmpty) {
|
|
var notreply = false;
|
|
if (metaResult.containsKey("notreply")) {
|
|
// 如果有导航卡片,直接添加
|
|
notreply = metaResult["notreply"] as bool;
|
|
}
|
|
if (notreply) {
|
|
Logger.i(TAG, 'Flutter 结束调用 不回复');
|
|
//不回复直接返回
|
|
break;
|
|
}
|
|
|
|
Logger.i(TAG, 'Flutter 结束调用 卡片数据 $index $metaResult');
|
|
// 判断是否为新的回复或响应ID是否改变
|
|
if (index == -1) {
|
|
// 添加新消息
|
|
messages.add(Message(
|
|
sessionid: sessionid,
|
|
isUser: false,
|
|
text: "",
|
|
meta: metaResult));
|
|
_isNewAssistantResponse = false;
|
|
} else {
|
|
// 更新最后一条消息
|
|
messages[index] = Message(
|
|
sessionid: sessionid,
|
|
isUser: false,
|
|
text: messages[index].text,
|
|
meta: metaResult,
|
|
timestamp: messages[index].timestamp,
|
|
functionCalls: messages[index].functionCalls,
|
|
isToolCallsExpanded: messages[index].isToolCallsExpanded,
|
|
);
|
|
}
|
|
messages.refresh();
|
|
if (metaResult.containsKey("maps_route_navigation")) {
|
|
final maps_route_navigation =
|
|
metaResult["maps_route_navigation"];
|
|
if (maps_route_navigation is Map<String, dynamic>) {
|
|
final card_navigation =
|
|
maps_route_navigation["card_navigation"];
|
|
final isinternal = metaResult["isinternal"] ?? false;
|
|
if (card_navigation is Map<String, dynamic>) {
|
|
//导航
|
|
final start = card_navigation["start"] as String;
|
|
final destination =
|
|
card_navigation["destination"] as String;
|
|
final end_longitude =
|
|
card_navigation["end_longitude"] as String;
|
|
final end_latitude =
|
|
card_navigation["end_latitude"] as String;
|
|
// End_longitude string `json:"end_longitude"` //目标点经度
|
|
// End_latitude string `json:"end_latitude"` //目标点纬度
|
|
final items = card_navigation['items'];
|
|
if (items != null) {
|
|
Future.delayed(Duration(seconds: 2), () {
|
|
// Logger.i(TAG, 'liwei-------- 打开导航页面: $start $end ');
|
|
// 2秒后执行的代码
|
|
final navigationMode =
|
|
AppConfig.env('APP_NAVIGATION_MODE') ??
|
|
'external_auto';
|
|
if (navigationMode == 'internal_gaode' || isinternal) {
|
|
_navigationManager.open(
|
|
start, "$end_longitude,$end_latitude", "driving");
|
|
}
|
|
// openNavigation(false, start, destination, end_latitude,
|
|
// end_longitude, "");
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if (metaResult
|
|
.containsKey("maps_google_route_navigation")) {
|
|
//导航
|
|
final maps_google_route_navigation_raw =
|
|
metaResult["maps_google_route_navigation"];
|
|
|
|
// 安全的类型检查
|
|
if (maps_google_route_navigation_raw is Map<String, dynamic>) {
|
|
final maps_google_route_navigation =
|
|
maps_google_route_navigation_raw;
|
|
final data = maps_google_route_navigation["data"];
|
|
if (data != null) {
|
|
final end = data["destination"] as String;
|
|
final mode = data["items"][0]["ntype"] as String;
|
|
Future.delayed(Duration(seconds: 1), () {
|
|
// Logger.i(TAG, 'liwei-------- 打开google地图: $end ');
|
|
openGoogleMap(end, mode);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// 其他函数调用
|
|
Logger.i(TAG, 'Flutter 结束调用 解析失败 $metaStr');
|
|
}
|
|
}
|
|
if (function_name.contains("music")) {
|
|
Future.delayed(Duration(seconds: 1), () {
|
|
_qqmusicManager.syncCurrentPlayInfo(isOpenPlay: false);
|
|
musicStatus();
|
|
});
|
|
// }
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
//后台导航事件同步
|
|
_amapeventSubscription = _navigationManager.events().listen((event) {
|
|
switch (event.type) {
|
|
case AmapServiceEventType.starte:
|
|
Logger.i(TAG, '导航通知 started!');
|
|
break;
|
|
case AmapServiceEventType.stope:
|
|
Logger.i(TAG, '导航通知 stoped!');
|
|
break;
|
|
case AmapServiceEventType.update:
|
|
Logger.i(TAG, '导航通知 update! $event');
|
|
Message? result;
|
|
|
|
try {
|
|
result = messages.lastWhere(
|
|
(msg) =>
|
|
!msg.isUser &&
|
|
msg.meta?.containsKey("card_navigation") == true,
|
|
);
|
|
} catch (e) {
|
|
result = null;
|
|
}
|
|
|
|
if (result != null) {
|
|
int index = messages.indexOf(result);
|
|
if (index != -1) {
|
|
// ✅ 创建 meta 的深拷贝
|
|
final newMeta = Map<String, dynamic>.from(result.meta ?? {});
|
|
newMeta["card_navigation"]["navigation"] = event.data;
|
|
|
|
// ✅ 创建新的 Message 替换原来的
|
|
messages[index] = Message(
|
|
sessionid: result.sessionid,
|
|
isUser: result.isUser,
|
|
text: result.text,
|
|
meta: newMeta,
|
|
timestamp: result.timestamp,
|
|
);
|
|
messages.refresh();
|
|
Logger.i(
|
|
TAG, '更新导航卡片! ${newMeta["card_navigation"]["navigation"]}');
|
|
}
|
|
}
|
|
break;
|
|
case AmapServiceEventType.error:
|
|
Logger.i(TAG, '导航通知 error!');
|
|
break;
|
|
case AmapServiceEventType.unknown:
|
|
Logger.i(TAG, '导航通知 unknown!');
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 订阅BLE事件
|
|
void _subscribeToBleEvents() {
|
|
try {
|
|
_bleEventSubscription = BleService().onStatusUpdated.listen((event) {
|
|
if (event is Map && event['type'] == 'wakeup') {
|
|
_handleBleWakeup();
|
|
}
|
|
});
|
|
Logger.i(TAG, 'BLE事件监听已设置');
|
|
} catch (e) {
|
|
Logger.e(TAG, 'BLE事件监听设置失败: $e');
|
|
}
|
|
}
|
|
|
|
// 处理BLE唤醒信号
|
|
void _handleBleWakeup() {
|
|
Logger.i(TAG, '🎧 收到BLE唤醒信号');
|
|
|
|
// 检查是否可以启动蓝牙唤醒
|
|
if (!_canStartBleWakeup()) {
|
|
Logger.w(TAG, '当前状态不允许BLE唤醒');
|
|
return;
|
|
}
|
|
|
|
// 保存当前输入模式
|
|
_saveCurrentInputMode();
|
|
|
|
// 设置蓝牙唤醒状态
|
|
isBleWakeupActive.value = true;
|
|
|
|
// 注意:这里不需要调用startVoiceInput(),因为原生层已经启动了语音识别
|
|
// 我们只需要同步Flutter层的状态
|
|
//isTextInputMode.value = false;
|
|
|
|
Logger.i(TAG, 'BLE唤醒状态已设置,等待原生层语音识别结果');
|
|
}
|
|
|
|
// 检查是否可以启动BLE唤醒
|
|
bool _canStartBleWakeup() {
|
|
// 如果已经在BLE唤醒状态,不允许重复启动
|
|
if (isBleWakeupActive.value) {
|
|
Logger.w(TAG, '已在BLE唤醒状态,忽略重复唤醒');
|
|
return false;
|
|
}
|
|
|
|
// 如果正在打电话模式,不允许BLE唤醒
|
|
if (isPhoneCallMode.value) {
|
|
Logger.w(TAG, '正在打电话模式,忽略BLE唤醒');
|
|
return false;
|
|
}
|
|
|
|
// 如果正在处理TTS,暂时不允许启动
|
|
if (isSpeaking.value) {
|
|
Logger.w(TAG, '正在播放TTS,延迟BLE唤醒');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// 发送消息(包括所有支持的消息类型)
|
|
//场景1:用户在文本输入框中输入文本,点击发送按钮,调用sendMessage()方法。
|
|
//场景2:当用户选择图片后在文本框输入内容并发送时,调用sendMessage()转发给sendImageMessage()。
|
|
Future<void> sendMessage() async {
|
|
// 收回键盘
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
// 1. 处理图片+文本的混合输入
|
|
if (isImageInputActive.value && selectedImagePath.value != null) {
|
|
final text = textController.text.trim();
|
|
final imagePath = selectedImagePath.value!; // 使用!强制断言非空
|
|
await sendImageMessage(imagePath, text: text);
|
|
|
|
// 清除图片输入状态
|
|
clearImageInput();
|
|
return;
|
|
}
|
|
|
|
// 2. 处理纯文本消息
|
|
final text = textController.text.trim();
|
|
if (text.isEmpty) return;
|
|
|
|
// 使用reverse:true时通常不需要手动滚动
|
|
// 3. 调用AI服务处理
|
|
try {
|
|
isProcessing.value = true;
|
|
// 标记为新的AI回复
|
|
_isNewAssistantResponse = true;
|
|
newAIReplyCompleted.value = false;
|
|
currsessionid.value = const Uuid().v4();
|
|
final sessionid = currsessionid.value;
|
|
// 根据TTS状态决定是否朗读
|
|
AgentService.processTextInput(sessionid, text,
|
|
speakResponse: isTtsEnabled.value);
|
|
final message = Message(sessionid: sessionid, isUser: true, text: text);
|
|
Logger.i(TAG, '发送AI消息:$sessionid $text');
|
|
messages.add(message);
|
|
textController.clear();
|
|
} catch (e) {
|
|
Logger.e(TAG, '发送文本消息失败: $e');
|
|
isProcessing.value = false;
|
|
}
|
|
}
|
|
|
|
// 发送图片消息
|
|
Future<void> sendImageMessage(String imagePath, {String? text}) async {
|
|
if (imagePath.isEmpty || !File(imagePath).existsSync()) {
|
|
Logger.e(TAG, '图片不存在: $imagePath');
|
|
return;
|
|
}
|
|
|
|
// 复制图片到应用永久存储目录
|
|
// 复制图片到应用永久存储目录
|
|
final imagePathResult = await _copyImageToPermanentStorage(imagePath);
|
|
final permanentImagePath = imagePathResult['fullPath']!;
|
|
final relativeImagePath = imagePathResult['relativePath']!;
|
|
var ImageUrl = await UploadOss.upload(
|
|
filepath: permanentImagePath,
|
|
rootDir: 'User/${User.instance.uid}/ChatImages');
|
|
|
|
// 显示的文本,如果没有提供则使用默认值
|
|
final displayText = text?.isNotEmpty == true ? text! : '[图片]';
|
|
|
|
currsessionid.value = const Uuid().v4();
|
|
final sessionid = currsessionid.value;
|
|
Logger.i(TAG,
|
|
'发送AI图片消息:$sessionid $displayText $permanentImagePath URL: $ImageUrl');
|
|
// 添加用户消息
|
|
final message = Message(
|
|
sessionid: sessionid,
|
|
isUser: true,
|
|
text: displayText,
|
|
hasImage: true,
|
|
imagePath: relativeImagePath,
|
|
);
|
|
messages.add(message);
|
|
|
|
// 清空文本输入
|
|
textController.clear();
|
|
|
|
// 使用reverse:true时通常不需要手动滚动
|
|
|
|
try {
|
|
isProcessing.value = true;
|
|
isImageProcessing.value = true;
|
|
// 标记为新的AI回复
|
|
_isNewAssistantResponse = true;
|
|
|
|
// 保存消息到持久化存储
|
|
//await _saveMessageToStorage(message);
|
|
currsessionid.value = const Uuid().v4();
|
|
final sessionid = currsessionid.value;
|
|
// 调用Agent Service处理图片,考虑TTS状态
|
|
await AgentService.processImageInput(
|
|
sessionid,
|
|
relativeImagePath,
|
|
ImageUrl,
|
|
text: text ?? '',
|
|
speakResponse: isTtsEnabled.value,
|
|
);
|
|
} catch (e) {
|
|
Logger.e(TAG, '处理图片失败: $e');
|
|
isProcessing.value = false;
|
|
isImageProcessing.value = false;
|
|
}
|
|
}
|
|
|
|
// 将图片复制到应用的永久存储目录
|
|
Future<Map<String, String>> _copyImageToPermanentStorage(
|
|
String sourcePath) async {
|
|
try {
|
|
final File sourceFile = File(sourcePath);
|
|
if (!await sourceFile.exists()) {
|
|
Logger.e(TAG, '源图片文件不存在: $sourcePath');
|
|
return {
|
|
'fullPath': sourcePath,
|
|
'relativePath': sourcePath
|
|
}; // 如果源文件不存在,返回原路径
|
|
}
|
|
|
|
// 创建应用文档目录下的chatImages文件夹
|
|
final appDir = await getApplicationDocumentsDirectory();
|
|
final imagesDir = Directory('${appDir.path}/chatImages');
|
|
if (!await imagesDir.exists()) {
|
|
await imagesDir.create(recursive: true);
|
|
}
|
|
|
|
// 生成唯一文件名
|
|
final fileName =
|
|
'${DateTime.now().millisecondsSinceEpoch}_${sourcePath.split('/').last}';
|
|
final targetPath = '${imagesDir.path}/$fileName';
|
|
final relativePath = '/chatImages/$fileName';
|
|
|
|
// 复制文件
|
|
await sourceFile.copy(targetPath);
|
|
Logger.i(TAG, '图片已复制到永久存储: $targetPath');
|
|
|
|
return {'fullPath': targetPath, 'relativePath': relativePath};
|
|
} catch (e) {
|
|
Logger.e(TAG, '复制图片到永久存储失败: $e');
|
|
return {'fullPath': sourcePath, 'relativePath': sourcePath}; // 出错时返回原路径
|
|
}
|
|
}
|
|
|
|
// 切换输入模式(改造为按住说话模式)
|
|
void toggleInputMode() async {
|
|
// 如果当前在图片输入模式,先清除
|
|
if (isImageInputActive.value) {
|
|
clearImageInput();
|
|
return;
|
|
}
|
|
|
|
// 如果要切换到语音模式,需要先检查麦克风权限
|
|
if (isTextInputMode.value) {
|
|
// 使用 PermissionUtil 检查麦克风权限
|
|
final hasPermission = await PermissionUtil.instance.requestPermission(
|
|
// context: Get.context!,
|
|
permissionType: Permission.microphone,
|
|
permissionName: 'microphonePermission'.tr, // 麦克风
|
|
explanationText: 'voiceRecognitionPermissionText'.tr, // 语音识别功能需要访问麦克风。
|
|
permanentDenialText:
|
|
'permissionDeniedText'.tr, // 您已永久拒绝麦克风权限,请前往系统设置开启权限。
|
|
settingsButtonText: 'goToSettings'.tr, // 去设置
|
|
cancelButtonText: 'cancel'.tr, // 取消
|
|
);
|
|
if (!hasPermission) {
|
|
return; // 权限未获取,保持文本模式
|
|
}
|
|
}
|
|
|
|
isTextInputMode.toggle();
|
|
|
|
// 切换到语音模式时,启用按住说话模式
|
|
// 切换到文本模式时,禁用按住说话模式并停止任何正在进行的语音输入
|
|
if (isTextInputMode.value) {
|
|
// 切换到文本模式
|
|
isTextInputMode.value = true;
|
|
// stopVoiceInput();
|
|
} else {
|
|
// 切换到语音模式,启用按住说话模式
|
|
isTextInputMode.value = false;
|
|
}
|
|
}
|
|
|
|
// 开始语音输入
|
|
Future<void> startVoiceInput() async {
|
|
if (isListening.value) return;
|
|
Logger.i(TAG, '开始语音输入'); // 开始语音输入
|
|
// final hasPermission = await PermissionUtil.instance.requestPermission(
|
|
// // context: Get.context!,
|
|
// permissionType: Permission.microphone,
|
|
// permissionName: 'microphonePermission'.tr, // 麦克风
|
|
// explanationText: 'voiceTranslationPermissionText'.tr, // 语音识别与翻译功能需要访问麦克风。
|
|
// permanentDenialText:
|
|
// 'permissionDeniedText'.tr, // 您已永久拒绝麦克风权限,请前往系统设置开启权限。
|
|
// settingsButtonText: 'goToSettings'.tr, // 去设置
|
|
// cancelButtonText: 'cancel'.tr, // 取消
|
|
// );
|
|
// if (!hasPermission) {
|
|
// isTextInputMode.value = true; // 如果没有权限,切回文本模式
|
|
// return;
|
|
// }
|
|
|
|
try {
|
|
await AgentService.stopConversation();
|
|
|
|
await AgentService.recognizeCallback();
|
|
if (_bluetoothManager.currentDeviceRx.value != null) {
|
|
// await AgentService.restoreOriginalAudioState();
|
|
} else {
|
|
// await AgentService.disableBluetoothAudio();
|
|
}
|
|
} catch (e) {
|
|
Logger.e(TAG, '启动识别器失败: $e');
|
|
}
|
|
}
|
|
|
|
// 停止语音输入
|
|
Future<void> stopVoiceInput() async {
|
|
if (!isListening.value) return;
|
|
|
|
try {
|
|
await AgentService.stopConversation();
|
|
} catch (e) {
|
|
Logger.e(TAG, '停止语音输入失败: $e');
|
|
}
|
|
}
|
|
|
|
// 清除聊天历史
|
|
Future<void> clearChat() async {
|
|
try {
|
|
await AgentService.clearChatHistory();
|
|
|
|
// 清空本地消息列表
|
|
messages.clear();
|
|
|
|
// 添加欢迎消息
|
|
_addWelcomeMessage();
|
|
|
|
// 重置回复状态
|
|
_isNewAssistantResponse = true;
|
|
} catch (e) {
|
|
Logger.e(TAG, '清除聊天历史失败: $e');
|
|
}
|
|
}
|
|
|
|
// 从相册选择图片
|
|
Future<void> pickImage() async {
|
|
try {
|
|
final ImagePicker picker = ImagePicker();
|
|
final XFile? image = await picker.pickImage(
|
|
source: ImageSource.gallery,
|
|
imageQuality: 80, // 设置图片质量
|
|
maxWidth: 1024, // 限制最大宽度
|
|
maxHeight: 1024, // 限制最大高度
|
|
);
|
|
|
|
if (image != null) {
|
|
// 设置已选择的图片
|
|
selectedImagePath.value = image.path;
|
|
// 激活图片输入模式
|
|
isImageInputActive.value = true;
|
|
}
|
|
} catch (e) {
|
|
Logger.e(TAG, '选择图片失败: $e');
|
|
}
|
|
}
|
|
|
|
// 拍照获取图片
|
|
Future<void> takePhoto() async {
|
|
try {
|
|
final ImagePicker picker = ImagePicker();
|
|
final XFile? photo = await picker.pickImage(
|
|
source: ImageSource.camera,
|
|
imageQuality: 80, // 设置图片质量
|
|
maxWidth: 1024, // 限制最大宽度
|
|
maxHeight: 1024, // 限制最大高度
|
|
);
|
|
|
|
if (photo != null) {
|
|
// 设置已选择的图片
|
|
selectedImagePath.value = photo.path;
|
|
// 激活图片输入模式
|
|
isImageInputActive.value = true;
|
|
}
|
|
} catch (e) {
|
|
Logger.e(TAG, '拍照失败: $e');
|
|
}
|
|
}
|
|
|
|
// 清除图片输入状态
|
|
void clearImageInput() {
|
|
selectedImagePath.value = null;
|
|
isImageInputActive.value = false;
|
|
}
|
|
|
|
// 切换'+'选项展开状态
|
|
void togglePlusOptions() {
|
|
if (isPlusOptionsExpanded.value) {
|
|
// 如果当前已展开,则收缩
|
|
collapsePlusOptions();
|
|
_pendingPlusOptionsExpansion = false; // 取消待展开状态
|
|
} else {
|
|
// 如果当前未展开,则展开并关闭键盘
|
|
if (isKeyboardVisible.value) {
|
|
// 设置待展开标记
|
|
_pendingPlusOptionsExpansion = true;
|
|
hideKeyboard();
|
|
// 使用双重机制:轮询检测 + 键盘状态监听
|
|
_waitForKeyboardToClose();
|
|
} else {
|
|
// 键盘未显示,直接展开
|
|
isPlusOptionsExpanded.value = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 等待键盘关闭的轮询机制
|
|
void _waitForKeyboardToClose() {
|
|
int attempts = 0;
|
|
const maxAttempts = 30; // 最多尝试30次,总计3秒
|
|
const checkInterval = Duration(milliseconds: 100); // 每100ms检查一次
|
|
|
|
Timer.periodic(checkInterval, (timer) {
|
|
attempts++;
|
|
|
|
// 检查是否还需要展开(用户可能已经取消了)
|
|
if (!_pendingPlusOptionsExpansion) {
|
|
timer.cancel();
|
|
return;
|
|
}
|
|
|
|
if (!isKeyboardVisible.value) {
|
|
// 键盘已关闭,展开选项
|
|
timer.cancel();
|
|
_pendingPlusOptionsExpansion = false;
|
|
// 添加小延迟确保UI稳定
|
|
Future.delayed(const Duration(milliseconds: 50), () {
|
|
if (!isKeyboardVisible.value) {
|
|
// 再次确认键盘状态
|
|
isPlusOptionsExpanded.value = true;
|
|
}
|
|
});
|
|
} else if (attempts >= maxAttempts) {
|
|
// 超时,强制展开(可能键盘检测有问题)
|
|
timer.cancel();
|
|
_pendingPlusOptionsExpansion = false;
|
|
Logger.d('AgentController', '键盘关闭检测超时,强制展开选项');
|
|
isPlusOptionsExpanded.value = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 收缩'+'选项
|
|
void collapsePlusOptions() {
|
|
isPlusOptionsExpanded.value = false;
|
|
}
|
|
|
|
// 更新键盘可见状态
|
|
void updateKeyboardVisibility(bool isVisible) {
|
|
final wasVisible = isKeyboardVisible.value;
|
|
isKeyboardVisible.value = isVisible;
|
|
|
|
// 只有当键盘从隐藏变为显示时,才收缩'+'选项
|
|
// 避免键盘关闭时误触发收缩逻辑
|
|
if (!wasVisible && isVisible && isPlusOptionsExpanded.value) {
|
|
collapsePlusOptions();
|
|
}
|
|
|
|
// 如果键盘关闭了,并且有待展开的选项,则展开
|
|
if (wasVisible && !isVisible && _pendingPlusOptionsExpansion) {
|
|
_pendingPlusOptionsExpansion = false;
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
if (!isKeyboardVisible.value) {
|
|
isPlusOptionsExpanded.value = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 标记是否有待展开的'+'选项
|
|
bool _pendingPlusOptionsExpansion = false;
|
|
|
|
// 隐藏键盘
|
|
void hideKeyboard() {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
}
|
|
|
|
// 使用预设提示词处理图片
|
|
void useImagePrompt(String prompt) {
|
|
// 确保selectedImagePath.value不为null
|
|
if (selectedImagePath.value == null) return;
|
|
|
|
// 填充文本框同时直接发送消息
|
|
textController.text = prompt;
|
|
// 立即发送图片和提示词
|
|
final imagePath = selectedImagePath.value!;
|
|
sendImageMessage(imagePath, text: prompt);
|
|
// 发送后清除图片输入状态
|
|
clearImageInput();
|
|
}
|
|
|
|
// 预设提示词:总结图片内容
|
|
void summarizeImage() {
|
|
useImagePrompt('请总结这张图片的内容');
|
|
}
|
|
|
|
// 预设提示词:抽取图像文字
|
|
void extractTextFromImage() {
|
|
useImagePrompt('请提取这张图片中的所有文字');
|
|
}
|
|
|
|
// 预设提示词:翻译图像文字
|
|
void translateImageText() {
|
|
useImagePrompt('请翻译这张图片中的文字');
|
|
}
|
|
|
|
// 预设提示词处理(文本输入模式)
|
|
void preSetPromptAction(String presetType) {
|
|
// 确保在文本输入模式下
|
|
if (!isTextInputMode.value) {
|
|
// 如果不在文本模式,先切换到文本模式
|
|
isTextInputMode.value = true;
|
|
}
|
|
|
|
switch (presetType) {
|
|
case 'Music':
|
|
textController.text = 'playSomeMusic'.tr;
|
|
break;
|
|
case 'Navigation':
|
|
textController.text = 'navigationTo'.tr;
|
|
break;
|
|
case 'Weather':
|
|
textController.text = 'todayWeather'.tr;
|
|
break;
|
|
case 'News':
|
|
textController.text = 'todayNews'.tr;
|
|
break;
|
|
case 'Food':
|
|
textController.text = 'nearbyFood'.tr;
|
|
break;
|
|
default:
|
|
textController.text = presetType;
|
|
break;
|
|
}
|
|
|
|
// 自动发送消息
|
|
if (presetType != 'Navigation') {
|
|
sendMessage();
|
|
}
|
|
}
|
|
|
|
// 模式历史记录
|
|
VoiceInputMode? _previousInputMode;
|
|
|
|
// 保存当前输入模式
|
|
void _saveCurrentInputMode() {
|
|
if (!isTextInputMode.value) {
|
|
_previousInputMode = VoiceInputMode.pushToTalk;
|
|
} else {
|
|
_previousInputMode = VoiceInputMode.text;
|
|
}
|
|
// else {
|
|
// _previousInputMode = null;
|
|
// }
|
|
}
|
|
|
|
// 恢复之前的输入模式
|
|
void _restorePreviousInputMode() {
|
|
switch (_previousInputMode) {
|
|
case VoiceInputMode.pushToTalk:
|
|
isTextInputMode.value = false;
|
|
//isPushToTalkMode.value = true;
|
|
break;
|
|
case VoiceInputMode.text:
|
|
isTextInputMode.value = true;
|
|
// isPushToTalkMode.value = false;
|
|
break;
|
|
default:
|
|
// 默认恢复到文字模式
|
|
isTextInputMode.value = true;
|
|
//isPushToTalkMode.value = false;
|
|
break;
|
|
}
|
|
_previousInputMode = null; // 清除历史记录
|
|
}
|
|
|
|
// 开始打电话模式(支持耳机唤醒)
|
|
Future<void> startPhoneCall({bool isHeadsetTriggered = false}) async {
|
|
Logger.i(TAG, '🔥 开始打电话模式 (耳机触发: $isHeadsetTriggered)');
|
|
|
|
// 保存当前输入模式,以便通话结束后恢复
|
|
_saveCurrentInputMode();
|
|
|
|
// 收缩"+"选项
|
|
collapsePlusOptions();
|
|
|
|
// 进入打电话模式
|
|
isPhoneCallMode.value = true;
|
|
|
|
// 如果当前是文本模式,切换到语音模式????
|
|
// if (isListening.value) {
|
|
// 检查麦克风权限
|
|
final hasPermission = await PermissionUtil.instance.requestPermission(
|
|
permissionType: Permission.microphone,
|
|
permissionName: 'microphonePermission'.tr, // 麦克风
|
|
explanationText: 'phoneCallPermissionText'.tr, // 打电话功能需要访问麦克风进行语音通话。
|
|
permanentDenialText:
|
|
'permissionDeniedText'.tr, // 您已永久拒绝麦克风权限,请前往系统设置开启权限。
|
|
settingsButtonText: 'goToSettings'.tr, // 去设置
|
|
cancelButtonText: 'cancel'.tr, // 取消
|
|
);
|
|
|
|
if (!hasPermission) {
|
|
// 权限未获取,退出打电话模式并恢复之前状态
|
|
isPhoneCallMode.value = false;
|
|
_previousInputMode = null; // 清除历史记录
|
|
return;
|
|
}
|
|
|
|
// 打电话模式
|
|
isTextInputMode.value = false;
|
|
Logger.i(TAG, '打电话模式已启动,开始语音输入');
|
|
await AgentService.startConversation(mode: 'phone_call');
|
|
}
|
|
|
|
// 结束打电话模式
|
|
void endPhoneCall() async {
|
|
Logger.i(TAG, '🔥 结束打电话模式');
|
|
|
|
try {
|
|
// 如果正在录音,先停止录音
|
|
if (isListening.value) {
|
|
final result = await AgentService.stopConversation();
|
|
|
|
if (result) {
|
|
isListening.value = false;
|
|
}
|
|
}
|
|
|
|
// 退出打电话模式
|
|
isPhoneCallMode.value = false;
|
|
_restorePreviousInputMode();
|
|
} catch (e) {
|
|
Logger.e(TAG, '结束打电话模式失败: $e');
|
|
|
|
// 即使出现异常,也要确保退出打电话模式
|
|
isPhoneCallMode.value = false;
|
|
isListening.value = false;
|
|
_restorePreviousInputMode();
|
|
}
|
|
}
|
|
|
|
// 打电话模式专用:切换录音状态
|
|
Future<void> togglePhoneCallRecording() async {
|
|
Logger.i(TAG, '🎤 打电话模式切换录音状态');
|
|
// 只有在打电话模式下才能使用此方法
|
|
if (!isPhoneCallMode.value) {
|
|
Logger.w(TAG, '不在打电话模式,无法切换录音状态');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (isListening.value) {
|
|
// 当前正在录音,停止录音
|
|
Logger.i(TAG, '停止打电话模式录音');
|
|
final result = await AgentService.stopConversation();
|
|
|
|
// 手动更新状态,确保UI同步
|
|
if (result) {
|
|
isListening.value = false;
|
|
}
|
|
} else {
|
|
// 当前未录音,开始录音
|
|
Logger.i(TAG, '开始打电话模式录音');
|
|
final result = await AgentService.startConversation(mode: 'phone_call');
|
|
|
|
// 手动更新状态,确保UI同步
|
|
if (result) {
|
|
isListening.value = true;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
Logger.e(TAG, '打电话模式录音状态切换失败: $e');
|
|
}
|
|
}
|
|
|
|
// 耳机唤醒通话模式(专用入口)
|
|
Future<void> startHeadsetCall() async {
|
|
Logger.i(TAG, '🎧 耳机唤醒通话模式');
|
|
if (canStartHeadsetCall()) {
|
|
await startPhoneCall(isHeadsetTriggered: true);
|
|
}
|
|
}
|
|
|
|
// 检查当前是否可以启动耳机通话
|
|
bool canStartHeadsetCall() {
|
|
// 如果已经在打电话模式,不允许重复启动
|
|
if (isPhoneCallMode.value) {
|
|
Logger.w(TAG, '已在打电话模式,忽略耳机唤醒');
|
|
return false;
|
|
}
|
|
|
|
// 如果正在处理语音或TTS,暂时不允许启动
|
|
if (isListening.value || isSpeaking.value || isProcessing.value) {
|
|
Logger.w(TAG, '当前正在处理语音,延迟耳机唤醒');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// 验证语音输入状态一致性
|
|
void _validateVoiceInputStates() {
|
|
// 检查状态冲突
|
|
// if (isPhoneCallMode.value && isPushToTalkMode.value) {
|
|
// Logger.w(TAG, '状态冲突:同时启用打电话和按住说话模式,优先保持打电话模式');
|
|
// isPushToTalkMode.value = false;
|
|
// }
|
|
|
|
// // 检查文字模式与语音模式的一致性
|
|
// if (isTextInputMode.value &&
|
|
// (isPushToTalkMode.value || isPhoneCallMode.value)) {
|
|
// Logger.w(TAG, '状态冲突:文字模式与语音模式同时启用,优先保持语音模式');
|
|
// isTextInputMode.value = false;
|
|
// }
|
|
|
|
// // 检查按住说话状态的合理性
|
|
// if (isPushToTalkActive.value && !isPushToTalkMode.value) {
|
|
// Logger.w(TAG, '状态冲突:按住说话激活但未启用按住说话模式');
|
|
// isPushToTalkActive.value = false;
|
|
// }
|
|
}
|
|
|
|
// 安全的状态切换方法
|
|
void _safeStateTransition(VoiceInputMode targetMode) {
|
|
Logger.i(TAG, '安全状态切换到: $targetMode');
|
|
|
|
switch (targetMode) {
|
|
case VoiceInputMode.text:
|
|
isTextInputMode.value = true;
|
|
// isPushToTalkMode.value = false;
|
|
isPhoneCallMode.value = false;
|
|
isPushToTalkActive.value = false;
|
|
break;
|
|
|
|
case VoiceInputMode.pushToTalk:
|
|
isTextInputMode.value = false;
|
|
// isPushToTalkMode.value = true;
|
|
isPhoneCallMode.value = false;
|
|
isPushToTalkActive.value = false;
|
|
break;
|
|
|
|
case VoiceInputMode.phoneCall:
|
|
isTextInputMode.value = false;
|
|
// isPushToTalkMode.value = false;
|
|
isPhoneCallMode.value = true;
|
|
isPushToTalkActive.value = false;
|
|
break;
|
|
}
|
|
|
|
// 验证状态一致性
|
|
_validateVoiceInputStates();
|
|
}
|
|
|
|
// 开始按住说话
|
|
void startPushToTalk() async {
|
|
Logger.i(TAG, '🎤 开始按住说话');
|
|
|
|
// 只有在语音模式且按住说话模式下才能按住说话
|
|
if (isTextInputMode.value) return;
|
|
|
|
// 如果已经在按住说话,不重复开始
|
|
if (isPushToTalkActive.value) return;
|
|
|
|
// 设置按住说话状态
|
|
isPushToTalkActive.value = true;
|
|
//await AgentService.recognizeCallback();
|
|
// 开始语音识别
|
|
AgentService.startConversation(mode: 'push_to_talk');
|
|
// await startVoiceInput();
|
|
}
|
|
|
|
// 结束按住说话
|
|
void endPushToTalk() async {
|
|
Logger.i(TAG, '🎤 结束按住说话');
|
|
|
|
// 如果不在按住说话状态,直接返回
|
|
if (!isPushToTalkActive.value) return;
|
|
|
|
// 清除按住说话状态(但保持按住说话模式)
|
|
isPushToTalkActive.value = false;
|
|
|
|
AgentService.stopConversation();
|
|
// 停止语音识别
|
|
// await stopVoiceInput();
|
|
}
|
|
|
|
// 获取音乐播放状态
|
|
Future<void> musicStatus() async {
|
|
try {
|
|
var status = await _musiceManager.getStatus();
|
|
isPlaying.value = status['isPlaying'] ?? false;
|
|
currentId.value = status['id'] as int;
|
|
Logger.d(
|
|
TAG, '音乐状态更新: playing=${isPlaying.value}, url=${currentId.value}');
|
|
Logger.d(
|
|
TAG, '音乐状态更新: playing=${isPlaying.value}, url=${currentId.value}');
|
|
} catch (e) {
|
|
Logger.e(TAG, '获取音乐状态失败: $e');
|
|
}
|
|
}
|
|
|
|
// 播放音乐
|
|
Future<void> musicPlay(Map<String, dynamic> song) async {
|
|
try {
|
|
Logger.d(TAG, '开始播放音乐: ${song['title'] ?? song['name']}');
|
|
|
|
// 立即更新UI状态,提供即时反馈
|
|
isPlaying.value = true;
|
|
// 修复:确保currentId接收int类型
|
|
final songId = song['id'];
|
|
currentId.value = songId ?? 0;
|
|
|
|
// 重置进度状态
|
|
currentPosition.value = 0;
|
|
duration.value = 0;
|
|
progress.value = 0.0;
|
|
|
|
// 转换为MusicManager期望的格式
|
|
final musicData = <String, dynamic>{
|
|
'id': song['id'],
|
|
'title': song['title']?.toString() ?? song['name']?.toString() ?? '',
|
|
'artist':
|
|
song['artist']?.toString() ?? song['sgener']?.toString() ?? '',
|
|
'coverUrl':
|
|
song['coverUrl']?.toString() ?? song['image']?.toString() ?? '',
|
|
};
|
|
// 调用音乐管理器播放
|
|
await _musiceManager.play(musicData);
|
|
// 更新媒体会话元数据和播放状态
|
|
await _updateMediaSessionMetadata(musicData);
|
|
await _updateMediaSessionPlaybackState(true);
|
|
Logger.d(TAG, '音乐播放启动成功');
|
|
} catch (e) {
|
|
Logger.e(TAG, '播放音乐失败: $e');
|
|
// 发生错误时重置状态
|
|
isPlaying.value = false;
|
|
currentId.value = 0;
|
|
}
|
|
}
|
|
|
|
// 暂停音乐
|
|
Future<void> musicPause() async {
|
|
try {
|
|
Logger.d('AgentController', '暂停音乐播放');
|
|
await _musiceManager.pause();
|
|
isPlaying.value = false;
|
|
_stopProgressTimer();
|
|
|
|
// 更新媒体会话播放状态
|
|
await _updateMediaSessionPlaybackState(false);
|
|
} catch (e) {
|
|
Logger.e('AgentController', '暂停音乐失败: $e');
|
|
}
|
|
}
|
|
|
|
// 恢复音乐播放
|
|
Future<void> musicResume() async {
|
|
try {
|
|
Logger.d('AgentController', '恢复播放音乐');
|
|
await _musiceManager.resume();
|
|
isPlaying.value = true;
|
|
|
|
// 更新媒体会话播放状态
|
|
await _updateMediaSessionPlaybackState(true);
|
|
|
|
Logger.d('AgentController', '音乐恢复播放成功');
|
|
} catch (e) {
|
|
Logger.e('AgentController', '恢复播放音乐失败: $e');
|
|
isPlaying.value = false;
|
|
}
|
|
}
|
|
|
|
// 停止进度更新定时器
|
|
void _stopProgressTimer() {
|
|
if (_progressTimer != null) {
|
|
Logger.d('AgentController', '停止播放进度定时器');
|
|
_progressTimer?.cancel();
|
|
_progressTimer = null;
|
|
}
|
|
}
|
|
|
|
// 获取播放进度
|
|
Future<void> updateMusicProgress(int currentIdInternal, int newPosition,
|
|
int newDuration, double newProgress) async {
|
|
try {
|
|
// 如果正在加载音乐,避免进度回调干扰状态更新
|
|
if (isLoadingMusic.value) {
|
|
return;
|
|
}
|
|
|
|
if (currentIdInternal != 0) {
|
|
isPlaying.value = true;
|
|
// return; // 如果没有在播放,直接返回
|
|
}
|
|
|
|
bool hasUpdate = false;
|
|
if (currentId.value != currentIdInternal) {
|
|
currentId.value = currentIdInternal;
|
|
hasUpdate = true;
|
|
}
|
|
// 只有当值发生变化时才更新,避免不必要的UI刷新
|
|
if (currentPosition.value != newPosition) {
|
|
currentPosition.value = newPosition;
|
|
hasUpdate = true;
|
|
}
|
|
if (duration.value != newDuration) {
|
|
duration.value = newDuration;
|
|
hasUpdate = true;
|
|
}
|
|
if ((progress.value - newProgress).abs() > 0.001) {
|
|
// 避免浮点数精度问题
|
|
progress.value = newProgress;
|
|
hasUpdate = true;
|
|
}
|
|
|
|
// // 如果有进度更新,通知系统媒体会话更新进度信息
|
|
// if (hasUpdate) {
|
|
// await _updateMediaSessionProgress();
|
|
// }
|
|
} catch (e) {
|
|
Logger.e('AgentController', '获取播放进度失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 更新系统媒体会话的进度信息
|
|
Future<void> _updateMediaSessionProgress() async {
|
|
try {
|
|
// 这里将调用music_service插件的方法来更新系统媒体会话进度
|
|
// 具体实现将在music_service插件中添加
|
|
await _musiceManager.updateMediaSessionProgress(
|
|
currentPosition.value,
|
|
duration.value,
|
|
progress.value,
|
|
);
|
|
} catch (e) {
|
|
// 忽略错误,避免影响正常播放
|
|
Logger.d('AgentController', '更新媒体会话进度失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 更新系统媒体会话的元数据
|
|
Future<void> _updateMediaSessionMetadata(Map<String, dynamic> song) async {
|
|
try {
|
|
await _musiceManager.updateMediaSessionMetadata(song);
|
|
} catch (e) {
|
|
Logger.d('AgentController', '更新媒体会话元数据失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 更新系统媒体会话的播放状态
|
|
Future<void> _updateMediaSessionPlaybackState(bool isPlaying) async {
|
|
try {
|
|
await _musiceManager.updateMediaSessionPlaybackState(isPlaying);
|
|
} catch (e) {
|
|
Logger.d('AgentController', '更新媒体会话播放状态失败: $e');
|
|
}
|
|
}
|
|
|
|
// 跳转到指定位置
|
|
Future<void> seekToPosition(int positionMs) async {
|
|
try {
|
|
await _musiceManager.seekTo(positionMs);
|
|
} catch (e) {
|
|
Logger.e('AgentController', '跳转播放位置失败: $e');
|
|
}
|
|
}
|
|
|
|
// 跳转到指定位置
|
|
Future<void> seekToProgress(int currentIdInternal, int newPosition,
|
|
int newDuration, double newProgress) async {
|
|
try {
|
|
// 立即更新进度状态
|
|
await updateMusicProgress(
|
|
currentIdInternal, newPosition, newDuration, newProgress);
|
|
} catch (e) {
|
|
Logger.e('AgentController', '跳转播放位置失败: $e');
|
|
}
|
|
}
|
|
|
|
// 修改自动停止处理,遵循TTS设置
|
|
void _handleAutoStop(String sessionid, int index) {
|
|
// 发送此消息到AI处理
|
|
isProcessing.value = true;
|
|
// 标记为新的AI回复
|
|
_isNewAssistantResponse = true;
|
|
currsessionid.value = sessionid;
|
|
AgentService.processTextInput(sessionid, messages[index].text,
|
|
speakResponse: isTtsEnabled.value);
|
|
}
|
|
|
|
//去导航
|
|
void openNavigation(bool isshow, String start, String destination,
|
|
String endlatitude, String endlongitude, String mode) {
|
|
print('liwei 导航:$destination $endlatitude $endlongitude $mode');
|
|
if (isshow) {
|
|
// 弹出导航选择面板
|
|
_showNavigationSelectionDialog(
|
|
start, destination, endlatitude, endlongitude, mode);
|
|
} else {
|
|
// 使用默认配置直接导航
|
|
_navigateWithDefaultMode(
|
|
start, destination, endlatitude, endlongitude, mode);
|
|
}
|
|
}
|
|
|
|
// 显示导航选择底部面板
|
|
void _showNavigationSelectionDialog(String start, String destination,
|
|
String endlatitude, String endlongitude, String mode) {
|
|
Get.bottomSheet(
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(20),
|
|
topRight: Radius.circular(20),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 顶部指示条
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 12),
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
// 标题
|
|
const Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: Text(
|
|
'选择导航软件',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
// 导航选项列表
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.map, color: Colors.blue),
|
|
),
|
|
title: const Text('高德地图'),
|
|
subtitle: const Text('使用高德地图导航'),
|
|
onTap: () {
|
|
Get.back();
|
|
openGaodeMap(destination, endlatitude, endlongitude, mode);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.map, color: Colors.green),
|
|
),
|
|
title: const Text('百度地图'),
|
|
subtitle: const Text('使用百度地图导航'),
|
|
onTap: () {
|
|
Get.back();
|
|
openBaiduMap(destination, endlatitude, endlongitude, mode);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.map, color: Colors.red),
|
|
),
|
|
title: const Text('谷歌地图'),
|
|
subtitle: const Text('使用谷歌地图导航'),
|
|
onTap: () {
|
|
Get.back();
|
|
openGoogleMap(destination, mode);
|
|
},
|
|
),
|
|
// 苹果导航 - 仅在 iOS 平台显示
|
|
if (Platform.isIOS)
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.map, color: Colors.orange),
|
|
),
|
|
title: const Text('苹果地图'),
|
|
subtitle: const Text('使用苹果地图导航'),
|
|
onTap: () {
|
|
Get.back();
|
|
openAppleMap(destination, mode);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Icon(Icons.navigation, color: Colors.grey),
|
|
),
|
|
title: const Text('内置导航'),
|
|
subtitle: const Text('使用内置地图导航'),
|
|
onTap: () {
|
|
Get.back();
|
|
openInternalGaodeMap(
|
|
start, destination, endlatitude, endlongitude, mode);
|
|
},
|
|
),
|
|
// 底部安全区域
|
|
SizedBox(height: MediaQuery.of(Get.context!).padding.bottom + 20),
|
|
],
|
|
),
|
|
),
|
|
isScrollControlled: true,
|
|
isDismissible: true, // 点击外部区域可关闭
|
|
enableDrag: true, // 支持拖拽关闭
|
|
);
|
|
}
|
|
|
|
// 使用默认配置导航
|
|
void _navigateWithDefaultMode(String start, String destination,
|
|
String endlatitude, String endlongitude, String mode) {
|
|
final navigationMode =
|
|
AppConfig.env('APP_NAVIGATION_MODE') ?? 'external_auto';
|
|
if (navigationMode == 'internal_gaode') {
|
|
_navigationManager.open(start, "$endlongitude,$endlatitude", mode);
|
|
} else if (navigationMode == 'external_baidu') {
|
|
openBaiduMap(destination, endlatitude, endlongitude, mode);
|
|
} else if (navigationMode == 'external_gaode') {
|
|
openGaodeMap(destination, endlatitude, endlongitude, mode);
|
|
} else if (navigationMode == 'external_google') {
|
|
openGoogleMap(destination, mode);
|
|
} else {
|
|
// 自动选择导航应用,优先级:百度 > 高德 > 谷歌
|
|
_autoSelectNavigation(
|
|
start, destination, endlatitude, endlongitude, mode);
|
|
}
|
|
}
|
|
|
|
// 自动选择导航应用
|
|
Future<void> _autoSelectNavigation(String start, String destination,
|
|
String endlatitude, String endlongitude, String mode) async {
|
|
try {
|
|
// 1. 优先尝试百度地图
|
|
final baiduUri = Uri(
|
|
scheme: 'baidumap',
|
|
host: 'map',
|
|
path: 'direction',
|
|
queryParameters: {
|
|
'destination': destination,
|
|
'coord_type': 'gcj02',
|
|
'mode': mode == 'driving' ? 'driving' : 'transit',
|
|
'src': 'deepvoice',
|
|
},
|
|
);
|
|
|
|
if (await canLaunchUrl(baiduUri)) {
|
|
print('自动选择:使用百度地图');
|
|
openBaiduMap(destination, endlatitude, endlongitude, mode);
|
|
return;
|
|
}
|
|
|
|
// 2. 其次尝试高德地图
|
|
final gaodeUri = Uri(
|
|
scheme: 'amapuri',
|
|
host: 'navi',
|
|
queryParameters: {
|
|
'to': destination,
|
|
'mode': mode.isNotEmpty ? mode : 'driving',
|
|
'callnative': '1',
|
|
},
|
|
);
|
|
|
|
if (await canLaunchUrl(gaodeUri)) {
|
|
print('自动选择:使用高德地图');
|
|
openGaodeMap(destination, endlatitude, endlongitude, mode);
|
|
return;
|
|
}
|
|
|
|
// 3. 然后尝试谷歌地图
|
|
final googleUri = Uri(
|
|
scheme: 'comgooglemaps',
|
|
queryParameters: {
|
|
'daddr': destination,
|
|
'directionsmode': mode == 'driving' ? 'driving' : 'transit',
|
|
},
|
|
);
|
|
|
|
if (await canLaunchUrl(googleUri)) {
|
|
print('自动选择:使用谷歌地图');
|
|
openGoogleMap(destination, mode);
|
|
return;
|
|
}
|
|
|
|
// 4. 在 iOS 平台尝试苹果地图
|
|
if (Platform.isIOS) {
|
|
final appleUri = Uri(
|
|
scheme: 'maps',
|
|
queryParameters: {
|
|
'daddr': destination,
|
|
'dirflg': mode == 'driving' ? 'd' : 'r',
|
|
},
|
|
);
|
|
|
|
if (await canLaunchUrl(appleUri)) {
|
|
print('自动选择:使用苹果地图');
|
|
openAppleMap(destination, mode);
|
|
return;
|
|
}
|
|
}
|
|
// 5. 最后尝试内置高德导航
|
|
_navigationManager.open(start, "$endlongitude,$endlatitude", mode);
|
|
// 5. 如果都没有安装,提示用户
|
|
// print('未找到可用的导航应用');
|
|
// final availableApps =
|
|
// Platform.isIOS ? '百度地图、高德地图、谷歌地图或苹果地图' : '百度地图、高德地图或谷歌地图';
|
|
// Get.snackbar('提示', '未找到可用的导航应用,请安装$availableApps');
|
|
} catch (e) {
|
|
print('自动选择导航失败: $e');
|
|
Get.snackbar('错误', '启动导航失败');
|
|
}
|
|
}
|
|
|
|
// 打开图片查看器
|
|
void openImageViewer(BuildContext context, String imagePath) {
|
|
// 构建完整的图片路径
|
|
String fullImagePath;
|
|
if (imagePath.startsWith('/')) {
|
|
// 如果是相对路径(以/开头),需要拼接AppDir.value
|
|
fullImagePath = '${AppDir.value}$imagePath';
|
|
} else {
|
|
// 如果已经是完整路径,直接使用
|
|
fullImagePath = imagePath;
|
|
}
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => Scaffold(
|
|
// appBar: AppBar(
|
|
// backgroundColor: Colors.black,
|
|
// leading: IconButton(
|
|
// icon: Icon(Icons.arrow_back_ios, color: Colors.white),
|
|
// onPressed: () => Navigator.of(context).pop(),
|
|
// ),
|
|
// iconTheme: IconThemeData(color: Colors.white),
|
|
// //title: Text('图片预览', style: TextStyle(color: Colors.white)),
|
|
// ),
|
|
body: Container(
|
|
color: Colors.black,
|
|
child: GestureDetector(
|
|
onTap: () => Navigator.of(context).pop(), // 点击退出
|
|
|
|
child: PhotoView(
|
|
imageProvider: FileImage(File(fullImagePath)),
|
|
minScale: PhotoViewComputedScale.contained,
|
|
maxScale: PhotoViewComputedScale.covered * 2,
|
|
backgroundDecoration: BoxDecoration(color: Colors.black),
|
|
// errorBuilder: (context, error, stackTrace) {
|
|
// return const Center(
|
|
// child: Column(
|
|
// mainAxisAlignment: MainAxisAlignment.center,
|
|
// children: [
|
|
// Icon(Icons.error, color: Colors.white, size: 64),
|
|
// SizedBox(height: 16),
|
|
// Text('图片加载失败', style: TextStyle(color: Colors.white)),
|
|
// ],
|
|
// ),
|
|
// );
|
|
// },
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 播放音乐列表
|
|
Future<void> playPlaylist(List<Map<String, dynamic>> playlist) async {
|
|
try {
|
|
if (playlist.isEmpty) {
|
|
Logger.e(TAG, '播放列表为空,无法播放');
|
|
return;
|
|
}
|
|
|
|
Logger.d(TAG, '开始播放音乐列表,共${playlist.length}首歌曲');
|
|
|
|
// 保存当前播放列表
|
|
currentPlaylist.assignAll(playlist);
|
|
currentPlaylistIndex.value = 0;
|
|
|
|
// 设置加载状态
|
|
isLoadingMusic.value = true;
|
|
loadingMusicId.value = playlist[0]['id'];
|
|
|
|
// 重置播放状态
|
|
isPlaying.value = true;
|
|
currentPosition.value = 0;
|
|
duration.value = 0;
|
|
progress.value = 0.0;
|
|
|
|
// 格式化歌曲列表数据
|
|
final formattedPlaylist = playlist.map((song) {
|
|
return {
|
|
'id': song['id'],
|
|
'title': song['title']?.toString() ?? song['name']?.toString() ?? '',
|
|
'artist':
|
|
song['artist']?.toString() ?? song['sgener']?.toString() ?? '',
|
|
'coverUrl':
|
|
song['coverUrl']?.toString() ?? song['image']?.toString() ?? '',
|
|
};
|
|
}).toList();
|
|
|
|
// 调用音乐管理器播放列表
|
|
await _musiceManager.playPlaylist(formattedPlaylist);
|
|
|
|
// 更新当前播放歌曲ID
|
|
if (formattedPlaylist.isNotEmpty) {
|
|
currentId.value = formattedPlaylist[0]['id'] ?? 0;
|
|
}
|
|
|
|
Logger.d(TAG, '音乐列表播放启动成功');
|
|
} catch (e) {
|
|
Logger.e(TAG, '播放音乐列表失败: $e');
|
|
isPlaying.value = false;
|
|
currentId.value = 0;
|
|
|
|
// 显示错误提示
|
|
Get.snackbar(
|
|
'播放失败',
|
|
'无法播放音乐列表,请稍后重试',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
backgroundColor: Colors.red[400],
|
|
colorText: Colors.white,
|
|
duration: const Duration(seconds: 2),
|
|
);
|
|
} finally {
|
|
// 清除加载状态
|
|
isLoadingMusic.value = false;
|
|
loadingMusicId.value = 0;
|
|
}
|
|
}
|
|
|
|
/// 播放上一曲
|
|
Future<void> playPrevious() async {
|
|
if (currentPlaylist.isEmpty) {
|
|
Logger.w(TAG, '当前播放列表为空,无法播放上一曲');
|
|
return;
|
|
}
|
|
|
|
// 计算上一曲的索引
|
|
int previousIndex = currentPlaylistIndex.value - 1;
|
|
if (previousIndex < 0) {
|
|
previousIndex = currentPlaylist.length - 1; // 循环到最后一首
|
|
}
|
|
|
|
currentPlaylistIndex.value = previousIndex;
|
|
final previousSong = currentPlaylist[previousIndex];
|
|
|
|
Logger.d(TAG, '播放上一曲: ${previousSong['name'] ?? previousSong['title']}');
|
|
await musicPlay(previousSong);
|
|
}
|
|
|
|
/// 播放下一曲
|
|
Future<void> playNext() async {
|
|
if (currentPlaylist.isEmpty) {
|
|
Logger.w(TAG, '当前播放列表为空,无法播放下一曲');
|
|
return;
|
|
}
|
|
|
|
// 计算下一曲的索引
|
|
int nextIndex = currentPlaylistIndex.value + 1;
|
|
if (nextIndex >= currentPlaylist.length) {
|
|
nextIndex = 0; // 循环到第一首
|
|
}
|
|
|
|
currentPlaylistIndex.value = nextIndex;
|
|
final nextSong = currentPlaylist[nextIndex];
|
|
|
|
Logger.d(TAG, '播放下一曲: ${nextSong['name'] ?? nextSong['title']}');
|
|
await musicPlay(nextSong);
|
|
}
|
|
|
|
/// 更新当前播放列表和索引(当单独播放某首歌曲时调用)
|
|
void updateCurrentPlaylistContext(
|
|
List<Map<String, dynamic>> playlist, int songId) {
|
|
currentPlaylist.assignAll(playlist);
|
|
|
|
// 查找当前歌曲在列表中的索引
|
|
int index = -1;
|
|
for (int i = 0; i < playlist.length; i++) {
|
|
if (playlist[i]['id'] == songId) {
|
|
index = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (index != -1) {
|
|
currentPlaylistIndex.value = index;
|
|
Logger.d(TAG, '更新播放列表上下文,当前歌曲索引: $index');
|
|
}
|
|
}
|
|
|
|
/// 应用生命周期变化回调
|
|
/// 目的:
|
|
/// - 当应用进入后台、失去焦点或被系统分离(inactive/paused/detached)时,
|
|
/// 兜底结束按住说话(调用 endPushToTalk),避免因 onPointerUp 未触发而卡在“按下”状态。
|
|
/// 参数:
|
|
/// - state: 当前应用生命周期状态
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.detached) {
|
|
if (isPushToTalkActive.value) {
|
|
// 使用 microtask 避免阻塞当前生命周期回调
|
|
Future.microtask(() {
|
|
endPushToTalk();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== 媒体会话回调处理方法 ==========
|
|
|
|
/// 处理来自系统通知栏的播放操作
|
|
Future<void> onMediaSessionPlay() async {
|
|
Logger.d(TAG, '收到媒体会话播放回调');
|
|
if (!isPlaying.value) {
|
|
await musicResume();
|
|
}
|
|
}
|
|
|
|
/// 处理来自系统通知栏的暂停操作
|
|
Future<void> onMediaSessionPause() async {
|
|
Logger.d(TAG, '收到媒体会话暂停回调');
|
|
if (isPlaying.value) {
|
|
await musicPause();
|
|
}
|
|
}
|
|
|
|
/// 处理来自系统通知栏的下一曲操作
|
|
Future<void> onMediaSessionNext() async {
|
|
Logger.d(TAG, '收到媒体会话下一曲回调');
|
|
await playNext();
|
|
}
|
|
|
|
/// 处理来自系统通知栏的上一曲操作
|
|
Future<void> onMediaSessionPrevious() async {
|
|
Logger.d(TAG, '收到媒体会话上一曲回调');
|
|
await playPrevious();
|
|
}
|
|
|
|
/// 处理来自系统通知栏的跳转操作
|
|
Future<void> onMediaSessionSeekTo(int positionMs) async {
|
|
Logger.d(TAG, '收到媒体会话跳转回调: ${positionMs}ms');
|
|
await seekToPosition(positionMs);
|
|
}
|
|
|
|
/// 处理来自系统通知栏的停止操作
|
|
Future<void> onMediaSessionStop() async {
|
|
Logger.d(TAG, '收到媒体会话停止回调');
|
|
await musicPause();
|
|
// 重置播放状态
|
|
currentPosition.value = 0;
|
|
progress.value = 0.0;
|
|
_stopProgressTimer();
|
|
}
|
|
|
|
/// 处理来自系统进度条
|
|
Future<void> onMediaSessionProgress(int currentIdInternal, int newPosition,
|
|
int newDuration, double newProgress) async {
|
|
// print(
|
|
// "onMediaSessionProgress currentIdInternal :$currentIdInternal ,newPosition:$newPosition newDuration:$newDuration newProgress:$newProgress");
|
|
await seekToProgress(
|
|
currentIdInternal, newPosition, newDuration, newProgress);
|
|
}
|
|
|
|
/// 处理来自系统通知栏的停止操作
|
|
Future<void> playQQMusic(
|
|
List<SongModel> songs, SongModel song, int index) async {
|
|
try {
|
|
if (_qqmusicManager.isPlaying.value) {
|
|
if (_qqmusicManager.currentSong.value?.mid == song.mid) {
|
|
// 当前歌曲正在播放,暂停它
|
|
await _qqmusicManager.pauseMusic();
|
|
} else {
|
|
final songMids = songs.map((s) => s.mid).toList();
|
|
// 其他歌曲正在播放,播放这首歌
|
|
final result = await _qqmusicManager.playSongs(
|
|
songMids: songMids,
|
|
index: index,
|
|
songList: [song],
|
|
currentSongModel: song,
|
|
);
|
|
if (result['success'] == true) {
|
|
// 播放成功后,如果歌曲不在当前列表中,保持列表显示状态不变
|
|
// Get.snackbar('开始播放', song.name);
|
|
_qqmusicManager.syncCurrentPlayInfo();
|
|
} else {
|
|
Get.snackbar('播放失败', result['error'] ?? '无法播放歌曲');
|
|
}
|
|
return;
|
|
}
|
|
} else {
|
|
if (_qqmusicManager.currentSong.value?.mid == song.mid) {
|
|
// 当前歌曲已暂停,恢复播放
|
|
await _qqmusicManager.resumeMusic();
|
|
} else {
|
|
final songMids = songs.map((s) => s.mid).toList();
|
|
// 播放新歌曲
|
|
final result = await _qqmusicManager.playSongs(
|
|
songMids: songMids,
|
|
index: index,
|
|
songList: [song],
|
|
currentSongModel: song,
|
|
);
|
|
if (result['success'] == true) {
|
|
// 播放成功后,如果歌曲不在当前列表中,保持列表显示状态不变
|
|
// Get.snackbar('开始播放', song.name);
|
|
_qqmusicManager.syncCurrentPlayInfo();
|
|
} else {
|
|
Get.snackbar('播放失败', result['error'] ?? '无法播放歌曲');
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// 静默处理错误,避免影响用户体验
|
|
print('播放控制失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 打开内置高德地图进行导航
|
|
Future<bool> openInternalGaodeMap(
|
|
String start,
|
|
String destination,
|
|
String endlatitude,
|
|
String endlongitude,
|
|
String mode // 'driving', 'transit', 'walking', 'cycling'
|
|
) async {
|
|
try {
|
|
_navigationManager.open(start, "$endlongitude,$endlatitude", mode);
|
|
return true;
|
|
} catch (e) {
|
|
print('打开内置高德地图失败: $e');
|
|
Get.snackbar('错误', '打开地图时发生错误');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 打开Google地图进行导航
|
|
Future<bool> openGoogleMap(String daddr, String mode) async {
|
|
try {
|
|
// 方案1: 尝试使用Google Maps应用的URL scheme
|
|
// final Uri googleMapsUri = Uri.parse(url);
|
|
|
|
// 方案1: Google Maps应用 URL scheme
|
|
final Uri appUri = Uri.parse(
|
|
'comgooglemaps://?saddr=CurrentLocation&daddr=$daddr&directionsmode=$mode');
|
|
if (await canLaunchUrl(appUri)) {
|
|
await launchUrl(appUri, mode: LaunchMode.externalApplication);
|
|
return true;
|
|
} else {
|
|
final Uri webUrl = Uri.parse(
|
|
"https://www.google.com/maps/dir/?api=1&origin=Current+Location&destination=$daddr&travelmode=$mode");
|
|
if (await canLaunchUrl(webUrl)) {
|
|
await launchUrl(webUrl);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
print('打开地图失败: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 打开高德地图进行导航
|
|
Future<bool> openGaodeMap(
|
|
String destination,
|
|
String lat,
|
|
String lon,
|
|
String mode, // 'driving', 'transit', 'walking', 'cycling'
|
|
) async {
|
|
try {
|
|
// 获取应用包名
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
|
|
// 构建src参数(带平台前缀)
|
|
final String src = Platform.isAndroid
|
|
? packageInfo.packageName
|
|
: "ios.${packageInfo.packageName}";
|
|
|
|
// 导航模式映射
|
|
String gaodeMode;
|
|
switch (mode) {
|
|
case 'transit':
|
|
gaodeMode = '1'; // 公交
|
|
break;
|
|
case 'walking':
|
|
gaodeMode = '2'; // 步行
|
|
break;
|
|
case 'cycling':
|
|
gaodeMode = '3'; // 骑行
|
|
break;
|
|
case 'driving':
|
|
default:
|
|
gaodeMode = '0'; // 驾车
|
|
break;
|
|
}
|
|
|
|
// 根据平台选择不同的 scheme 和参数
|
|
final Uri uri;
|
|
if (Platform.isAndroid) {
|
|
uri = Uri(
|
|
scheme: 'androidamap',
|
|
host: 'route',
|
|
path: 'plan',
|
|
queryParameters: {
|
|
'dlat': lat,
|
|
'dlon': lon,
|
|
'dname': destination,
|
|
'dev': '0', // 0: gcj02
|
|
't': gaodeMode,
|
|
'sourceApplication': src,
|
|
},
|
|
);
|
|
} else {
|
|
// iOS
|
|
uri = Uri(
|
|
scheme: 'amapuri',
|
|
host: 'route',
|
|
path: 'plan',
|
|
queryParameters: {
|
|
'dlat': lat,
|
|
'dlon': lon,
|
|
'dname': destination,
|
|
'dev': '0', // 0: gcj02
|
|
't': gaodeMode,
|
|
'sourceApplication': src,
|
|
},
|
|
);
|
|
}
|
|
|
|
print('高德导航链接: ${uri.toString()}');
|
|
|
|
if (await canLaunchUrl(uri)) {
|
|
final bool launched = await launchUrl(
|
|
uri,
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
return launched;
|
|
} else {
|
|
print('高德地图未安装');
|
|
Get.snackbar('提示', '请先安装高德地图应用');
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
print('打开高德地图失败: $e');
|
|
Get.snackbar('错误', '打开地图时发生错误');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 打开Baidu地图进行导航
|
|
Future<bool> openBaiduMap(
|
|
String destination,
|
|
String lat,
|
|
String lon,
|
|
String mode, // 'driving', 'transit', 'walking', 'riding'
|
|
) async {
|
|
try {
|
|
// 获取应用包名
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
final String src =
|
|
'${Platform.isAndroid ? '' : 'ios.'}${packageInfo.packageName}';
|
|
|
|
// 百度地图导航模式映射
|
|
String baiduMode;
|
|
switch (mode) {
|
|
case 'transit':
|
|
baiduMode = 'transit'; // 公交
|
|
break;
|
|
case 'walking':
|
|
baiduMode = 'walking'; // 步行
|
|
break;
|
|
case 'cycling':
|
|
baiduMode = 'riding'; // 骑行
|
|
break;
|
|
case 'driving':
|
|
default:
|
|
baiduMode = 'driving'; // 驾车
|
|
break;
|
|
}
|
|
|
|
final Uri uri = Uri(
|
|
scheme: 'baidumap',
|
|
host: 'map',
|
|
path: 'direction',
|
|
queryParameters: {
|
|
'destination': 'name:$destination|latlng:$lat,$lon',
|
|
'coord_type': 'gcj02',
|
|
'mode': baiduMode,
|
|
'src': src,
|
|
},
|
|
);
|
|
|
|
print('百度导航链接: ${uri.toString()}');
|
|
|
|
if (await canLaunchUrl(uri)) {
|
|
final bool launched = await launchUrl(
|
|
uri,
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
return launched;
|
|
} else {
|
|
print('百度地图未安装');
|
|
Get.snackbar('提示', '请先安装百度地图应用');
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
print('打开百度地图失败: $e');
|
|
Get.snackbar('错误', '打开地图时发生错误');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 打开苹果地图进行导航
|
|
Future<void> openAppleMap(String destination, String mode) async {
|
|
try {
|
|
// 构建苹果地图导航链接
|
|
final Uri uri = Uri(
|
|
scheme: 'maps',
|
|
queryParameters: {
|
|
'daddr': destination, // 目的地地址
|
|
'dirflg': mode == 'driving' ? 'd' : 'r', // d=驾车, r=公交
|
|
},
|
|
);
|
|
|
|
print('苹果地图导航链接: ${uri.toString()}');
|
|
|
|
// 尝试打开苹果地图应用
|
|
if (await canLaunchUrl(uri)) {
|
|
final bool launched = await launchUrl(
|
|
uri,
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
if (!launched) {
|
|
print('苹果地图启动失败');
|
|
Get.snackbar('提示', '苹果地图启动失败');
|
|
}
|
|
} else {
|
|
print('苹果地图不可用');
|
|
Get.snackbar('提示', '苹果地图不可用');
|
|
}
|
|
} catch (e) {
|
|
print('打开苹果地图失败: $e');
|
|
Get.snackbar('错误', '打开苹果地图时发生错误');
|
|
}
|
|
}
|
|
|
|
Future<void> updateToolCallsExpanded(
|
|
String sessionid,
|
|
bool isExpanded,
|
|
) async {
|
|
final message = messages.firstWhere(
|
|
(msg) => msg.sessionid == sessionid && msg.isUser == false,
|
|
);
|
|
message.isToolCallsExpanded = isExpanded;
|
|
messages.refresh();
|
|
}
|
|
|
|
String getDeviceName() {
|
|
if (_bluetoothManager.currentDevice != null) {
|
|
return _bluetoothManager.currentDevice!.name;
|
|
}
|
|
return "";
|
|
}
|
|
}
|
|
|