diff --git a/lib/controller/discover/room_controller.dart b/lib/controller/discover/room_controller.dart index f1eb85f..9231af0 100644 --- a/lib/controller/discover/room_controller.dart +++ b/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 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 _joinRtcChannel(String token, String channelName, int uid) async { + Future _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 _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 disposeRtcResources() async { + await RTCManager.instance.dispose(); + } } diff --git a/lib/pages/discover/discover_page.dart b/lib/pages/discover/discover_page.dart index ecafdad..6413dbc 100644 --- a/lib/pages/discover/discover_page.dart +++ b/lib/pages/discover/discover_page.dart @@ -13,7 +13,8 @@ class DiscoverPage extends StatefulWidget { State createState() => _DiscoverPageState(); } -class _DiscoverPageState extends State with AutomaticKeepAliveClientMixin{ +class _DiscoverPageState extends State + with AutomaticKeepAliveClientMixin { late final RoomController roomController; List topNav = ["相亲", "聚会脱单"]; @@ -31,13 +32,10 @@ class _DiscoverPageState extends State with AutomaticKeepAliveClie {"isNew": false}, ]; - List tabList = [ - "全部", "同城", "相亲视频", "相亲语音" - ]; + List tabList = ["全部", "同城", "相亲视频", "相亲语音"]; int active = 0; - void changeNav(int active) { print("当前项: $active"); } @@ -69,29 +67,33 @@ class _DiscoverPageState extends State 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 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 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 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 { @override Widget build(BuildContext context) { return InkWell( - onTap: (){ + onTap: () { Get.to(() => LiveRoomPage(id: 0)); }, child: ClipRRect( @@ -185,7 +195,7 @@ class _LiveItemState extends State { 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 { 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 { 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, + ), ), ), ), - ) ], - ) + ), ], ), - ) + ), ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/discover/live_room_page.dart b/lib/pages/discover/live_room_page.dart index c297871..ab5e03b 100644 --- a/lib/pages/discover/live_room_page.dart +++ b/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 { + late final RoomController _roomController; String message = ''; final TextEditingController _messageController = TextEditingController(); @@ -64,6 +67,16 @@ class _LiveRoomPageState extends State { @override void initState() { super.initState(); + _roomController = Get.isRegistered() + ? Get.find() + : Get.put(RoomController()); + } + + @override + void dispose() { + _roomController.disposeRtcResources(); + _messageController.dispose(); + super.dispose(); } void _showGiftPopup() { @@ -135,7 +148,7 @@ class _LiveRoomPageState extends State { popularityText: '1263', ), SizedBox(height: 7.w), - const LiveRoomAnchorShowcase(), + LiveRoomAnchorShowcase(), SizedBox(height: 5.w), const LiveRoomSeatList(), SizedBox(height: 5.w), diff --git a/lib/rtc/rtc_manager.dart b/lib/rtc/rtc_manager.dart index 6f70add..6150f64 100644 --- a/lib/rtc/rtc_manager.dart +++ b/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 channelJoinedNotifier = ValueNotifier(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 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'); diff --git a/lib/widget/live/live_room_anchor_showcase.dart b/lib/widget/live/live_room_anchor_showcase.dart index 1b1247b..953182a 100644 --- a/lib/widget/live/live_room_anchor_showcase.dart +++ b/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 createState() => _LiveRoomAnchorShowcaseState(); +} + +class _LiveRoomAnchorShowcaseState extends State { + final RTCManager _rtcManager = RTCManager.instance; + @override Widget build(BuildContext context) { - return Column( - children: [ - Stack( + return ValueListenableBuilder( + 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 { ); } } - diff --git a/pubspec.lock b/pubspec.lock index 1886d6d..83bf62a 100644 --- a/pubspec.lock +++ b/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