Browse Source

feat(live): 实现直播间RTC功能并优化UI交互

- 集成声网RTC SDK,实现直播频道创建与加入功能
- 添加摄像头和麦克风权限申请机制
- 实现RTC频道状态监听与UI同步更新
-优化直播间主播展示区域,支持视频流渲染
- 完善房间控制器逻辑,添加资源释放机制
- 更新依赖库,引入app_settings和package_info_plus插件- 修复discover页面布局样式问题,提升代码可读性
ios
Jolie 4 months ago
parent
commit
11832809e0
6 changed files with 321 additions and 203 deletions
  1. 45
      lib/controller/discover/room_controller.dart
  2. 212
      lib/pages/discover/discover_page.dart
  3. 15
      lib/pages/discover/live_room_page.dart
  4. 24
      lib/rtc/rtc_manager.dart
  5. 218
      lib/widget/live/live_room_anchor_showcase.dart
  6. 10
      pubspec.lock

45
lib/controller/discover/room_controller.dart

@ -5,8 +5,7 @@ import 'package:dating_touchme_app/rtc/rtc_manager.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../pages/discover/live_room_page.dart';
import 'package:permission_handler/permission_handler.dart';
///
class RoomController extends GetxController {
@ -23,7 +22,9 @@ class RoomController extends GetxController {
/// RTC
Future<void> createRtcChannel() async {
if (isLoading.value) return ;
if (isLoading.value) return;
final granted = await _ensureRtcPermissions();
if (!granted) return;
try {
isLoading.value = true;
@ -31,6 +32,14 @@ class RoomController extends GetxController {
final base = response.data;
if (base.isSuccess && base.data != null) {
rtcChannel.value = base.data;
GetStorage storage = GetStorage();
String userId = storage.read('userId') ?? '';
String tokens = RtmTokenBuilder.buildToken(
appId: '4c2ea9dcb4c5440593a418df0fdd512d',
appCertificate: '16f34b45181a4fae8acdb1a28762fcfa',
userId: userId,
tokenExpireSeconds: 3600,
);
await _joinRtcChannel(base.data!.token, base.data!.channelId, base.data!.uid);
} else {
final message = base.message.isNotEmpty ? base.message : '创建频道失败';
@ -42,16 +51,42 @@ class RoomController extends GetxController {
isLoading.value = false;
}
}
Future<void> _joinRtcChannel(String token, String channelName, int uid) async {
Future<void> _joinRtcChannel(
String token,
String channelName,
int uid,
) async {
try {
await RTCManager.instance.joinChannel(
token: token,
channelId: channelName,
uid: uid
uid: uid,
);
} catch (e) {
SmartDialog.showToast('加入频道失败:$e');
}
}
Future<bool> _ensureRtcPermissions() async {
final statuses = await [Permission.camera, Permission.microphone].request();
final allGranted = statuses.values.every((status) => status.isGranted);
if (allGranted) {
return true;
}
final permanentlyDenied =
statuses.values.any((status) => status.isPermanentlyDenied);
if (permanentlyDenied) {
SmartDialog.showToast('请在系统设置中开启摄像头和麦克风权限');
await openAppSettings();
} else {
SmartDialog.showToast('请允许摄像头和麦克风权限以进入房间');
}
return false;
}
Future<void> disposeRtcResources() async {
await RTCManager.instance.dispose();
}
}

212
lib/pages/discover/discover_page.dart

@ -13,7 +13,8 @@ class DiscoverPage extends StatefulWidget {
State<DiscoverPage> createState() => _DiscoverPageState();
}
class _DiscoverPageState extends State<DiscoverPage> with AutomaticKeepAliveClientMixin{
class _DiscoverPageState extends State<DiscoverPage>
with AutomaticKeepAliveClientMixin {
late final RoomController roomController;
List<String> topNav = ["相亲", "聚会脱单"];
@ -31,13 +32,10 @@ class _DiscoverPageState extends State<DiscoverPage> with AutomaticKeepAliveClie
{"isNew": false},
];
List<String> tabList = [
"全部", "同城", "相亲视频", "相亲语音"
];
List<String> tabList = ["全部", "同城", "相亲视频", "相亲语音"];
int active = 0;
void changeNav(int active) {
print("当前项: $active");
}
@ -69,29 +67,33 @@ class _DiscoverPageState extends State<DiscoverPage> with AutomaticKeepAliveClie
child: Column(
children: [
HomeAppbar(topNav: topNav, changeNav: changeNav, right: InkWell(
onTap: () async {
await roomController.createRtcChannel();
},
child: Container(
width: 52.w,
height: 20.w,
decoration: BoxDecoration(
HomeAppbar(
topNav: topNav,
changeNav: changeNav,
right: InkWell(
onTap: () async {
await roomController.createRtcChannel();
},
child: Container(
width: 52.w,
height: 20.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(20.w)),
color: const Color.fromRGBO(108, 105, 244, 1)
),
child: Center(
child: Text(
"申请红娘",
style: TextStyle(
color: const Color.fromRGBO(108, 105, 244, 1),
),
child: Center(
child: Text(
"申请红娘",
style: TextStyle(
fontSize: 10.w,
color: Colors.white,
fontWeight: FontWeight.w500
fontWeight: FontWeight.w500,
),
),
),
),
),
),),
),
Container(
width: 351.w,
height: 45.w,
@ -100,30 +102,41 @@ class _DiscoverPageState extends State<DiscoverPage> with AutomaticKeepAliveClie
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
...tabList.asMap().entries.map((entry){
...tabList.asMap().entries.map((entry) {
return Container(
margin: EdgeInsets.only(right: 27.w),
child: InkWell(
onTap: (){
onTap: () {
active = entry.key;
setState(() {
});
setState(() {});
},
child: Container(
height: 21.w,
padding: EdgeInsets.symmetric(horizontal: active == entry.key ? 30.w : 0),
padding: EdgeInsets.symmetric(
horizontal: active == entry.key ? 30.w : 0,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(21.w)),
color: Color.fromRGBO(108, 105, 244, active == entry.key ? 1 : 0)
borderRadius: BorderRadius.all(
Radius.circular(21.w),
),
color: Color.fromRGBO(
108,
105,
244,
active == entry.key ? 1 : 0,
),
),
child: Center(
child: Text(
entry.value,
style: TextStyle(
fontSize: 12.w,
color: active == entry.key ? Colors.white :const Color.fromRGBO(51, 51, 51, .7),
fontWeight: active == entry.key ? FontWeight.w700 : FontWeight.w500
fontSize: 12.w,
color: active == entry.key
? Colors.white
: const Color.fromRGBO(51, 51, 51, .7),
fontWeight: active == entry.key
? FontWeight.w700
: FontWeight.w500,
),
),
),
@ -141,17 +154,16 @@ class _DiscoverPageState extends State<DiscoverPage> with AutomaticKeepAliveClie
spacing: 7.w,
runSpacing: 7.w,
children: [
...liveList.map((e){
return LiveItem(item: e,);
...liveList.map((e) {
return LiveItem(item: e);
}),
],
),
),
)
),
],
),
)
),
],
);
}
@ -160,8 +172,6 @@ class _DiscoverPageState extends State<DiscoverPage> with AutomaticKeepAliveClie
bool get wantKeepAlive => true;
}
class LiveItem extends StatefulWidget {
final Map item;
const LiveItem({super.key, required this.item});
@ -174,7 +184,7 @@ class _LiveItemState extends State<LiveItem> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: (){
onTap: () {
Get.to(() => LiveRoomPage(id: 0));
},
child: ClipRRect(
@ -185,7 +195,7 @@ class _LiveItemState extends State<LiveItem> {
width: 171.w,
height: 171.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10.w))
borderRadius: BorderRadius.all(Radius.circular(10.w)),
),
child: Image.network(
"https://picsum.photos/400",
@ -208,62 +218,63 @@ class _LiveItemState extends State<LiveItem> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(width: 5.w,),
SizedBox(width: 5.w),
Image.asset(
Assets.imagesLocationIcon,
width: 6.w,
height: 7.w,
),
SizedBox(width: 3.w,),
SizedBox(width: 3.w),
Text(
"49.9km",
style: TextStyle(
fontSize: 8.w,
color: Colors.white,
fontWeight: FontWeight.w500
fontSize: 8.w,
color: Colors.white,
fontWeight: FontWeight.w500,
),
)
),
],
),
)
),
],
),
),
if(widget.item["isNew"]) Positioned(
top: 9.w,
right: 8.w,
child: Container(
width: 39.w,
height: 13.w,
decoration: BoxDecoration(
if (widget.item["isNew"])
Positioned(
top: 9.w,
right: 8.w,
child: Container(
width: 39.w,
height: 13.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(13.w)),
color: const Color.fromRGBO(0, 0, 0, .3)
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 5.w,
height: 5.w,
margin: EdgeInsets.only(right: 3.w),
decoration: BoxDecoration(
color: const Color.fromRGBO(0, 0, 0, .3),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 5.w,
height: 5.w,
margin: EdgeInsets.only(right: 3.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(5.w)),
color: const Color.fromRGBO(255, 209, 43, 1)
color: const Color.fromRGBO(255, 209, 43, 1),
),
),
),
Text(
"等待",
style: TextStyle(
Text(
"等待",
style: TextStyle(
fontSize: 8.w,
color: Colors.white,
fontWeight: FontWeight.w500
fontWeight: FontWeight.w500,
),
),
)
],
],
),
),
),
),
Positioned(
left: 9.w,
bottom: 6.w,
@ -277,51 +288,54 @@ class _LiveItemState extends State<LiveItem> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 8.w,
color: Colors.white,
fontWeight: FontWeight.w500
fontSize: 8.w,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(height: 2.w,),
SizedBox(height: 2.w),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"福州 | 28岁",
style: TextStyle(
fontSize: 11.w,
color: Colors.white,
fontWeight: FontWeight.w500
fontSize: 11.w,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
SizedBox(width: 5.w,),
if(widget.item["isNew"]) Container(
width: 32.w,
height: 10.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10.w)),
color: const Color.fromRGBO(255, 206, 28, .8)
),
child: Center(
child: Text(
"新人",
style: TextStyle(
SizedBox(width: 5.w),
if (widget.item["isNew"])
Container(
width: 32.w,
height: 10.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(10.w),
),
color: const Color.fromRGBO(255, 206, 28, .8),
),
child: Center(
child: Text(
"新人",
style: TextStyle(
fontSize: 8.w,
color: Colors.white,
fontWeight: FontWeight.w500
fontWeight: FontWeight.w500,
),
),
),
),
)
],
)
),
],
),
)
),
],
),
),
);
}
}
}

