|
|
|
@ -8,6 +8,7 @@ import '/core/utils/overlay_permission_util.dart'; |
|
|
|
import '/core/utils/permission_util.dart'; |
|
|
|
import 'package:audio_waveforms/audio_waveforms.dart'; |
|
|
|
import 'package:flutter/material.dart'; |
|
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
|
|
import 'package:get/get.dart'; |
|
|
|
import 'package:intl/intl.dart'; |
|
|
|
import 'package:floating_ui_plugin/native_plugin.dart'; |
|
|
|
@ -28,14 +29,20 @@ enum TimerStatus { |
|
|
|
/// 会议记录控制器 |
|
|
|
/// 负责会议记录的状态管理、音频处理、语音识别和UI交互 |
|
|
|
class MeetingRecordController extends GetxController |
|
|
|
with GetTickerProviderStateMixin { |
|
|
|
with GetTickerProviderStateMixin, WidgetsBindingObserver { |
|
|
|
static const int _backgroundRecordingNotificationId = 91001; |
|
|
|
|
|
|
|
static FlutterLocalNotificationsPlugin? _localNotificationsPlugin; |
|
|
|
final NativePlugin _nativePlugin = NativePlugin(); |
|
|
|
Timer? _recordingFloatingBarTimer; |
|
|
|
Timer? _routeMonitorTimer; |
|
|
|
StreamSubscription<String>? _recordingFloatingActionSubscription; |
|
|
|
bool _floatingBarManuallyClosed = false; |
|
|
|
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; |
|
|
|
String _lastKnownRoute = ''; |
|
|
|
|
|
|
|
OverlayEntry? _recordingInAppOverlay; |
|
|
|
Offset? _recordingInAppOverlayOffset; |
|
|
|
|
|
|
|
Future<FlutterLocalNotificationsPlugin> |
|
|
|
_ensureLocalNotificationsPlugin() async { |
|
|
|
@ -76,8 +83,15 @@ class MeetingRecordController extends GetxController |
|
|
|
await _requestNotificationPermissionIfNeeded(); |
|
|
|
if (!Platform.isIOS) return; |
|
|
|
|
|
|
|
if (_floatingBarManuallyClosed) return; |
|
|
|
if (_appLifecycleState == AppLifecycleState.resumed) { |
|
|
|
await _showInAppRecordingOverlay(); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
_hideInAppRecordingOverlay(); |
|
|
|
final plugin = await _ensureLocalNotificationsPlugin(); |
|
|
|
final details = NotificationDetails( |
|
|
|
const details = NotificationDetails( |
|
|
|
iOS: DarwinNotificationDetails( |
|
|
|
presentAlert: true, |
|
|
|
presentBadge: false, |
|
|
|
@ -102,6 +116,8 @@ class MeetingRecordController extends GetxController |
|
|
|
if (!Platform.isIOS) return; |
|
|
|
final plugin = await _ensureLocalNotificationsPlugin(); |
|
|
|
await plugin.cancel(_backgroundRecordingNotificationId); |
|
|
|
_floatingBarManuallyClosed = false; |
|
|
|
_hideInAppRecordingOverlay(); |
|
|
|
} |
|
|
|
|
|
|
|
String _formatTimeForFloatingBar(int ms) { |
|
|
|
@ -154,7 +170,7 @@ class MeetingRecordController extends GetxController |
|
|
|
_recordingFloatingBarTimer = |
|
|
|
Timer.periodic(const Duration(seconds: 1), (_) { |
|
|
|
if (!isRecording.value) { |
|
|
|
_stopAndroidRecordingFloatingBar(); |
|
|
|
unawaited(_stopAndroidRecordingFloatingBar()); |
|
|
|
return; |
|
|
|
} |
|
|
|
_nativePlugin.updateRecordingFloatingBar( |
|
|
|
@ -262,15 +278,23 @@ class MeetingRecordController extends GetxController |
|
|
|
_initializeComponents(); |
|
|
|
_setupFromArguments(); |
|
|
|
_setupRecordingFloatingBarActionListener(); |
|
|
|
WidgetsBinding.instance.addObserver(this); |
|
|
|
//generateWaveData(); |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
void onClose() { |
|
|
|
_cleanupResources(); |
|
|
|
WidgetsBinding.instance.removeObserver(this); |
|
|
|
super.onClose(); |
|
|
|
} |
|
|
|
|
|
|
|
@override |
|
|
|
void didChangeAppLifecycleState(AppLifecycleState state) { |
|
|
|
_appLifecycleState = state; |
|
|
|
unawaited(_refreshRecordingIndicator()); |
|
|
|
} |
|
|
|
|
|
|
|
void applyRouteArgumentsIfIdle() { |
|
|
|
if (isEditingTitle.value) return; |
|
|
|
if (isRecording.value) return; |
|
|
|
@ -332,9 +356,12 @@ class MeetingRecordController extends GetxController |
|
|
|
/// 控制器销毁时清理资源 |
|
|
|
void _cleanupResources() { |
|
|
|
_timer?.cancel(); |
|
|
|
_stopAndroidRecordingFloatingBar(); |
|
|
|
unawaited(_stopAndroidRecordingFloatingBar()); |
|
|
|
_routeMonitorTimer?.cancel(); |
|
|
|
_routeMonitorTimer = null; |
|
|
|
_recordingFloatingActionSubscription?.cancel(); |
|
|
|
_recordingFloatingActionSubscription = null; |
|
|
|
_hideInAppRecordingOverlay(); |
|
|
|
_stopWaveformTimer(); |
|
|
|
_recognitionSubscription?.cancel(); |
|
|
|
_recognitionSubscription = null; |
|
|
|
@ -472,6 +499,7 @@ class MeetingRecordController extends GetxController |
|
|
|
_resetWaveform(); |
|
|
|
_startWaveformTimer(); |
|
|
|
startTimer(); |
|
|
|
await _refreshRecordingIndicator(); |
|
|
|
} |
|
|
|
|
|
|
|
/// 根据音频类型启动对应的硬件服务 |
|
|
|
@ -497,6 +525,7 @@ class MeetingRecordController extends GetxController |
|
|
|
_stopWaveformTimer(); |
|
|
|
rms.value = 0.0; |
|
|
|
pauseTimer(); |
|
|
|
await _refreshRecordingIndicator(); |
|
|
|
} |
|
|
|
|
|
|
|
/// 恢复已暂停的录音会话 |
|
|
|
@ -504,6 +533,7 @@ class MeetingRecordController extends GetxController |
|
|
|
await _asrService.resumeRecord(); |
|
|
|
_startWaveformTimer(); |
|
|
|
startTimer(); |
|
|
|
await _refreshRecordingIndicator(); |
|
|
|
} |
|
|
|
|
|
|
|
/// 处理原始Uint8List音频数据 |
|
|
|
@ -609,6 +639,7 @@ class MeetingRecordController extends GetxController |
|
|
|
_smoothedRms = 0.0; |
|
|
|
_latestRms = 0.0; |
|
|
|
_resetWaveform(); |
|
|
|
await _refreshRecordingIndicator(); |
|
|
|
} |
|
|
|
|
|
|
|
Future<void> discardRecording() async { |
|
|
|
@ -627,6 +658,7 @@ class MeetingRecordController extends GetxController |
|
|
|
_smoothedRms = 0.0; |
|
|
|
_latestRms = 0.0; |
|
|
|
_resetWaveform(); |
|
|
|
await _refreshRecordingIndicator(); |
|
|
|
} |
|
|
|
|
|
|
|
/// 将控制器重置到初始状态 |
|
|
|
@ -692,4 +724,217 @@ class MeetingRecordController extends GetxController |
|
|
|
seconds.value = 0; |
|
|
|
timerStatus.value = TimerStatus.stopped; |
|
|
|
} |
|
|
|
|
|
|
|
bool _shouldShowRecordingIndicator() { |
|
|
|
if (!isRecording.value) return false; |
|
|
|
if (_appLifecycleState != AppLifecycleState.resumed) return true; |
|
|
|
return Get.currentRoute != Routes.meetingRecord; |
|
|
|
} |
|
|
|
|
|
|
|
Future<void> _refreshRecordingIndicator() async { |
|
|
|
if (!isRecording.value) { |
|
|
|
_routeMonitorTimer?.cancel(); |
|
|
|
_routeMonitorTimer = null; |
|
|
|
_hideInAppRecordingOverlay(); |
|
|
|
await _stopAndroidRecordingFloatingBar(); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
_startRouteMonitorIfNeeded(); |
|
|
|
final shouldShow = _shouldShowRecordingIndicator(); |
|
|
|
if (!shouldShow) { |
|
|
|
_hideInAppRecordingOverlay(); |
|
|
|
await _stopAndroidRecordingFloatingBar(); |
|
|
|
if (Platform.isIOS) { |
|
|
|
final plugin = await _ensureLocalNotificationsPlugin(); |
|
|
|
await plugin.cancel(_backgroundRecordingNotificationId); |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (_floatingBarManuallyClosed) return; |
|
|
|
|
|
|
|
if (Platform.isAndroid) { |
|
|
|
await showBackgroundRecordingIndicator(); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
if (Platform.isIOS) { |
|
|
|
if (_appLifecycleState == AppLifecycleState.resumed) { |
|
|
|
final plugin = await _ensureLocalNotificationsPlugin(); |
|
|
|
await plugin.cancel(_backgroundRecordingNotificationId); |
|
|
|
await _showInAppRecordingOverlay(); |
|
|
|
} else { |
|
|
|
await showBackgroundRecordingIndicator(); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
void _startRouteMonitorIfNeeded() { |
|
|
|
if (_routeMonitorTimer != null) return; |
|
|
|
_lastKnownRoute = Get.currentRoute; |
|
|
|
_routeMonitorTimer = Timer.periodic(const Duration(milliseconds: 400), (_) { |
|
|
|
final currentRoute = Get.currentRoute; |
|
|
|
if (currentRoute == _lastKnownRoute) return; |
|
|
|
_lastKnownRoute = currentRoute; |
|
|
|
unawaited(_refreshRecordingIndicator()); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
Future<void> _showInAppRecordingOverlay() async { |
|
|
|
if (!Platform.isIOS) return; |
|
|
|
if (_recordingInAppOverlay != null) return; |
|
|
|
final context = Get.overlayContext; |
|
|
|
if (context == null) return; |
|
|
|
|
|
|
|
final mediaQuery = MediaQuery.of(context); |
|
|
|
final screenSize = mediaQuery.size; |
|
|
|
final safeTop = mediaQuery.padding.top; |
|
|
|
final safeBottom = mediaQuery.padding.bottom; |
|
|
|
|
|
|
|
final width = 260.w; |
|
|
|
final height = 52.h; |
|
|
|
final initial = _recordingInAppOverlayOffset ?? |
|
|
|
Offset(16.w, max(16.h + safeTop, 100.h)); |
|
|
|
_recordingInAppOverlayOffset = Offset( |
|
|
|
initial.dx.clamp(8.w, max(8.w, screenSize.width - width - 8.w)), |
|
|
|
initial.dy.clamp( |
|
|
|
8.h + safeTop, |
|
|
|
max(8.h + safeTop, screenSize.height - height - 8.h - safeBottom), |
|
|
|
), |
|
|
|
); |
|
|
|
|
|
|
|
_recordingInAppOverlay = OverlayEntry( |
|
|
|
builder: (_) { |
|
|
|
final offset = _recordingInAppOverlayOffset ?? Offset(16.w, 120.h); |
|
|
|
return Positioned( |
|
|
|
left: offset.dx, |
|
|
|
top: offset.dy, |
|
|
|
child: Material( |
|
|
|
color: Colors.transparent, |
|
|
|
child: GestureDetector( |
|
|
|
onPanUpdate: (details) { |
|
|
|
final next = Offset( |
|
|
|
offset.dx + details.delta.dx, |
|
|
|
offset.dy + details.delta.dy, |
|
|
|
); |
|
|
|
_recordingInAppOverlayOffset = Offset( |
|
|
|
next.dx.clamp(8.w, max(8.w, screenSize.width - width - 8.w)), |
|
|
|
next.dy.clamp( |
|
|
|
8.h + safeTop, |
|
|
|
max( |
|
|
|
8.h + safeTop, |
|
|
|
screenSize.height - height - 8.h - safeBottom, |
|
|
|
), |
|
|
|
), |
|
|
|
); |
|
|
|
_recordingInAppOverlay?.markNeedsBuild(); |
|
|
|
}, |
|
|
|
child: Container( |
|
|
|
width: width, |
|
|
|
height: height, |
|
|
|
padding: EdgeInsets.symmetric(horizontal: 10.w), |
|
|
|
decoration: BoxDecoration( |
|
|
|
color: Colors.black.withValues(alpha: 0.82), |
|
|
|
borderRadius: BorderRadius.circular(26.r), |
|
|
|
boxShadow: [ |
|
|
|
BoxShadow( |
|
|
|
color: Colors.black.withValues(alpha: 0.25), |
|
|
|
blurRadius: 10.r, |
|
|
|
offset: const Offset(0, 4), |
|
|
|
), |
|
|
|
], |
|
|
|
), |
|
|
|
child: Row( |
|
|
|
children: [ |
|
|
|
Icon( |
|
|
|
Icons.mic_rounded, |
|
|
|
size: 18.sp, |
|
|
|
color: Colors.white, |
|
|
|
), |
|
|
|
SizedBox(width: 8.w), |
|
|
|
Expanded( |
|
|
|
child: Obx(() { |
|
|
|
final durationText = |
|
|
|
_formatTimeForFloatingBar(milliseconds.value); |
|
|
|
final typeText = _getRecordingTypeText(); |
|
|
|
return Text( |
|
|
|
'$durationText · $typeText', |
|
|
|
maxLines: 1, |
|
|
|
overflow: TextOverflow.ellipsis, |
|
|
|
style: TextStyle( |
|
|
|
color: Colors.white, |
|
|
|
fontSize: 12.sp, |
|
|
|
fontWeight: FontWeight.w600, |
|
|
|
), |
|
|
|
); |
|
|
|
}), |
|
|
|
), |
|
|
|
SizedBox(width: 6.w), |
|
|
|
Obx(() { |
|
|
|
final paused = timerStatus.value == TimerStatus.paused; |
|
|
|
return InkWell( |
|
|
|
onTap: togglePauseResumeFromFloatingBar, |
|
|
|
borderRadius: BorderRadius.circular(18.r), |
|
|
|
child: Padding( |
|
|
|
padding: EdgeInsets.all(6.w), |
|
|
|
child: Icon( |
|
|
|
paused |
|
|
|
? Icons.play_arrow_rounded |
|
|
|
: Icons.pause_rounded, |
|
|
|
size: 18.sp, |
|
|
|
color: Colors.white, |
|
|
|
), |
|
|
|
), |
|
|
|
); |
|
|
|
}), |
|
|
|
InkWell( |
|
|
|
onTap: () { |
|
|
|
_floatingBarManuallyClosed = false; |
|
|
|
if (Get.currentRoute == Routes.meetingRecord) return; |
|
|
|
Get.toNamed( |
|
|
|
Routes.meetingRecord, |
|
|
|
arguments: {'audioTypes': audioType.value}, |
|
|
|
); |
|
|
|
}, |
|
|
|
borderRadius: BorderRadius.circular(18.r), |
|
|
|
child: Padding( |
|
|
|
padding: EdgeInsets.all(6.w), |
|
|
|
child: Icon( |
|
|
|
Icons.open_in_new_rounded, |
|
|
|
size: 18.sp, |
|
|
|
color: Colors.white, |
|
|
|
), |
|
|
|
), |
|
|
|
), |
|
|
|
InkWell( |
|
|
|
onTap: () { |
|
|
|
_floatingBarManuallyClosed = true; |
|
|
|
_hideInAppRecordingOverlay(); |
|
|
|
}, |
|
|
|
borderRadius: BorderRadius.circular(18.r), |
|
|
|
child: Padding( |
|
|
|
padding: EdgeInsets.all(6.w), |
|
|
|
child: Icon( |
|
|
|
Icons.close_rounded, |
|
|
|
size: 18.sp, |
|
|
|
color: Colors.white70, |
|
|
|
), |
|
|
|
), |
|
|
|
), |
|
|
|
], |
|
|
|
), |
|
|
|
), |
|
|
|
), |
|
|
|
), |
|
|
|
); |
|
|
|
}, |
|
|
|
); |
|
|
|
Overlay.of(context, rootOverlay: true).insert(_recordingInAppOverlay!); |
|
|
|
} |
|
|
|
|
|
|
|
void _hideInAppRecordingOverlay() { |
|
|
|
_recordingInAppOverlay?.remove(); |
|
|
|
_recordingInAppOverlay = null; |
|
|
|
} |
|
|
|
} |
|
|
|
|