Browse Source

feat(live): 实现实名认证匹配功能并优化直播间动效播放

- 新增销毁 RTC 频道接口及对应网络请求实现
- 优化直播间礼物弹窗界面,替换为 GridView 并设置默认选中项
- 完善 SVGA 动画播放逻辑,支持队列播放和播放完成回调
- 调整直播间用户展示逻辑,区分左右侧观众身份判断
- 移除无用日志打印和冗余依赖包引用
- 修复主播离线时频道销毁流程,确保先调用销毁接口再发送结束消息
- 引入 SvgaPlayerWidget 组件用于直播间动效展示
- 优化实名认证判断逻辑,增强代码可读性
ios
Jolie 4 months ago
parent
commit
833be0f04b
8 changed files with 252 additions and 134 deletions
  1. 46
      lib/controller/discover/room_controller.dart
  2. 2
      lib/network/api_urls.dart
  3. 4
      lib/network/rtc_api.dart
  4. 31
      lib/network/rtc_api.g.dart
  5. 4
      lib/pages/discover/live_room_page.dart
  6. 108
      lib/widget/live/live_gift_popup.dart
  7. 6
      lib/widget/live/live_room_anchor_showcase.dart
  8. 185
      lib/widget/live/svga_player_widget.dart

46
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<RtcChannelData> rtcChannel = Rxn<RtcChannelData>();
final Rxn<RtcChannelDetail> rtcChannelDetail = Rxn<RtcChannelDetail>();
@ -47,7 +48,8 @@ class RoomController extends GetxController {
final RxList<GiftProductModel> giftProducts = <GiftProductModel>[].obs;
///
final LiveChatMessageService _messageService = LiveChatMessageService.instance;
final LiveChatMessageService _messageService =
LiveChatMessageService.instance;
// matchmakerFlag
@ -107,7 +109,7 @@ class RoomController extends GetxController {
/// RTC
Future<void> 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<void> 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));
}
}
}

2
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 =

4
lib/network/rtc_api.dart

@ -59,6 +59,10 @@ abstract class RtcApi {
@Body() Map<String, dynamic> data,
);
/// RTC
@POST(ApiUrls.destroyRtcChannel)
Future<HttpResponse<BaseResponse<dynamic>>> destroyRtcChannel();
/// RTC
@GET(ApiUrls.getRtcChannelPage)
Future<HttpResponse<BaseResponse<PaginatedResponse<RtcChannelModel>>>> getRtcChannelPage();

31
lib/network/rtc_api.g.dart

@ -287,6 +287,37 @@ class _RtcApi implements RtcApi {
return httpResponse;
}
@override
Future<HttpResponse<BaseResponse<dynamic>>> destroyRtcChannel() async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
const Map<String, dynamic>? _data = null;
final _options = _setStreamType<HttpResponse<BaseResponse<dynamic>>>(
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<Map<String, dynamic>>(_options);
late BaseResponse<dynamic> _value;
try {
_value = BaseResponse<dynamic>.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<HttpResponse<BaseResponse<PaginatedResponse<RtcChannelModel>>>>
getRtcChannelPage() async {

4
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<LiveRoomPage> {
],
),
),
// SVGA
const SvgaPlayerWidget(),
],
),
),

108
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<LiveGiftPopup> {
// 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<LiveGiftPopup> {
//
await roomController.sendGift(gift: gift, targetUserId: _selectedUserId);
//
SmartDialog.dismiss();
SmartDialog.showToast('礼物已送出');
}
@override
@ -312,52 +315,25 @@ class _LiveGiftPopupState extends State<LiveGiftPopup> {
);
}
// 824
final itemsPerPage = 8;
final totalPages = (widget.giftList.length / itemsPerPage).ceil();
return Expanded(
child: ValueListenableBuilder<int?>(
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<LiveGiftPopup> {
),
);
}
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,
),
),
),
),
);
}
}

6
lib/widget/live/live_room_anchor_showcase.dart

@ -158,7 +158,6 @@ class _LiveRoomAnchorShowcaseState extends State<LiveRoomAnchorShowcase> {
}
Widget _buildWaitingPlaceholder() {
Get.log("buildWaitingPlaceholder");
return Container(
width: 177.w,
height: 175.w,
@ -185,11 +184,10 @@ class _LiveRoomAnchorShowcaseState extends State<LiveRoomAnchorShowcase> {
}) {
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(

185
lib/widget/live/svga_player_widget.dart

@ -13,7 +13,7 @@ class SvgaPlayerWidget extends StatefulWidget {
}
class _SvgaPlayerWidgetState extends State<SvgaPlayerWidget>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
final SvgaPlayerManager _manager = SvgaPlayerManager.instance;
SVGAAnimationController? _controller;
SvgaAnimationItem? _currentItem;
@ -23,8 +23,20 @@ class _SvgaPlayerWidgetState extends State<SvgaPlayerWidget>
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<SvgaPlayerWidget>
///
Future<void> _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<SvgaPlayerWidget>
}
return Positioned.fill(
child: IgnorePointer(
child: SVGAImage(_controller!),
),
child: IgnorePointer(child: SVGAImage(_controller!)),
);
});
}
}
Loading…
Cancel
Save