15
lib/pages/discover/live_room_page.dart

@ -1,8 +1,10 @@
import 'package:dating_touchme_app/controller/discover/room_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';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:dating_touchme_app/widget/live/live_room_user_header.dart';
import 'package:dating_touchme_app/widget/live/live_room_anchor_showcase.dart';
import 'package:dating_touchme_app/widget/live/live_room_seat_list.dart';
@ -21,6 +23,7 @@ class LiveRoomPage extends StatefulWidget {
}
class _LiveRoomPageState extends State<LiveRoomPage> {
late final RoomController _roomController;
String message = '';
final TextEditingController _messageController = TextEditingController();
@ -64,6 +67,16 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
@override
void initState() {
super.initState();
_roomController = Get.isRegistered<RoomController>()
? Get.find<RoomController>()
: Get.put(RoomController());
}
@override
void dispose() {
_roomController.disposeRtcResources();
_messageController.dispose();
super.dispose();
}
void _showGiftPopup() {
@ -135,7 +148,7 @@ class _LiveRoomPageState extends State<LiveRoomPage> {
popularityText: '1263',
),
SizedBox(height: 7.w),
const LiveRoomAnchorShowcase(),
LiveRoomAnchorShowcase(),
SizedBox(height: 5.w),
const LiveRoomSeatList(),
SizedBox(height: 5.w),

24
lib/rtc/rtc_manager.dart

@ -2,12 +2,19 @@ import 'dart:convert';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:dating_touchme_app/rtc/rtm_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import '../pages/discover/live_room_page.dart';
/// RTC
class RTCManager {
/// UI监听
final ValueNotifier<bool> channelJoinedNotifier = ValueNotifier<bool>(false);
RtcEngine? get engine => _engine;
bool get isInChannel => _isInChannel;
int? get currentUid => _currentUid;
//
static final RTCManager _instance = RTCManager._internal();
factory RTCManager() => _instance;
@ -19,6 +26,7 @@ class RTCManager {
bool _isInChannel = false;
String? _currentChannelId;
int? _currentUid;
int? _streamId;
//
Function(RtcConnection connection, int elapsed)? onJoinChannelSuccess;
@ -101,6 +109,7 @@ class RTCManager {
RtcEngineEventHandler(
onJoinChannelSuccess: (RtcConnection connection, int elapsed) async{
_isInChannel = true;
channelJoinedNotifier.value = true;
_currentChannelId = connection.channelId;
print('加入频道成功,频道名:${connection.channelId},耗时:${elapsed}ms');
if(connection.localUid == _currentUid){
@ -121,6 +130,9 @@ class RTCManager {
onUserJoined!(connection, remoteUid, elapsed);
}
},
onStreamMessage: (RtcConnection connection, int remoteUid, int streamId, Uint8List data, int length, int sentTs){
print('收到消息,UID:$remoteUid,流ID:$streamId,数据:${utf8.decode(data)}');
},
onUserOffline:
(
RtcConnection connection,
@ -134,6 +146,7 @@ class RTCManager {
},
onLeaveChannel: (RtcConnection connection, RtcStats stats) {
_isInChannel = false;
channelJoinedNotifier.value = false;
_currentChannelId = null;
print('离开频道,统计信息:${stats.duration}');
if (onLeaveChannel != null) {
@ -347,6 +360,7 @@ class RTCManager {
uid: uid,
options: options ?? const ChannelMediaOptions(),
);
_streamId = await _engine?.createDataStream(DataStreamConfig(syncWithAudio: false, ordered: false));
print('正在加入频道:$channelId,UID:$uid');
}
@ -407,18 +421,9 @@ class RTCManager {
print('客户端角色已设置为:$role');
}
///
bool get isInChannel => _isInChannel;
/// ID
String? get currentChannelId => _currentChannelId;
/// ID
int? get currentUid => _currentUid;
/// RTC Engine
RtcEngine? get engine => _engine;
///
Future<void> dispose() async {
try {
@ -433,6 +438,7 @@ class RTCManager {
_isInChannel = false;
_currentChannelId = null;
_currentUid = null;
channelJoinedNotifier.value = false;
print('RTC Engine disposed');
} catch (e) {
print('Failed to dispose RTC Engine: $e');

218
lib/widget/live/live_room_anchor_showcase.dart

@ -1,110 +1,157 @@
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:dating_touchme_app/generated/assets.dart';
import 'package:dating_touchme_app/rtc/rtc_manager.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class LiveRoomAnchorShowcase extends StatelessWidget {
class LiveRoomAnchorShowcase extends StatefulWidget {
const LiveRoomAnchorShowcase({super.key});
@override
State<LiveRoomAnchorShowcase> createState() => _LiveRoomAnchorShowcaseState();
}
class _LiveRoomAnchorShowcaseState extends State<LiveRoomAnchorShowcase> {
final RTCManager _rtcManager = RTCManager.instance;
@override
Widget build(BuildContext context) {
return Column(
children: [
Stack(
return ValueListenableBuilder<bool>(
valueListenable: _rtcManager.channelJoinedNotifier,
builder: (context, joined, _) {
return Column(
children: [
Container(
width: 177.w,
height: 175.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(9.w)),
color: const Color.fromRGBO(47, 10, 94, 1),
),
),
Positioned(
top: 5.w,
left: 5.w,
child: Container(
width: 42.w,
height: 13.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(13.w)),
color: const Color.fromRGBO(142, 20, 186, 1),
),
child: Center(
child: Text(
"主持人",
style: TextStyle(
fontSize: 9.w,
color: Colors.white,
Stack(
children: [
_buildAnchorVideo(joined),
Positioned(
top: 5.w,
left: 5.w,
child: Container(
width: 42.w,
height: 13.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(13.w)),
color: const Color.fromRGBO(142, 20, 186, 1),
),
child: Center(
child: Text(
"主持人",
style: TextStyle(fontSize: 9.w, color: Colors.white),
),
),
),
),
),
),
Positioned(
top: 5.w,
right: 5.w,
child: Container(
width: 20.w,
height: 20.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(20.w)),
color: const Color.fromRGBO(0, 0, 0, .3),
Positioned(
top: 5.w,
right: 5.w,
child: Container(
width: 20.w,
height: 20.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(20.w)),
color: const Color.fromRGBO(0, 0, 0, .3),
),
child: Center(
child: Image.asset(
Assets.imagesGiftIcon,
width: 19.w,
height: 19.w,
),
),
),
),
child: Center(
child: Image.asset(
Assets.imagesGiftIcon,
width: 19.w,
height: 19.w,
Positioned(
bottom: 5.w,
right: 5.w,
child: Container(
width: 47.w,
height: 20.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(20.w)),
color: Colors.white,
),
child: Center(
child: Text(
"加好友",
style: TextStyle(
fontSize: 11.w,
color: const Color.fromRGBO(117, 98, 249, 1),
),
),
),
),
),
),
],
),
Positioned(
bottom: 5.w,
right: 5.w,
child: Container(
width: 47.w,
height: 20.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(20.w)),
color: Colors.white,
SizedBox(height: 5.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSideAnchorCard(
isLeft: true,
micIcon: Assets.imagesMicClose,
),
child: Center(
child: Text(
"加好友",
style: TextStyle(
fontSize: 11.w,
color: const Color.fromRGBO(117, 98, 249, 1),
),
),
_buildSideAnchorCard(
isLeft: false,
micIcon: Assets.imagesMicOpen,
),
),
],
),
],
),
SizedBox(height: 5.w),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildSideAnchorCard(
isLeft: true,
micIcon: Assets.imagesMicClose,
),
SizedBox(width: 15.w),
_buildSideAnchorCard(
isLeft: false,
micIcon: Assets.imagesMicOpen,
);
},
);
}
Widget _buildAnchorVideo(bool joined) {
final engine = _rtcManager.engine;
if (!joined || engine == null) {
return _buildWaitingPlaceholder();
}
final localUid = _rtcManager.currentUid ?? 0;
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(9.w)),
child: SizedBox(
width: 177.w,
height: 175.w,
child: AgoraVideoView(
controller: VideoViewController(
rtcEngine: engine,
canvas: VideoCanvas(
uid: 0,
),
],
),
onAgoraVideoViewCreated: (viewId){
engine.startPreview();
},
),
],
),
);
}
Widget _buildWaitingPlaceholder() {
return Container(
width: 177.w,
height: 175.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(9.w)),
color: const Color.fromRGBO(47, 10, 94, 1),
),
child: Center(
child: Text(
'等待主播',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 12.w,
),
),
),
);
}
Widget _buildSideAnchorCard({
required bool isLeft,
required String micIcon,
}) {
Widget _buildSideAnchorCard({required bool isLeft, required String micIcon}) {
return Stack(
children: [
Container(
@ -170,11 +217,7 @@ class LiveRoomAnchorShowcase extends StatelessWidget {
color: const Color.fromRGBO(0, 0, 0, .65),
),
child: Center(
child: Image.asset(
micIcon,
width: 10.w,
height: 11.w,
),
child: Image.asset(micIcon, width: 10.w, height: 11.w),
),
),
SizedBox(width: 5.w),
@ -193,4 +236,3 @@ class LiveRoomAnchorShowcase extends StatelessWidget {
);
}
}

10
pubspec.lock

@ -49,6 +49,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.3"
app_settings:
dependency: "direct main"
description:
name: app_settings
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.1"
archive:
dependency: transitive
description:
@ -949,7 +957,7 @@ packages:
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
dependency: "direct main"
description:
name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d

Loading…
Cancel
Save