Browse Source

feat(录音): 增加录音悬浮窗显示逻辑,录音页面不显示,此外都显示

实现应用生命周期状态监听,优化录音状态指示器的显示逻辑
移除未使用的波形着色器代码,简化视图层实现
将底部弹窗的insets参数从视图层传递到控制器
newdev_shunjiawei
lxm 9 months ago
parent
commit
2d709f9cd0
  1. 253
      lib/modules/meeting/controllers/meeting_record_controller.dart
  2. 21
      lib/modules/meeting/views/meeting_record_view.dart

253
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -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;
}
}

21
lib/modules/meeting/views/meeting_record_view.dart

@ -367,17 +367,6 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
final size = Size(screenWidth, 200.h);
final waveColor =
isDarkMode ? Colors.white : Colors.black87;
final shader = ui.Gradient.linear(
const Offset(0, 0),
Offset(size.width, 0),
[
waveColor.withValues(alpha: 0.0),
waveColor.withValues(alpha: 0.25),
waveColor.withValues(alpha: 0.6),
waveColor.withValues(alpha: 0.95),
],
const [0.0, 0.55, 0.8, 1.0],
);
return Center(
child: AudioWaveforms(
@ -399,7 +388,6 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
showTop: true,
showBottom: true,
extendWaveform: true,
// gradient: shader,
waveColor: waveColor,
scaleFactor: 90,
),
@ -517,7 +505,10 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
// 完成按钮
_buildActionButton(
onTap: () => _bottomSheet(context, isDarkMode),
onTap: () => _bottomSheet(
isDarkMode,
MediaQuery.of(context).viewInsets.bottom,
),
child: Icon(
Icons.check_rounded,
size: 36.sp,
@ -616,7 +607,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
}
// 底部编辑弹窗
void _bottomSheet(BuildContext context, bool isDarkMode) async {
void _bottomSheet(bool isDarkMode, double bottomInset) async {
//如果当前计时器在运行,先暂停录音,避免编辑时继续录音
if (controller.timerStatus.value == TimerStatus.running) {
await controller.toggleRecording();
@ -628,7 +619,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
left: 20.w,
right: 20.w,
top: 20.w,
bottom: MediaQuery.of(context).viewInsets.bottom + 20.w,
bottom: bottomInset + 20.w,
),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.darkBackground : Colors.white,

Loading…
Cancel
Save