From 833be0f04bfe997d5278a05ca2f183cc9b5e515c Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Thu, 27 Nov 2025 00:40:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(live):=20=E5=AE=9E=E7=8E=B0=E5=AE=9E?= =?UTF-8?q?=E5=90=8D=E8=AE=A4=E8=AF=81=E5=8C=B9=E9=85=8D=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E7=9B=B4=E6=92=AD=E9=97=B4=E5=8A=A8?= =?UTF-8?q?=E6=95=88=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增销毁 RTC 频道接口及对应网络请求实现 - 优化直播间礼物弹窗界面,替换为 GridView 并设置默认选中项 - 完善 SVGA 动画播放逻辑,支持队列播放和播放完成回调 - 调整直播间用户展示逻辑,区分左右侧观众身份判断 - 移除无用日志打印和冗余依赖包引用 - 修复主播离线时频道销毁流程,确保先调用销毁接口再发送结束消息 - 引入 SvgaPlayerWidget 组件用于直播间动效展示 - 优化实名认证判断逻辑,增强代码可读性 --- lib/controller/discover/room_controller.dart | 46 +++-- lib/network/api_urls.dart | 2 + lib/network/rtc_api.dart | 4 + lib/network/rtc_api.g.dart | 31 +++ lib/pages/discover/live_room_page.dart | 4 +- lib/widget/live/live_gift_popup.dart | 108 +++------- .../live/live_room_anchor_showcase.dart | 6 +- lib/widget/live/svga_player_widget.dart | 185 +++++++++++++++--- 8 files changed, 252 insertions(+), 134 deletions(-) diff --git a/lib/controller/discover/room_controller.dart b/lib/controller/discover/room_controller.dart index 984dc77..6cc90d7 100644 --- a/lib/controller/discover/room_controller.dart +++ b/lib/controller/discover/room_controller.dart @@ -36,6 +36,7 @@ class RoomController extends GetxController { CurrentRole currentRole = CurrentRole.normalUser; var isLive = false.obs; var matchmakerFlag = false.obs; + /// 当前频道信息 final Rxn rtcChannel = Rxn(); final Rxn rtcChannelDetail = Rxn(); @@ -47,7 +48,8 @@ class RoomController extends GetxController { final RxList giftProducts = [].obs; /// 消息服务实例 - final LiveChatMessageService _messageService = LiveChatMessageService.instance; + final LiveChatMessageService _messageService = + LiveChatMessageService.instance; // matchmakerFlag @@ -107,7 +109,7 @@ class RoomController extends GetxController { /// 调用接口创建 RTC 频道 Future createRtcChannel() async { - if(isLive.value){ + if (isLive.value) { return; } final granted = await _ensureRtcPermissions(); @@ -213,8 +215,12 @@ class RoomController extends GetxController { final newDetail = RtcChannelDetail( channelId: rtcChannelDetail.value!.channelId, anchorInfo: rtcChannelDetail.value!.anchorInfo, - maleInfo: role == CurrentRole.maleAudience ? userInfo : null, - femaleInfo: role == CurrentRole.femaleAudience ? userInfo : null, + maleInfo: role == CurrentRole.maleAudience + ? userInfo + : rtcChannelDetail.value?.maleInfo, + femaleInfo: role == CurrentRole.femaleAudience + ? userInfo + : rtcChannelDetail.value?.femaleInfo, ); rtcChannelDetail.value = newDetail; isLive.value = true; @@ -296,15 +302,27 @@ class RoomController extends GetxController { } Future leaveChannel() async { - // 如果是主播,发送结束直播消息 + // 如果是主播,先销毁 RTC 频道,然后发送结束直播消息 if (currentRole == CurrentRole.broadcaster) { - final channelId = RTCManager.instance.currentChannelId; - if (channelId != null && channelId.isNotEmpty) { - await RTMManager.instance.publishChannelMessage( - channelName: channelId, - message: json.encode({'type': 'end_live'}), - ); + try { + // 先调用销毁 RTC 频道 API + final destroyResponse = await _networkService.rtcApi + .destroyRtcChannel(); + if (destroyResponse.data.isSuccess) { + // 然后发送结束直播消息 + final channelId = RTCManager.instance.currentChannelId; + if (channelId != null && channelId.isNotEmpty) { + await RTMManager.instance.publishChannelMessage( + channelName: channelId, + message: json.encode({'type': 'end_live'}), + ); + } + } + } catch (e) { + print('❌ 销毁 RTC 频道异常: $e'); } + + } isLive.value = false; @@ -486,13 +504,13 @@ class RoomController extends GetxController { } } - void registerMatch(){ - if(GlobalData().userData!.identityCard != null && GlobalData().userData!.identityCard!.isNotEmpty){ + void registerMatch() { + if (GlobalData().userData!.identityCard != null && + GlobalData().userData!.identityCard!.isNotEmpty) { Get.to(() => MatchLeaguePage()); } else { SmartDialog.showToast('请先进行实名认证'); Get.to(() => RealNamePage(type: 1)); } } - } diff --git a/lib/network/api_urls.dart b/lib/network/api_urls.dart index 003242f..39f32e4 100644 --- a/lib/network/api_urls.dart +++ b/lib/network/api_urls.dart @@ -56,6 +56,8 @@ class ApiUrls { 'dating-agency-chat-audio/user/enable/rtc-channel-user/audio'; static const String disconnectRtcChannel = 'dating-agency-chat-audio/user/disconnect/rtc-channel'; + static const String destroyRtcChannel = + 'dating-agency-chat-audio/user/destroy/rtc-channel'; static const String getRtcChannelPage = 'dating-agency-chat-audio/user/page/rtc-channel'; static const String listGiftProduct = diff --git a/lib/network/rtc_api.dart b/lib/network/rtc_api.dart index 6ec1931..7c2e928 100644 --- a/lib/network/rtc_api.dart +++ b/lib/network/rtc_api.dart @@ -59,6 +59,10 @@ abstract class RtcApi { @Body() Map data, ); + /// 销毁 RTC 频道 + @POST(ApiUrls.destroyRtcChannel) + Future>> destroyRtcChannel(); + /// 获取 RTC 频道分页列表 @GET(ApiUrls.getRtcChannelPage) Future>>> getRtcChannelPage(); diff --git a/lib/network/rtc_api.g.dart b/lib/network/rtc_api.g.dart index e9ee003..52bbfcb 100644 --- a/lib/network/rtc_api.g.dart +++ b/lib/network/rtc_api.g.dart @@ -287,6 +287,37 @@ class _RtcApi implements RtcApi { return httpResponse; } + @override + Future>> destroyRtcChannel() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + 'dating-agency-chat-audio/user/destroy/rtc-channel', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late BaseResponse _value; + try { + _value = BaseResponse.fromJson( + _result.data!, + (json) => json as dynamic, + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + final httpResponse = HttpResponse(_value, _result); + return httpResponse; + } + @override Future>>> getRtcChannelPage() async { diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index 29c566c..b4fb318 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/lib/pages/discover/live_room_page.dart @@ -1,6 +1,5 @@ import 'package:dating_touchme_app/controller/discover/room_controller.dart'; import 'package:dating_touchme_app/controller/overlay_controller.dart'; -import 'package:dating_touchme_app/generated/assets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; @@ -13,6 +12,7 @@ import 'package:dating_touchme_app/widget/live/live_room_notice_chat_panel.dart' import 'package:dating_touchme_app/widget/live/live_room_action_bar.dart'; import 'package:dating_touchme_app/widget/live/live_gift_popup.dart'; import 'package:dating_touchme_app/widget/live/live_recharge_popup.dart'; +import 'package:dating_touchme_app/widget/live/svga_player_widget.dart'; class LiveRoomPage extends StatefulWidget { final int id; @@ -207,6 +207,8 @@ class _LiveRoomPageState extends State { ], ), ), + // SVGA 动画播放组件 + const SvgaPlayerWidget(), ], ), ), diff --git a/lib/widget/live/live_gift_popup.dart b/lib/widget/live/live_gift_popup.dart index 73d23d8..2e4daab 100644 --- a/lib/widget/live/live_gift_popup.dart +++ b/lib/widget/live/live_gift_popup.dart @@ -8,9 +8,7 @@ import 'package:dating_touchme_app/widget/live/live_room_gift_item.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:flutter_swiper_null_safety/flutter_swiper_null_safety.dart'; import 'package:get/get.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; class LiveGiftPopup extends StatefulWidget { const LiveGiftPopup({ @@ -34,6 +32,15 @@ class _LiveGiftPopupState extends State { // 选中的用户ID(单选) String? _selectedUserId; + @override + void initState() { + super.initState(); + // 默认选择第一个礼物 + if (widget.giftList.isNotEmpty && widget.activeGift.value == null) { + widget.activeGift.value = 0; + } + } + // 切换用户选中状态(单选模式) void _toggleUserSelection(String userId) { setState(() { @@ -91,11 +98,7 @@ class _LiveGiftPopupState extends State { // 发送礼物 await roomController.sendGift(gift: gift, targetUserId: _selectedUserId); - - // 关闭弹窗 SmartDialog.dismiss(); - - SmartDialog.showToast('礼物已送出'); } @override @@ -312,52 +315,25 @@ class _LiveGiftPopupState extends State { ); } - // 计算需要多少页,每页显示8个礼物(2行4列) - final itemsPerPage = 8; - final totalPages = (widget.giftList.length / itemsPerPage).ceil(); - return Expanded( child: ValueListenableBuilder( valueListenable: widget.activeGift, builder: (context, active, _) { - return Swiper( - autoplay: false, - itemCount: totalPages, - loop: false, - pagination: totalPages > 1 - ? const SwiperPagination( - alignment: Alignment.bottomCenter, - builder: TDSwiperDotsPagination( - color: Color.fromRGBO(144, 144, 144, 1), - activeColor: Color.fromRGBO(77, 77, 77, 1), - ), - ) - : null, - itemBuilder: (context, pageIndex) { - final startIndex = pageIndex * itemsPerPage; - final endIndex = (startIndex + itemsPerPage).clamp( - 0, - widget.giftList.length, - ); - final pageItems = widget.giftList.sublist(startIndex, endIndex); - - return Align( - alignment: Alignment.topCenter, - child: Wrap( - spacing: 7.w, - runSpacing: 7.w, - children: [ - ...pageItems.asMap().entries.map((entry) { - final globalIndex = startIndex + entry.key; - return LiveRoomGiftItem( - item: entry.value, - active: active ?? 0, - index: globalIndex, - changeActive: widget.changeActive, - ); - }), - ], - ), + return GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.w), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, // 每行4个 + crossAxisSpacing: 7.w, + mainAxisSpacing: 7.w, + childAspectRatio: 0.85, // 调整宽高比 + ), + itemCount: widget.giftList.length, + itemBuilder: (context, index) { + return LiveRoomGiftItem( + item: widget.giftList[index], + active: active ?? 0, + index: index, + changeActive: widget.changeActive, ); }, ); @@ -421,40 +397,4 @@ class _LiveGiftPopupState extends State { ), ); } - - Widget _buildAdjustButton({ - required String label, - required bool enabled, - required VoidCallback onTap, - }) { - return InkWell( - onTap: enabled ? onTap : null, - child: Container( - width: 14.w, - height: 14.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(14.w)), - color: enabled - ? const Color.fromRGBO(117, 98, 249, 1) - : Colors.transparent, - border: Border.all( - width: 1, - color: const Color.fromRGBO(117, 98, 249, 1), - ), - ), - child: Center( - child: Text( - label, - style: TextStyle( - fontSize: 13.w, - color: enabled - ? Colors.white - : const Color.fromRGBO(117, 98, 249, 1), - height: 1, - ), - ), - ), - ), - ); - } } diff --git a/lib/widget/live/live_room_anchor_showcase.dart b/lib/widget/live/live_room_anchor_showcase.dart index 00acd48..68f74aa 100644 --- a/lib/widget/live/live_room_anchor_showcase.dart +++ b/lib/widget/live/live_room_anchor_showcase.dart @@ -158,7 +158,6 @@ class _LiveRoomAnchorShowcaseState extends State { } Widget _buildWaitingPlaceholder() { - Get.log("buildWaitingPlaceholder"); return Container( width: 177.w, height: 175.w, @@ -185,11 +184,10 @@ class _LiveRoomAnchorShowcaseState extends State { }) { final engine = _rtcManager.engine; final joined = _rtcManager.channelJoinedNotifier.value; - // 判断是否是当前用户 final bool isCurrentUser = - _roomController.currentRole == CurrentRole.maleAudience || - _roomController.currentRole == CurrentRole.femaleAudience; + _roomController.currentRole == CurrentRole.maleAudience && isLeft || + _roomController.currentRole == CurrentRole.femaleAudience && !isLeft; return Stack( children: [ ClipRRect( diff --git a/lib/widget/live/svga_player_widget.dart b/lib/widget/live/svga_player_widget.dart index 052f038..7d71124 100644 --- a/lib/widget/live/svga_player_widget.dart +++ b/lib/widget/live/svga_player_widget.dart @@ -13,7 +13,7 @@ class SvgaPlayerWidget extends StatefulWidget { } class _SvgaPlayerWidgetState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final SvgaPlayerManager _manager = SvgaPlayerManager.instance; SVGAAnimationController? _controller; SvgaAnimationItem? _currentItem; @@ -23,8 +23,20 @@ class _SvgaPlayerWidgetState extends State super.initState(); // 监听队列变化 ever(_manager.currentItem, (item) { - if (item != null && item != _currentItem) { - _playAnimation(item); + print( + '📢 currentItem 变化: ${item?.svgaFile ?? "null"}, 当前 _currentItem: ${_currentItem?.svgaFile ?? "null"}', + ); + if (item != null) { + // 如果当前没有播放,或者新的 item 与当前不同,则播放 + if (_currentItem == null || item.svgaFile != _currentItem!.svgaFile) { + print('🎯 准备播放新动画: ${item.svgaFile}'); + _playAnimation(item); + } else { + print('⚠️ 相同的动画,跳过播放'); + } + } else { + // currentItem 变为 null,但不立即清理,等待播放完成回调 + print('📢 currentItem 变为 null,等待播放完成回调'); } }); } @@ -37,48 +49,162 @@ class _SvgaPlayerWidgetState extends State /// 播放动画 Future _playAnimation(SvgaAnimationItem item) async { - // 如果正在播放,先停止 + print( + '🎬 开始播放动画: ${item.svgaFile}, 当前状态: _controller=${_controller != null}, _currentItem=${_currentItem?.svgaFile ?? "null"}', + ); + + // 如果正在播放,先停止并清理 if (_controller != null) { + print('🛑 停止当前播放'); + _controller!.stop(); _controller!.dispose(); _controller = null; } + // 设置当前项并创建新的 controller _currentItem = item; _controller = SVGAAnimationController(vsync: this); + print('✅ 创建新的 controller,准备加载动画'); try { // 判断是网络 URL 还是本地资源 if (item.svgaFile.startsWith('http://') || item.svgaFile.startsWith('https://')) { // 网络 URL - SVGAParser.shared.decodeFromURL(item.svgaFile).then((video) { - if (mounted && _currentItem == item) { - _controller!.videoItem = video; - _controller!.repeat(); - print('✅ SVGA 动画加载成功(网络): ${item.svgaFile}'); - } - }).catchError((error) { - print('❌ SVGA 动画加载失败(网络): $error'); - _manager.onAnimationError(error.toString()); - }); + SVGAParser.shared + .decodeFromURL(item.svgaFile) + .then((video) { + if (!mounted) return; + + // 检查是否还是当前要播放的动画 + if (_currentItem != item || _controller == null) { + print('⚠️ 动画已变更,取消播放: ${item.svgaFile}'); + return; + } + + _controller!.videoItem = video; + // 播放动画(repeat 会循环播放,我们需要监听完成) + _controller!.repeat(); + + // 获取动画时长,如果为 null 则使用默认值 3 秒 + final duration = _controller!.duration; + final playDuration = duration != null && duration > Duration.zero + ? duration + : const Duration(seconds: 3); + + print( + '✅ SVGA 动画加载成功(网络): ${item.svgaFile}, 播放时长: ${playDuration.inMilliseconds}ms', + ); + + // 在动画时长后停止并通知完成 + Future.delayed(playDuration, () { + if (!mounted) { + print('⚠️ Widget 已卸载,取消完成回调'); + return; + } + + // 再次检查是否还是当前动画 + if (_currentItem == item && _controller != null) { + print('✅ SVGA 动画播放完成(网络): ${item.svgaFile}'); + + // 先停止动画 + _controller!.stop(); + + // 清理当前项和 controller + final wasCurrentItem = _currentItem; + _currentItem = null; + _controller?.dispose(); + _controller = null; + + // 通知管理器播放完成(这会触发下一个动画) + if (wasCurrentItem == item) { + _manager.onAnimationFinished(); + } + } else { + print( + '⚠️ 动画已变更,跳过完成回调: _currentItem=${_currentItem?.svgaFile ?? "null"}, item=${item.svgaFile}', + ); + } + }); + }) + .catchError((error) { + print('❌ SVGA 动画加载失败(网络): $error'); + _currentItem = null; + _controller?.dispose(); + _controller = null; + _manager.onAnimationError(error.toString()); + }); } else { // 本地资源(assets) - SVGAParser.shared.decodeFromAssets(item.svgaFile).then((video) { - if (mounted && _currentItem == item) { - _controller!.videoItem = video; - _controller!.repeat(); - print('✅ SVGA 动画加载成功(本地): ${item.svgaFile}'); - } - }).catchError((error) { - print('❌ SVGA 动画加载失败(本地): $error'); - _manager.onAnimationError(error.toString()); - }); - } + SVGAParser.shared + .decodeFromAssets(item.svgaFile) + .then((video) { + if (!mounted) return; + + // 检查是否还是当前要播放的动画 + if (_currentItem != item || _controller == null) { + print('⚠️ 动画已变更,取消播放: ${item.svgaFile}'); + return; + } + + _controller!.videoItem = video; + // 播放动画(repeat 会循环播放,我们需要监听完成) + _controller!.repeat(); + + // 获取动画时长,如果为 null 则使用默认值 3 秒 + final duration = _controller!.duration; + final playDuration = duration != null && duration > Duration.zero + ? duration + : const Duration(seconds: 3); + + print( + '✅ SVGA 动画加载成功(本地): ${item.svgaFile}, 播放时长: ${playDuration.inMilliseconds}ms', + ); + + // 在动画时长后停止并通知完成 + Future.delayed(playDuration, () { + if (!mounted) { + print('⚠️ Widget 已卸载,取消完成回调'); + return; + } - // 监听动画完成(repeat 模式不会自动完成,需要手动停止) - // 这里可以根据需要调整播放逻辑 + // 再次检查是否还是当前动画 + if (_currentItem == item && _controller != null) { + print('✅ SVGA 动画播放完成(本地): ${item.svgaFile}'); + + // 先停止动画 + _controller!.stop(); + + // 清理当前项和 controller + final wasCurrentItem = _currentItem; + _currentItem = null; + _controller?.dispose(); + _controller = null; + + // 通知管理器播放完成(这会触发下一个动画) + if (wasCurrentItem == item) { + _manager.onAnimationFinished(); + } + } else { + print( + '⚠️ 动画已变更,跳过完成回调: _currentItem=${_currentItem?.svgaFile ?? "null"}, item=${item.svgaFile}', + ); + } + }); + }) + .catchError((error) { + print('❌ SVGA 动画加载失败(本地): $error'); + _currentItem = null; + _controller?.dispose(); + _controller = null; + _manager.onAnimationError(error.toString()); + }); + } } catch (e) { print('❌ SVGA 播放异常: $e'); + _currentItem = null; + _controller?.dispose(); + _controller = null; _manager.onAnimationError(e.toString()); } } @@ -94,11 +220,8 @@ class _SvgaPlayerWidgetState extends State } return Positioned.fill( - child: IgnorePointer( - child: SVGAImage(_controller!), - ), + child: IgnorePointer(child: SVGAImage(_controller!)), ); }); } } -