import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:audio_session/audio_session.dart'; import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:path_provider/path_provider.dart'; import '../../../core/utils/logger.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../../../data/services/meeting/meeting_task_service.dart'; import '../../../data/services/meeting/meeting_upload_service.dart'; import '../../../data/services/network/api.dart'; import '../model/meeting_details_model.dart'; import '../model/meeting_model.dart'; /// 会议详情控制器 class MeetingDetailsController extends GetxController with GetTickerProviderStateMixin, WidgetsBindingObserver { late TabController tabController; final RxInt currentIndex = 0.obs; late AnimationController lottieController; late final Worker _tasktypeListener; final _meetingUploadService = Get.find(); final _meetingTaskService = Get.find(); final PlayerController playerController = PlayerController(); StreamSubscription? _currentDurationSubscription; StreamSubscription? _playerStateSubscription; RxBool isTop = false.obs; RxInt durationMax = 0.obs; RxDouble dragOffset = 210.w.obs; RxBool isPlay = false.obs; RxInt currentDuration = 0.obs; // 播放器准备状态 RxBool isPlayerReady = false.obs; RxInt speakerAllStarttime = (-1).obs; RxInt speakerAllEndtime = (-1).obs; int id = 0; RxBool isLoading = true.obs; Rx meetingData = MeetingModel().obs; Rx meetingDetails = MeetingDetailsModel().obs; RxList textList = [].obs; RxBool isSpeakerText = false.obs; List editSpeakerTextList = []; RxInt editSpeakerTextId = 0.obs; String editSpeakerTextChange = ''; RxInt summaryNumberModifications = 0.obs; RxInt personnelNumberModifications = 0.obs; String markmapLibJs = ''; String d3Js = ''; String markmapViewJs = ''; String markmapToolbarJs = ''; String saveSvgAsPngJs = ''; RxBool isDownload = false.obs; RxDouble downloadProgress = 0.0.obs; List categoryTemplateList = []; Map editTemplate = {}; int deleteTemplateId = 0; @override void onInit() { super.onInit(); tabController = TabController(length: 4, vsync: this); tabController.addListener(() { if (currentIndex.value == 2 && tabController.index != 2) { FocusScope.of(Get.context!).unfocus(); } currentIndex.value = tabController.index; }); lottieController = AnimationController(vsync: this) ..duration = const Duration(seconds: 2); final Map args = Get.arguments ?? {}; id = args['id']; _meetingTaskService.meetingId = id; WidgetsBinding.instance.addObserver(this); _getDataDetails(); _loadJS(); _tasktypeListener = ever(_meetingTaskService.tasktype, (value) { meetingDetails.value.tasktype = value; // ⚠️ 只拿**有内容**的值。MeetingTaskService 那几个字段是「上一次轮询到 // 本条记录时」填的,没轮询到过就是空的;原来无条件覆盖,refreshFromServer // 刚拉回来的总结/转写会被这里的空值当场抹掉(本地库是对的,所以退出重进 // 又有了)。原先那个 _everType 门槛也一并去掉:它是按「进页面时的状态」定的, // 服务端跑得比本地快一步就把数据分支整个跳过。 if (_meetingTaskService.meetingId == id) { if (value >= 3 && value < 10000 && _meetingTaskService.textList.isNotEmpty) { textList.value = List.from(_meetingTaskService.textList); } if (value >= 5 && value < 10000 && _meetingTaskService.summary.isNotEmpty) { meetingDetails.value.personnel = _meetingTaskService.personnel; meetingDetails.value.summary = _meetingTaskService.summary; meetingDetails.value.overview = _meetingTaskService.overview; summaryNumberModifications.value++; } } meetingDetails.refresh(); _syncProgressPolling(); }); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // 切后台回来补拉一次:全局轮询可能已被冻结/清空,这是最常见的「卡在转写中」场景 if (state == AppLifecycleState.resumed) unawaited(refreshFromServer()); } @override void onClose() { WidgetsBinding.instance.removeObserver(this); _refreshTimer?.cancel(); _refreshTimer = null; _tasktypeListener.dispose(); _currentDurationSubscription?.cancel(); _playerStateSubscription?.cancel(); tabController.dispose(); playerController.dispose(); lottieController.dispose(); super.onClose(); } void popInvokedWithResult(didPop, result) async { _meetingUploadService.removeUpload(id); _meetingTaskService.meetingId = 0; } void _getDataDetails() async { final data = await SqfliteApi.getMeetingDetails(id); meetingData.value = MeetingModel.fromJson(data); meetingDetails.value = MeetingDetailsModel.fromJson(data['details']); // tasktype 在 meeting / meetingdetails 两张表里各存一份 // (见 [SqfliteApi.setMeetingTaskType]),**以 meeting 表那份为准**: // 它是历史上唯一每次状态变化都会写的,meetingdetails 那份在旧版本里 // 只有里程碑才更新,存量记录可能还停在 0。不取这一份的话, // 一条正在转写的记录点进来会既没有等待态、也没有内容,只剩空白。 meetingDetails.value.tasktype = meetingData.value.tasktype; _meetingTaskService.tasktype.value = meetingDetails.value.tasktype; if (meetingDetails.value.tasktype == 5) { await _meetingTaskService.readTask(id); } if (meetingDetails.value.tasktype >= 5 && meetingDetails.value.tasktype < 10000) { dragOffset.value = 0; isTop.value = true; } if (meetingData.value.filepath.isNotEmpty) { await _loadPlayerController(); } if (meetingDetails.value.tasktype >= 3 && meetingDetails.value.tasktype < 10000) { List speakerList = await SqfliteApi.getMeetingSpeaker(id); textList.value = List.from(speakerList); } isLoading.value = false; _syncProgressPolling(); // 本地先出,服务端整条再覆盖(不 await:页面已经能用了) unawaited(refreshFromServer()); } // ---------- 进行中状态的自我刷新 ---------- // // 本页原来一辈子只向服务端拉一次数据,之后完全依赖 MeetingTaskService 那个它既不 // 拥有、也无法重启的全局轮询:轮询队列会被首页退出 (stopTask) 清空、被 Synchrodata // 整体替换、进程被冻结时定时器也不走——任何一条命中,页面就永远停在「正在转写…」, // 退出重进才好(2026-09-20 苹果测试原话)。现在进行中就自己每 6s 拉一次, // 到终态/失败态即停;切后台回来也补拉一次;占位图上还给了一个手动刷新。 Timer? _refreshTimer; bool _refreshing = false; static const Duration _progressPollInterval = Duration(seconds: 6); bool get _inProgress { final t = meetingDetails.value.tasktype; return t > 0 && t < 5; } void _syncProgressPolling() { if (isClosed) return; if (_inProgress) { _refreshTimer ??= Timer.periodic(_progressPollInterval, (_) { unawaited(refreshFromServer()); }); } else { _refreshTimer?.cancel(); _refreshTimer = null; } } /// 用服务端整条记录刷新本地缓存与页面。 /// /// 列表接口只回骨架(见 Synchrodata),转写和总结**只从这里进本地库**: /// 每次进详情拉一次 `echomeet_getrecord`,服务端是真源,别的设备重新生成的总结、 /// 改过的标题都在这一步跟过来。拉不到就沿用本地,不报错。并发调用直接返回。 Future refreshFromServer() async { if (_refreshing || isClosed) return; _refreshing = true; try { await _refreshFromServer(); } finally { _refreshing = false; } } Future _refreshFromServer() async { dynamic resp; try { resp = await Api.getRecordEchomeet({'id': id}); } catch (e) { Logger.w('MeetingDetails', '拉取整条记录失败(沿用本地): $e'); return; } final record = resp?['record']; if (record is! Map) return; if (isClosed) return; final int state = record['state'] ?? meetingDetails.value.tasktype; final String title = record['title'] ?? meetingData.value.title; final String audiourl = record['audiourl'] ?? meetingData.value.audiourl; final String summary = record['summary'] ?? ''; final String overview = record['overview'] ?? ''; final String personnel = record['personnel'] ?? ''; await SqfliteApi.editMeetingTitle(id, { 'title': title, 'audiourl': audiourl, 'seconds': record['seconds'] ?? meetingData.value.seconds, }); await SqfliteApi.setMeetingTaskType(id, state); // 转写:translate 是服务端翻译/整理后的版本(无翻译时等于 original), // 键与 meetingspeaker 列一一对应(meetingid/content/starttime/endtime/speaker) final speakers = _decodeSpeakers(record['translate']); if (speakers != null) { await SqfliteApi.deleteMeetingSpeaker(id); await SqfliteApi.insertMeetingSpeaker(speakers); } if (state >= 5 && state < 10000) { await SqfliteApi.editMeeting(id, { 'personnel': personnel, 'summary': summary, 'overview': overview, }); } if (isClosed) return; meetingData.value.title = title; meetingData.value.audiourl = audiourl; meetingData.value.tasktype = state; meetingData.refresh(); if (speakers != null && state >= 3 && state < 10000) { textList.value = List.from(await SqfliteApi.getMeetingSpeaker(id)); } final bool summaryChanged = state >= 5 && state < 10000 && (meetingDetails.value.summary != summary || meetingDetails.value.overview != overview || meetingDetails.value.personnel != personnel); if (summaryChanged) { meetingDetails.value.summary = summary; meetingDetails.value.overview = overview; meetingDetails.value.personnel = personnel; summaryNumberModifications.value++; } if (meetingDetails.value.tasktype != state) { // 先把服务端拿到的内容喂给 MeetingTaskService,再推状态: // 推状态会触发 onInit 里那个 ever 监听,它从 service 里取内容—— // 不先喂的话它取到的是空值(见那里的说明)。 if (_meetingTaskService.meetingId == id) { if (state >= 3 && state < 10000 && textList.isNotEmpty) { _meetingTaskService.textList = List.from(textList); } if (state >= 5 && state < 10000) { _meetingTaskService.personnel = personnel; _meetingTaskService.summary = summary; _meetingTaskService.overview = overview; } } meetingDetails.value.tasktype = state; _meetingTaskService.tasktype.value = state; } meetingDetails.refresh(); _syncProgressPolling(); } List>? _decodeSpeakers(dynamic raw) { if (raw is! String || raw.trim().isEmpty) return null; try { final decoded = jsonDecode(raw); if (decoded is! List || decoded.isEmpty) return null; return decoded.map((e) => Map.from(e as Map)).toList(); } catch (e) { Logger.w('MeetingDetails', 'translate 解析失败,转写沿用本地: $e'); return null; } } Future _loadPlayerController() async { try { final filepath = meetingData.value.filepath; // 验证文件路径 if (filepath.isEmpty) { Logger.error('音频文件路径为空,无法加载播放器'); isPlayerReady.value = false; return; } // 验证文件是否存在 final file = File(filepath); if (!await file.exists()) { Logger.error('音频文件不存在: $filepath'); isPlayerReady.value = false; // 本地文件缺失:若云端存在音频,清空本地路径并持久化, // 让 UI 自动切换为"下载"按钮,下载完成后即可播放; // 仅当云端也没有音频时才提示文件不存在 if (meetingData.value.audiourl.isNotEmpty) { Logger.d('MeetingDetails', '本地音频缺失,切换为下载态(云端URL可用)'); meetingData.value.filepath = ''; meetingData.refresh(); await SqfliteApi.editMeetingTitle(id, {'filePath': ''}); } else { Get.snackbar( 'error'.tr, 'audioFileNotFound'.tr, // 音频文件不存在 snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 3), ); } return; } Logger.d('MeetingDetails', '开始加载音频播放器: $filepath'); // iOS平台特殊处理:配置音频会话 if (Platform.isIOS) { try { Logger.d('MeetingDetails', 'iOS平台:开始配置音频会话'); final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration( avAudioSessionCategory: AVAudioSessionCategory.playback, avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers, avAudioSessionMode: AVAudioSessionMode.defaultMode, avAudioSessionRouteSharingPolicy: AVAudioSessionRouteSharingPolicy.defaultPolicy, avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.music, usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gain, androidWillPauseWhenDucked: true, )); Logger.d('MeetingDetails', 'iOS平台:音频会话配置成功'); } catch (e) { Logger.error('iOS平台:音频会话配置失败: $e'); // 继续执行,尝试播放 } } // 准备播放器 await playerController.preparePlayer( path: filepath, shouldExtractWaveform: false, noOfSamples: 88, ); // 获取音频时长 final duration = await playerController.getDuration(DurationType.max); // 校验时长有效性,防止无效值导致 DateUtil.formatDateMs 报错 // 有效的毫秒时间戳应该在合理范围内(大于0且小于未来100年) const int maxValidDuration = 315360000000; // 约10年的毫秒数 if (duration > 0 && duration < maxValidDuration) { durationMax.value = duration; } else { durationMax.value = 0; Logger.w('MeetingDetails', '获取到无效的音频时长: $duration,已重置为0'); } Logger.d('MeetingDetails', '音频播放器加载成功,时长: ${durationMax.value}ms'); // 设置播放完成模式 playerController.setFinishMode(finishMode: FinishMode.pause); // 监听播放进度 _currentDurationSubscription = playerController.onCurrentDurationChanged.listen((int position) { currentDuration.value = position; if (speakerAllEndtime.value > 0) { if (position >= speakerAllEndtime.value) { playerController.pausePlayer(); speakerAllStarttime.value = -1; speakerAllEndtime.value = -1; } } }); // 监听播放状态 _playerStateSubscription = playerController.onPlayerStateChanged .listen((PlayerState playerState) { isPlay.value = playerState.isPlaying; if (playerState.isPlaying) { lottieController.repeat(); } else { lottieController.stop(); } }); // 标记播放器已准备好 isPlayerReady.value = true; } catch (e, stackTrace) { Logger.error('加载音频播放器失败: $e'); Logger.error('堆栈跟踪: $stackTrace'); isPlayerReady.value = false; Get.snackbar( 'error'.tr, 'playerLoadFailed'.tr, // 音频播放器加载失败 snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 3), ); } } void _loadJS() async { markmapLibJs = await rootBundle.loadString('assets/js/markmap-lib.js'); d3Js = await rootBundle.loadString('assets/js/d3@7.js'); markmapViewJs = await rootBundle.loadString('assets/js/markmap-view.js'); markmapToolbarJs = await rootBundle.loadString('assets/js/markmap-toolbar.js'); saveSvgAsPngJs = await rootBundle.loadString('assets/js/saveSvgAsPng.min.js'); } void downloadAudio() async { if (isDownload.value) return; isDownload.value = true; final directory = await getApplicationDocumentsDirectory(); final directoryDir = Directory('${directory.path}/DownloadAudio'); if (!await directoryDir.exists()) { await directoryDir.create(recursive: true); } final fileName = meetingData.value.audiourl.split('/').last; final String filePath = '${directory.path}/DownloadAudio/$fileName'; await Dio().download( meetingData.value.audiourl, filePath, onReceiveProgress: (int received, int total) { downloadProgress.value = total > 0 ? received / total : 0.0; }, ); meetingData.value.filepath = filePath; meetingData.refresh(); _loadPlayerController(); SqfliteApi.editMeetingTitle(id, {'filePath': filePath}); isDownload.value = false; } /// 从云端下载音频文件(用于分享/导出场景) /// 与 [downloadAudio] 的区别:有返回值,便于调用方判断是否成功 /// /// 返回值:下载成功的本地文件路径;失败时返回 null Future downloadAudioForShare() async { final audioUrl = meetingData.value.audiourl; if (audioUrl.isEmpty) { Logger.error('云端音频URL为空,无法从云端下载'); return null; } try { final directory = await getApplicationDocumentsDirectory(); final directoryDir = Directory('${directory.path}/DownloadAudio'); if (!await directoryDir.exists()) { await directoryDir.create(recursive: true); } final fileName = audioUrl.split('/').last; final String filePath = '${directory.path}/DownloadAudio/$fileName'; await Dio().download( audioUrl, filePath, onReceiveProgress: (int received, int total) { downloadProgress.value = total > 0 ? received / total : 0.0; }, ); // 更新本地路径并持久化 meetingData.value.filepath = filePath; meetingData.refresh(); SqfliteApi.editMeetingTitle(id, {'filePath': filePath}); // 若播放器尚未初始化,顺带加载(异步,不阻塞分享流程) if (!isPlayerReady.value) { _loadPlayerController(); } Logger.d('MeetingDetails', '云端音频下载成功: $filePath'); return filePath; } catch (e) { Logger.error('从云端下载音频失败: $e'); return null; } } void editSpeakerText() async { if (editSpeakerTextId.value != 0 && editSpeakerTextChange.isNotEmpty) { Map? foundElement = editSpeakerTextList.firstWhere( (element) => element['id'] == editSpeakerTextId.value, orElse: () => null, ); if (foundElement != null) { foundElement['content'] = editSpeakerTextChange; } else { editSpeakerTextList.add({ 'id': editSpeakerTextId.value, 'content': editSpeakerTextChange, }); } } if (editSpeakerTextList.isNotEmpty) { EasyLoading.show(); var result = await SqfliteApi.editMeetingSpeaker(editSpeakerTextList); if (result != null) { for (var element in editSpeakerTextList) { int index = textList.indexWhere((item) => item['id'] == element['id']); if (index != -1) { final dataMap = Map.from(textList[index]); dataMap['content'] = element['content']; textList[index] = dataMap; } } await Api.modifyRecordEchomeet({ 'id': id, 'translate': jsonEncode(textList), }); isSpeakerText.value = false; editSpeakerTextId.value = 0; editSpeakerTextList = []; } EasyLoading.dismiss(); } else { isSpeakerText.value = false; editSpeakerTextId.value = 0; editSpeakerTextList = []; } } void summaryCreate( String formlanguage, String tolanguage, bool isdistinguishspeaker, int templateid, String tid, ) async { EasyLoading.show(); final String audiourl = meetingData.value.audiourl; final bool accepted = await _meetingTaskService.addTask( id, audiourl, formlanguage, tolanguage, isdistinguishspeaker, templateid, tid, ); EasyLoading.dismiss(); // ⚠️ 没受理(服务端拒绝 / 上传队列里找不到这条)就别把页面切成「等待转写」: // 那样页面会永远停在占位图上等一个根本不存在的任务。 if (!accepted) { Logger.w('MeetingDetails', '生成任务未受理 id=$id,页面保持未处理'); Get.snackbar('error'.tr, 'networkErrorRetry'.tr, snackPosition: SnackPosition.TOP); return; } meetingDetails.value.tasktype = 1; meetingDetails.refresh(); _syncProgressPolling(); } void refreshSummary(int templateid, String tid, String tolanguage) async { EasyLoading.show(); await _meetingTaskService.refreshTask(id, templateid, tid, tolanguage); meetingDetails.value.tasktype = 3; meetingDetails.refresh(); _syncProgressPolling(); EasyLoading.dismiss(); } }