From b0d68d2756c96638a4511c2b37689a6908a6b527 Mon Sep 17 00:00:00 2001 From: roxy Date: Wed, 29 Apr 2026 17:32:00 +0800 Subject: [PATCH] =?UTF-8?q?bgm=E6=92=AD=E6=94=BE=E5=99=A8=20=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app/routes/sc_fluro_navigator.dart | 18 - lib/modules/index/index_page.dart | 8 + .../room/music/room_music_folder_page.dart | 204 ++++---- lib/modules/room/music/room_music_page.dart | 454 +++++++++++------- .../room/music/room_music_select_page.dart | 101 ++-- lib/modules/room/seat/sc_seat_item.dart | 73 ++- lib/modules/room/voice_room_page.dart | 38 +- lib/modules/room/voice_room_route.dart | 22 +- lib/services/audio/rtc_manager.dart | 385 +++++++++++---- lib/services/music/room_music_manager.dart | 24 + lib/shared/tools/sc_heartbeat_utils.dart | 4 + lib/shared/tools/sc_room_utils.dart | 5 +- .../widgets/room/exit_min_room_page.dart | 229 ++++----- .../room/music/room_music_floating_entry.dart | 83 +--- .../room/music/room_music_player_bar.dart | 11 +- .../room/music/room_music_room_player.dart | 251 ++++++++++ lib/ui_kit/widgets/room/room_menu_dialog.dart | 26 +- lib/ui_kit/widgets/room/room_play_widget.dart | 6 + sc_images/room/sc_music_room_player_close.png | Bin 0 -> 588 bytes .../room/sc_music_room_player_manage.png | Bin 0 -> 637 bytes .../room/sc_music_room_player_minimize.png | Bin 0 -> 482 bytes sc_images/room/sc_music_room_player_next.png | Bin 0 -> 796 bytes .../room/sc_music_room_player_previous.png | Bin 0 -> 792 bytes sc_images/room/sc_music_user_bgm.png | Bin 0 -> 730 bytes 需求进度.md | 4 +- 25 files changed, 1290 insertions(+), 656 deletions(-) create mode 100644 lib/ui_kit/widgets/room/music/room_music_room_player.dart create mode 100644 sc_images/room/sc_music_room_player_close.png create mode 100644 sc_images/room/sc_music_room_player_manage.png create mode 100644 sc_images/room/sc_music_room_player_minimize.png create mode 100644 sc_images/room/sc_music_room_player_next.png create mode 100644 sc_images/room/sc_music_room_player_previous.png create mode 100644 sc_images/room/sc_music_user_bgm.png diff --git a/lib/app/routes/sc_fluro_navigator.dart b/lib/app/routes/sc_fluro_navigator.dart index 5486ea6..2cbca3d 100644 --- a/lib/app/routes/sc_fluro_navigator.dart +++ b/lib/app/routes/sc_fluro_navigator.dart @@ -9,24 +9,6 @@ import 'package:yumi/app/routes/sc_lk_application.dart'; /// 安卓页面切换时长 const Duration kAndroidTransitionDuration = Duration(milliseconds: 375); -/// 进入房间页动画时长 -const Duration kOpenRoomTransitionDuration = Duration(milliseconds: 325); - - -/// 进入房间页过渡动画 -SlideTransition kOpenRoomTransitionBuilder( BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child,) { - return SlideTransition( - position: Tween( - begin: const Offset(0.0, 1.0), - end: const Offset(0.0, 0.0), - ).animate(CurvedAnimation(parent: animation, curve: Curves.ease)), - child: child, - ); -} - /// fluro的路由跳转工具类 class SCNavigatorUtils { static bool inChatPage = false; diff --git a/lib/modules/index/index_page.dart b/lib/modules/index/index_page.dart index e83a6c0..f9cdc18 100644 --- a/lib/modules/index/index_page.dart +++ b/lib/modules/index/index_page.dart @@ -61,6 +61,14 @@ class _SCIndexPageState extends State { context, listen: false, ).initializeRealTimeCommunicationManager(context); + unawaited( + Provider.of( + context, + listen: false, + ).prewarmRtcEngine().catchError((error, stackTrace) { + debugPrint('[Agora] prewarm on index failed: $error'); + }), + ); Provider.of(context, listen: false).init(context); Provider.of( context, diff --git a/lib/modules/room/music/room_music_folder_page.dart b/lib/modules/room/music/room_music_folder_page.dart index 3ca30e6..df2a2dd 100644 --- a/lib/modules/room/music/room_music_folder_page.dart +++ b/lib/modules/room/music/room_music_folder_page.dart @@ -11,105 +11,115 @@ class RoomMusicFolderPage extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF071F1B), - appBar: AppBar( - backgroundColor: const Color(0xFF071F1B), - elevation: 0, - title: Text( - RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"), - style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), - ), + return Theme( + data: Theme.of(context).copyWith( + splashFactory: NoSplash.splashFactory, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, ), - body: ListView.separated( - padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 20.w), - itemCount: folders.length, - separatorBuilder: - (_, __) => - Divider(color: Colors.white.withValues(alpha: 0.08), height: 1), - itemBuilder: (context, index) { - final folder = folders[index]; - return InkWell( - onTap: () async { - final changed = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => RoomMusicSelectPage(folder: folder), - ), - ); - if (changed == true && context.mounted) { - Navigator.pop(context, true); - } - }, - child: SizedBox( - height: 82.w, - child: Row( - children: [ - Image.asset( - "sc_images/room/sc_music_material_folder.png", - width: 44.w, - height: 44.w, - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - folder.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white, - fontSize: 14.sp, - fontWeight: FontWeight.w700, - ), - ), - ), - if (folder.addedCount > 0) - Text( - RoomMusicTexts.t( - context, - "roomMusicAddedCount", - "已添加 ${folder.addedCount} 首", - ).replaceAll( - "{1}", - folder.addedCount.toString(), - ), - style: TextStyle( - color: const Color(0xFF33E6A2), - fontSize: 11.sp, - ), - ), - ], - ), - SizedBox(height: 6.w), - Text( - "${folder.musicCount} Music | ${folder.path}", - maxLines: 1, - overflow: TextOverflow.ellipsis, - textDirection: TextDirection.ltr, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.52), - fontSize: 11.sp, - ), - ), - ], - ), - ), - SizedBox(width: 8.w), - Icon( - Icons.chevron_right, - color: Colors.white.withValues(alpha: 0.55), - ), - ], + child: Scaffold( + backgroundColor: const Color(0xFF071F1B), + appBar: AppBar( + backgroundColor: const Color(0xFF071F1B), + elevation: 0, + title: Text( + RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"), + style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), + ), + ), + body: ListView.separated( + padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 20.w), + itemCount: folders.length, + separatorBuilder: + (_, __) => Divider( + color: Colors.white.withValues(alpha: 0.08), + height: 1, ), - ), - ); - }, + itemBuilder: (context, index) { + final folder = folders[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + final changed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RoomMusicSelectPage(folder: folder), + ), + ); + if (changed == true && context.mounted) { + Navigator.pop(context, true); + } + }, + child: SizedBox( + height: 82.w, + child: Row( + children: [ + Image.asset( + "sc_images/room/sc_music_material_folder.png", + width: 44.w, + height: 44.w, + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + folder.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w700, + ), + ), + ), + if (folder.addedCount > 0) + Text( + RoomMusicTexts.t( + context, + "roomMusicAddedCount", + "已添加 ${folder.addedCount} 首", + ).replaceAll( + "{1}", + folder.addedCount.toString(), + ), + style: TextStyle( + color: const Color(0xFF33E6A2), + fontSize: 11.sp, + ), + ), + ], + ), + SizedBox(height: 6.w), + Text( + "${folder.musicCount} Music | ${folder.path}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + textDirection: TextDirection.ltr, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.52), + fontSize: 11.sp, + ), + ), + ], + ), + ), + SizedBox(width: 8.w), + Icon( + Icons.chevron_right, + color: Colors.white.withValues(alpha: 0.55), + ), + ], + ), + ), + ); + }, + ), ), ); } diff --git a/lib/modules/room/music/room_music_page.dart b/lib/modules/room/music/room_music_page.dart index 5455dc0..9828de8 100644 --- a/lib/modules/room/music/room_music_page.dart +++ b/lib/modules/room/music/room_music_page.dart @@ -44,54 +44,63 @@ class _RoomMusicPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF071F1B), - body: SafeArea( - child: Stack( - children: [ - Column( - children: [ - _buildHeader(context), - _buildSearchField(), - Expanded( - child: Consumer( - builder: (context, manager, child) { - final songs = _filteredSongs(manager.playlist); - if (manager.playlist.isEmpty) { - return _buildEmptyState(context); - } - return Column( - children: [ - _buildTotal(context, manager.playlist.length), - Expanded( - child: - _isEditing && _searchController.text.isEmpty - ? _buildReorderableList(manager, songs) - : _buildSongList(manager, songs), - ), - ], - ); - }, + return Theme( + data: Theme.of(context).copyWith( + splashFactory: NoSplash.splashFactory, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + child: Scaffold( + backgroundColor: const Color(0xFF071F1B), + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + _buildHeader(context), + Expanded( + child: Consumer( + builder: (context, manager, child) { + final songs = _filteredSongs(manager.playlist); + if (manager.playlist.isEmpty) { + return _buildEmptyState(context); + } + return Column( + children: [ + _buildSearchField(), + _buildTotal(context, manager.playlist.length), + Expanded( + child: + _isEditing && _searchController.text.isEmpty + ? _buildReorderableList(manager, songs) + : _buildSongList(manager, songs), + ), + ], + ); + }, + ), ), - ), - ], - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: const RoomMusicPlayerBar(), - ), - if (_isScanning) - Positioned.fill( - child: Container( - color: Colors.black.withValues(alpha: 0.35), - child: const Center( - child: CircularProgressIndicator(color: Color(0xFF33E6A2)), - ), - ), + ], ), - ], + Positioned( + left: 0, + right: 0, + bottom: 0, + child: const RoomMusicPlayerBar(), + ), + if (_isScanning) + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.35), + child: const Center( + child: CircularProgressIndicator( + color: Color(0xFF33E6A2), + ), + ), + ), + ), + ], + ), ), ), ); @@ -102,9 +111,13 @@ class _RoomMusicPageState extends State { height: 54.w, child: Row( children: [ - IconButton( + _PlainIconButton( onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), + child: Icon( + Icons.arrow_back_ios_new, + color: Colors.white, + size: 24.w, + ), ), Expanded( child: Text( @@ -117,26 +130,14 @@ class _RoomMusicPageState extends State { ), ), ), - IconButton( + _PlainIconButton( onPressed: _startAddFlow, - icon: Image.asset( + child: Image.asset( "sc_images/room/sc_icon_room_music_add.png", width: 24.w, height: 24.w, ), ), - IconButton( - onPressed: () { - setState(() { - _isEditing = !_isEditing; - }); - }, - icon: Image.asset( - "sc_images/room/sc_music_material_edit.png", - width: 24.w, - height: 24.w, - ), - ), ], ), ); @@ -144,7 +145,7 @@ class _RoomMusicPageState extends State { Widget _buildSearchField() { return Padding( - padding: EdgeInsets.fromLTRB(16.w, 4.w, 16.w, 10.w), + padding: EdgeInsets.fromLTRB(16.w, 4.w, 16.w, 14.w), child: TextField( controller: _searchController, onChanged: (_) => setState(() {}), @@ -164,7 +165,7 @@ class _RoomMusicPageState extends State { filled: true, fillColor: Colors.white.withValues(alpha: 0.08), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(18.w), + borderRadius: BorderRadius.circular(17.w), borderSide: BorderSide.none, ), ), @@ -180,15 +181,39 @@ class _RoomMusicPageState extends State { ); return Padding( padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 8.w), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - template.replaceAll("{1}", count.toString()), - style: TextStyle( - color: Colors.white.withValues(alpha: 0.62), - fontSize: 12.sp, + child: Row( + children: [ + Expanded( + child: Text( + template.replaceAll("{1}", count.toString()), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.90), + fontSize: 14.sp, + ), + ), ), - ), + _PlainIconButton( + onPressed: + _searchController.text.trim().isEmpty + ? () { + setState(() { + _isEditing = !_isEditing; + }); + } + : null, + child: Image.asset( + "sc_images/room/sc_music_material_edit.png", + width: 24.w, + height: 24.w, + color: + _isEditing + ? const Color(0xFF33E6A2) + : Colors.white.withValues( + alpha: _searchController.text.trim().isEmpty ? 1 : 0.36, + ), + ), + ), + ], ), ); } @@ -215,19 +240,29 @@ class _RoomMusicPageState extends State { ), ), SizedBox(height: 18.w), - ElevatedButton( - onPressed: _startAddFlow, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF33E6A2), - foregroundColor: const Color(0xFF06251F), - minimumSize: Size(164.w, 42.w), - shape: RoundedRectangleBorder( + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _startAddFlow, + child: Container( + width: 164.w, + height: 42.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFF33E6A2), borderRadius: BorderRadius.circular(22.w), ), - ), - child: Text( - RoomMusicTexts.t(context, "roomMusicScanFromPhone", "从手机扫描添加"), - style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w700), + child: Text( + RoomMusicTexts.t( + context, + "roomMusicScanFromPhone", + "从手机扫描添加", + ), + style: TextStyle( + color: const Color(0xFF06251F), + fontSize: 14.sp, + fontWeight: FontWeight.w700, + ), + ), ), ), ], @@ -243,34 +278,20 @@ class _RoomMusicPageState extends State { itemCount: songs.length, itemBuilder: (context, index) { final item = songs[index]; - return Dismissible( - key: ValueKey("music-${item.id}"), - direction: DismissDirection.endToStart, - confirmDismiss: (_) => _confirmDelete(item), - background: Container( - alignment: AlignmentDirectional.centerEnd, - padding: EdgeInsetsDirectional.only(end: 18.w), - color: const Color(0xFFE64A4A), - child: Image.asset( - "sc_images/room/sc_music_material_delete.png", - width: 24.w, - height: 24.w, + return Padding( + padding: EdgeInsets.only(bottom: 8.w), + child: _SwipeDeleteTile( + key: ValueKey("music-${item.id}"), + onDelete: () { + _confirmDelete(item); + }, + child: _SongTile( + item: item, + selected: manager.current?.id == item.id, + playing: manager.current?.id == item.id && manager.isPlaying, + onTap: () => manager.play(item), ), ), - child: _SongTile( - item: item, - selected: manager.current?.id == item.id, - playing: manager.current?.id == item.id && manager.isPlaying, - onTap: () => manager.play(item), - trailing: - _isEditing - ? Image.asset( - "sc_images/room/sc_music_material_more.png", - width: 24.w, - height: 24.w, - ) - : null, - ), ); }, ); @@ -289,18 +310,21 @@ class _RoomMusicPageState extends State { }, itemBuilder: (context, index) { final item = songs[index]; - return _SongTile( + return Padding( key: ValueKey("reorder-${item.id}"), - item: item, - selected: manager.current?.id == item.id, - playing: manager.current?.id == item.id && manager.isPlaying, - onTap: () => manager.play(item), - trailing: ReorderableDragStartListener( - index: index, - child: Image.asset( - "sc_images/room/sc_music_material_more.png", - width: 24.w, - height: 24.w, + padding: EdgeInsets.only(bottom: 8.w), + child: _SongTile( + item: item, + selected: manager.current?.id == item.id, + playing: manager.current?.id == item.id && manager.isPlaying, + onTap: () => manager.play(item), + trailing: ReorderableDragStartListener( + index: index, + child: Image.asset( + "sc_images/room/sc_music_material_more.png", + width: 24.w, + height: 24.w, + ), ), ), ); @@ -389,7 +413,6 @@ class _RoomMusicPageState extends State { class _SongTile extends StatelessWidget { const _SongTile({ - super.key, required this.item, required this.selected, required this.playing, @@ -405,57 +428,69 @@ class _SongTile extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( + return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( - height: 62.w, decoration: BoxDecoration( - color: - selected - ? Colors.white.withValues(alpha: 0.08) - : Colors.transparent, - border: Border( - bottom: BorderSide(color: Colors.white.withValues(alpha: 0.08)), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFB2FBCC), Color(0x00B2FBCC)], + stops: [0, 1], ), + borderRadius: BorderRadius.circular(8.w), ), - child: Row( - children: [ - Image.asset( - playing - ? "sc_images/room/sc_music_material_pause.png" - : "sc_images/room/sc_music_material_play.png", - width: 28.w, - height: 28.w, - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: selected ? const Color(0xFF33E6A2) : Colors.white, - fontSize: 14.sp, - fontWeight: FontWeight.w600, + child: Container( + height: 54.w, + margin: const EdgeInsets.all(1), + padding: EdgeInsetsDirectional.fromSTEB(12.w, 0, 12.w, 0), + decoration: BoxDecoration( + color: const Color(0xFF08251E), + borderRadius: BorderRadius.circular(7.w), + ), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: + selected ? const Color(0xFF33E6A2) : Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), ), - ), - SizedBox(height: 5.w), - Text( - _formatDuration(item.durationMs), - style: TextStyle( - color: Colors.white.withValues(alpha: 0.52), - fontSize: 11.sp, + SizedBox(height: 5.w), + Text( + _formatDuration(item.durationMs), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.52), + fontSize: 11.sp, + ), ), - ), - ], + ], + ), ), - ), - if (trailing != null) trailing!, - ], + if (trailing != null) ...[ + SizedBox(width: 10.w), + trailing!, + ] else + Image.asset( + playing + ? "sc_images/room/sc_music_material_pause.png" + : "sc_images/room/sc_music_material_play.png", + width: 26.w, + height: 26.w, + ), + ], + ), ), ), ); @@ -468,3 +503,94 @@ class _SongTile extends StatelessWidget { return "$minutes:${rest.toString().padLeft(2, '0')}"; } } + +class _SwipeDeleteTile extends StatefulWidget { + const _SwipeDeleteTile({ + super.key, + required this.child, + required this.onDelete, + }); + + final Widget child; + final VoidCallback onDelete; + + @override + State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState(); +} + +class _SwipeDeleteTileState extends State<_SwipeDeleteTile> { + double _dragOffset = 0; + + @override + Widget build(BuildContext context) { + final actionWidth = 60.w; + return SizedBox( + height: 56.w, + child: Stack( + children: [ + PositionedDirectional( + end: 0, + top: 0, + bottom: 0, + width: actionWidth, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onDelete, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFE64A4A), + borderRadius: BorderRadius.circular(8.w), + ), + alignment: Alignment.center, + child: Image.asset( + "sc_images/room/sc_music_material_delete.png", + width: 22.w, + height: 22.w, + ), + ), + ), + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (details) { + setState(() { + _dragOffset = (_dragOffset + details.delta.dx).clamp( + -actionWidth, + 0, + ); + }); + }, + onHorizontalDragEnd: (_) { + setState(() { + _dragOffset = + _dragOffset.abs() > actionWidth * 0.38 ? -actionWidth : 0; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + transform: Matrix4.translationValues(_dragOffset, 0, 0), + child: widget.child, + ), + ), + ], + ), + ); + } +} + +class _PlainIconButton extends StatelessWidget { + const _PlainIconButton({required this.child, required this.onPressed}); + + final Widget child; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: SizedBox(width: 48.w, height: 48.w, child: Center(child: child)), + ); + } +} diff --git a/lib/modules/room/music/room_music_select_page.dart b/lib/modules/room/music/room_music_select_page.dart index 0f96c84..8cb1a3c 100644 --- a/lib/modules/room/music/room_music_select_page.dart +++ b/lib/modules/room/music/room_music_select_page.dart @@ -42,34 +42,41 @@ class _RoomMusicSelectPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF071F1B), - appBar: AppBar( - backgroundColor: const Color(0xFF071F1B), - elevation: 0, - title: Text( - RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"), - style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), - ), + return Theme( + data: Theme.of(context).copyWith( + splashFactory: NoSplash.splashFactory, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, ), - body: Column( - children: [ - Expanded( - child: ListView.separated( - padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 16.w), - itemCount: widget.folder.songs.length, - separatorBuilder: - (_, __) => Divider( - color: Colors.white.withValues(alpha: 0.08), - height: 1, - ), - itemBuilder: (context, index) { - return _buildSongItem(widget.folder.songs[index]); - }, - ), + child: Scaffold( + backgroundColor: const Color(0xFF071F1B), + appBar: AppBar( + backgroundColor: const Color(0xFF071F1B), + elevation: 0, + title: Text( + RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"), + style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), ), - _buildBottomBar(context), - ], + ), + body: Column( + children: [ + Expanded( + child: ListView.separated( + padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 16.w), + itemCount: widget.folder.songs.length, + separatorBuilder: + (_, __) => Divider( + color: Colors.white.withValues(alpha: 0.08), + height: 1, + ), + itemBuilder: (context, index) { + return _buildSongItem(widget.folder.songs[index]); + }, + ), + ), + _buildBottomBar(context), + ], + ), ), ); } @@ -84,7 +91,8 @@ class _RoomMusicSelectPageState extends State { ? Colors.white.withValues(alpha: 0.34) : const Color(0xFF33E6A2); - return InkWell( + return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () { setState(() { if (selected) { @@ -161,7 +169,8 @@ class _RoomMusicSelectPageState extends State { ), child: Row( children: [ - InkWell( + GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () { setState(() { if (_allSelected) { @@ -193,21 +202,31 @@ class _RoomMusicSelectPageState extends State { ), ), const Spacer(), - ElevatedButton( - onPressed: _hasChanged ? _submit : null, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF33E6A2), - disabledBackgroundColor: Colors.white.withValues(alpha: 0.18), - foregroundColor: const Color(0xFF06251F), - disabledForegroundColor: Colors.white.withValues(alpha: 0.38), - shape: RoundedRectangleBorder( + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _hasChanged ? _submit : null, + child: Container( + width: 104.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + _hasChanged + ? const Color(0xFF33E6A2) + : Colors.white.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(20.w), ), - minimumSize: Size(104.w, 40.w), - ), - child: Text( - RoomMusicTexts.t(context, "add", "添加"), - style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w700), + child: Text( + RoomMusicTexts.t(context, "add", "添加"), + style: TextStyle( + color: + _hasChanged + ? const Color(0xFF06251F) + : Colors.white.withValues(alpha: 0.38), + fontSize: 14.sp, + fontWeight: FontWeight.w700, + ), + ), ), ), ], diff --git a/lib/modules/room/seat/sc_seat_item.dart b/lib/modules/room/seat/sc_seat_item.dart index b1858ae..d0b3097 100644 --- a/lib/modules/room/seat/sc_seat_item.dart +++ b/lib/modules/room/seat/sc_seat_item.dart @@ -11,6 +11,7 @@ import 'package:provider/provider.dart'; import 'package:yumi/app/constants/sc_screen.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; +import 'package:yumi/services/music/room_music_manager.dart'; import 'package:yumi/shared/data_sources/models/enum/sc_room_special_mike_type.dart'; ///麦位 @@ -109,6 +110,22 @@ class _SCSeatItemState extends State with TickerProviderStateMixin { width: widget.isGameModel ? 38.w : 52.w, height: widget.isGameModel ? 38.w : 52.w, )), + Positioned( + bottom: widget.isGameModel ? 2.w : 5.w, + left: widget.isGameModel ? 2.w : 5.w, + child: + seatSnapshot.isCurrentUser + ? Selector( + selector: + (_, manager) => manager.isPublishingToRoom, + builder: (context, isPlayingBgm, child) { + return isPlayingBgm + ? const _BgmPlayingIndicator() + : const SizedBox.shrink(); + }, + ) + : const SizedBox.shrink(), + ), Positioned( bottom: widget.isGameModel ? 2.w : 5.w, right: widget.isGameModel ? 2.w : 5.w, @@ -254,6 +271,55 @@ class _SCSeatItemState extends State with TickerProviderStateMixin { } } +class _BgmPlayingIndicator extends StatefulWidget { + const _BgmPlayingIndicator(); + + @override + State<_BgmPlayingIndicator> createState() => _BgmPlayingIndicatorState(); +} + +class _BgmPlayingIndicatorState extends State<_BgmPlayingIndicator> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 12.w, + height: 12.w, + decoration: const BoxDecoration( + color: Color(0xFF1E1A27), + shape: BoxShape.circle, + ), + child: Center( + child: RotationTransition( + turns: _controller, + child: Image.asset( + "sc_images/room/sc_music_user_bgm.png", + width: 8.w, + height: 8.w, + ), + ), + ), + ); + } +} + class _SeatRenderSnapshot { const _SeatRenderSnapshot({ required this.isExitingCurrentVoiceRoomSession, @@ -267,6 +333,7 @@ class _SeatRenderSnapshot { required this.userHeartbeatValue, required this.micLock, required this.micMute, + required this.isCurrentUser, }); factory _SeatRenderSnapshot.fromProvider(RtcProvider provider, num index) { @@ -287,6 +354,7 @@ class _SeatRenderSnapshot { userHeartbeatValue: user?.heartbeatVal ?? 0, micLock: roomSeat?.micLock ?? false, micMute: roomSeat?.micMute ?? false, + isCurrentUser: provider.isOnMaiInIndex(index), ); } @@ -301,6 +369,7 @@ class _SeatRenderSnapshot { final num userHeartbeatValue; final bool micLock; final bool micMute; + final bool isCurrentUser; bool get hasUser => userId.isNotEmpty; @@ -321,7 +390,8 @@ class _SeatRenderSnapshot { other.userVipName == userVipName && other.userHeartbeatValue == userHeartbeatValue && other.micLock == micLock && - other.micMute == micMute; + other.micMute == micMute && + other.isCurrentUser == isCurrentUser; } @override @@ -337,6 +407,7 @@ class _SeatRenderSnapshot { userHeartbeatValue, micLock, micMute, + isCurrentUser, ); } diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 6dc686d..9feb64e 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -1,6 +1,5 @@ import 'dart:math' as math; import 'dart:async'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; @@ -31,7 +30,7 @@ import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart'; import 'package:yumi/ui_kit/widgets/room/room_play_widget.dart'; -import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart'; +import 'package:yumi/ui_kit/widgets/room/music/room_music_room_player.dart'; import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart'; import '../../ui_kit/components/sc_float_ichart.dart'; @@ -73,15 +72,6 @@ class _VoiceRoomPageState extends State _tabController = TabController(length: _pages.length, vsync: this); _enableRoomVisualEffects(); _tabController.addListener(_handleTabChange); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - final rtcProvider = context.read(); - rtcProvider.requestMicrophoneListRefresh(notifyIfUnchanged: true); - rtcProvider.fetchOnlineUsersList(notifyIfUnchanged: true); - }); - _subscription = eventBus.on().listen(( event, ) { @@ -179,24 +169,7 @@ class _VoiceRoomPageState extends State if (snapshot.status != RoomStartupStatus.loading) { return const SizedBox.shrink(); } - return Positioned.fill( - child: AbsorbPointer( - child: Container( - color: Colors.black.withValues(alpha: 0.18), - alignment: Alignment.center, - child: Container( - width: 55.w, - height: 55.w, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(8.w), - ), - alignment: Alignment.center, - child: const CupertinoActivityIndicator(color: Colors.white24), - ), - ), - ), - ); + return const SizedBox.shrink(); }, ); } @@ -329,7 +302,12 @@ class _VoiceRoomPageState extends State ], ), _buildRoomStartupLayer(), - const RoomMusicFloatingEntry(), + const PositionedDirectional( + start: 0, + end: 0, + bottom: 0, + child: RoomMusicRoomPlayer(), + ), // _buildPlayViews(), ///幸运礼物中奖动画 LuckGiftNomorAnimWidget(), diff --git a/lib/modules/room/voice_room_route.dart b/lib/modules/room/voice_room_route.dart index 070c227..bf13e80 100644 --- a/lib/modules/room/voice_room_route.dart +++ b/lib/modules/room/voice_room_route.dart @@ -21,11 +21,27 @@ class VoiceRoomRoute implements SCIRouterProvider { static String roomBackgroundUpload = '/room/background/upload'; static String roomMusic = '/room/music'; - static Route _buildRoute(Widget page) { + static Route _buildRoute(Widget page, {RouteSettings? settings}) { if (Platform.isIOS) { - return CupertinoPageRoute(builder: (_) => page); + return CupertinoPageRoute(builder: (_) => page, settings: settings); } - return MaterialPageRoute(builder: (_) => page); + return MaterialPageRoute(builder: (_) => page, settings: settings); + } + + static Future openVoiceRoom( + BuildContext context, { + bool replace = false, + bool rootNavigator = false, + }) { + final navigator = Navigator.of(context, rootNavigator: rootNavigator); + final route = _buildRoute( + const VoiceRoomPage(), + settings: RouteSettings(name: voiceRoom), + ); + if (replace) { + return navigator.pushReplacement(route); + } + return navigator.push(route); } static Future openRoomEdit( diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart index ddbf9a5..8bc6b6e 100644 --- a/lib/services/audio/rtc_manager.dart +++ b/lib/services/audio/rtc_manager.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; -import 'package:fluro/fluro.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:yumi/app_localizations.dart'; @@ -21,7 +20,6 @@ import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/app/routes/sc_routes.dart'; import 'package:yumi/app/routes/sc_fluro_navigator.dart'; import 'package:yumi/shared/tools/sc_lk_dialog_util.dart'; -import 'package:yumi/shared/tools/sc_loading_manager.dart'; import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; @@ -53,9 +51,35 @@ enum RoomStartupStatus { idle, loading, ready, failed } enum RoomStartupFailureType { none, entry, im, rtc } +class _RoomStartupAsyncResult { + const _RoomStartupAsyncResult.value(this.value) + : error = null, + stackTrace = null; + + const _RoomStartupAsyncResult.error(this.error, this.stackTrace) + : value = null; + + final T? value; + final Object? error; + final StackTrace? stackTrace; + + T requireValue() { + final capturedError = error; + if (capturedError != null) { + Error.throwWithStackTrace( + capturedError, + stackTrace ?? StackTrace.current, + ); + } + return value as T; + } +} + class RealTimeCommunicationManager extends ChangeNotifier { static const Duration _micListPollingInterval = Duration(seconds: 2); static const Duration _onlineUsersPollingInterval = Duration(seconds: 3); + static const int _exitNetworkRetryLimit = 3; + static const Duration _exitNetworkRetryDelay = Duration(milliseconds: 500); static const Duration _selfMicStateGracePeriod = Duration(seconds: 4); static const Duration _giftTriggeredMicRefreshMinInterval = Duration( milliseconds: 900, @@ -69,6 +93,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { bool _isHandlingAgoraRoomFailure = false; bool _agoraJoined = false; bool _agoraRoleIsBroadcaster = false; + bool _agoraAudioConfigured = false; RoomStartupStatus _roomStartupStatus = RoomStartupStatus.idle; RoomStartupFailureType _roomStartupFailureType = RoomStartupFailureType.none; int _roomStartupFailureToken = 0; @@ -78,6 +103,8 @@ class RealTimeCommunicationManager extends ChangeNotifier { Timer? _joinAgoraTimeoutTimer; Timer? _agoraDisconnectedCleanupTimer; Completer? _joinAgoraCompleter; + Future? _rtcEngineInitTask; + Future? _rtcEnginePrewarmTask; String? _joiningChannelId; bool _isRefreshingMicList = false; bool _isRefreshingOnlineUsers = false; @@ -948,6 +975,19 @@ class RealTimeCommunicationManager extends ChangeNotifier { _resyncHeartbeatFromAgoraState(); } + Future _leaveAgoraRoomForExit() async { + final rtcEngine = engine; + _resetAgoraTracking(); + if (rtcEngine == null) { + return; + } + try { + await rtcEngine.leaveChannel(); + } catch (error) { + debugPrint('[RoomExit] leave Agora channel failed: $error'); + } + } + Future _handleAgoraJoinFailed( Object error, { required bool exitRoom, @@ -985,7 +1025,55 @@ class RealTimeCommunicationManager extends ChangeNotifier { }); } - Future joinAgoraVoiceChannel({bool throwOnError = false}) async { + Future prewarmRtcEngine() { + final existingTask = _rtcEnginePrewarmTask; + if (existingTask != null) { + return existingTask; + } + final task = _prewarmRtcEngine(); + _rtcEnginePrewarmTask = task; + return task; + } + + Future _prewarmRtcEngine() async { + try { + final rtcEngine = await _initAgoraRtcEngine(); + await _configureAgoraAudioEngine(rtcEngine); + } catch (error, stackTrace) { + _rtcEnginePrewarmTask = null; + debugPrint('[Agora] prewarm failed: $error\n$stackTrace'); + rethrow; + } + } + + Future _fetchRtcTokenForCurrentRoom({ + required bool isPublisher, + }) async { + final channelId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); + final userId = + (AccountStorage().getCurrentUser()?.userProfile?.id ?? "").trim(); + final rtcToken = await SCAccountRepository().getRtcToken( + channelId, + userId, + isPublisher: isPublisher, + ); + return rtcToken.rtcToken ?? ""; + } + + Future<_RoomStartupAsyncResult> _captureStartupFuture( + Future future, + ) async { + try { + return _RoomStartupAsyncResult.value(await future); + } catch (error, stackTrace) { + return _RoomStartupAsyncResult.error(error, stackTrace); + } + } + + Future joinAgoraVoiceChannel({ + bool throwOnError = false, + String? rtcToken, + }) async { final channelId = (currenRoom?.roomProfile?.roomProfile?.id ?? "").trim(); if (channelId.isEmpty) { final error = StateError('Agora channel id is empty'); @@ -1013,23 +1101,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { try { final rtcEngine = await _initAgoraRtcEngine(); - rtcEngine.setAudioProfile( - profile: AudioProfileType.audioProfileSpeechStandard, - scenario: AudioScenarioType.audioScenarioGameStreaming, - ); - rtcEngine.enableAudioVolumeIndication( - interval: 500, - smooth: 3, - reportVad: true, - ); - await rtcEngine.disableVideo(); - var rtcToken = await SCAccountRepository().getRtcToken( - channelId, - AccountStorage().getCurrentUser()?.userProfile?.id ?? "", - isPublisher: false, - ); + await _configureAgoraAudioEngine(rtcEngine); + final resolvedRtcToken = + rtcToken ?? await _fetchRtcTokenForCurrentRoom(isPublisher: false); await rtcEngine.joinChannel( - token: rtcToken.rtcToken ?? "", + token: resolvedRtcToken, channelId: channelId, uid: _resolveAgoraUidForCurrentUser(), options: const ChannelMediaOptions( @@ -1054,7 +1130,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { if (throwOnError) { rethrow; } - print('加入失败:${e.runtimeType},${e.toString()}'); + debugPrint('加入失败:${e.runtimeType},${e.toString()}'); } finally { if (identical(_joinAgoraCompleter, joinCompleter)) { _cancelAgoraJoinWaiter(); @@ -1165,10 +1241,32 @@ class RealTimeCommunicationManager extends ChangeNotifier { } } - Future _initAgoraRtcEngine() async { + Future _initAgoraRtcEngine() { if (engine != null) { - return engine!; + return Future.value(engine!); } + final existingTask = _rtcEngineInitTask; + if (existingTask != null) { + return existingTask; + } + final task = _createAgoraRtcEngine(); + _rtcEngineInitTask = task; + void clearInitTask() { + if (identical(_rtcEngineInitTask, task)) { + _rtcEngineInitTask = null; + } + } + + unawaited( + task.then( + (_) => clearInitTask(), + onError: (_, __) => clearInitTask(), + ), + ); + return task; + } + + Future _createAgoraRtcEngine() async { final rtcEngine = createAgoraRtcEngine(); await rtcEngine.initialize( RtcEngineContext(appId: SCGlobalConfig.agoraRtcAppid), @@ -1176,7 +1274,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { engine = rtcEngine; _rtcEngineEventHandler = RtcEngineEventHandler( onError: (ErrorCodeType err, String msg) { - print('rtc错误$err'); + debugPrint('rtc错误$err'); final error = 'Agora error: $err $msg'; if (!_agoraJoined) { final completer = _joinAgoraCompleter; @@ -1192,7 +1290,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { } }, onJoinChannelSuccess: (RtcConnection connection, int elapsed) { - print('rtc 自己加入 ${connection.channelId} ${connection.localUid}'); + debugPrint('rtc 自己加入 ${connection.channelId} ${connection.localUid}'); final joinedChannelId = connection.channelId ?? ""; if (!_isCurrentAgoraChannel(joinedChannelId)) { return; @@ -1309,11 +1407,11 @@ class RealTimeCommunicationManager extends ChangeNotifier { _roomMusicMixingStateListener?.call(state, reason); }, onUserJoined: (connection, remoteUid, elapsed) { - print('rtc用户 $remoteUid 加入了频道'); + debugPrint('rtc用户 $remoteUid 加入了频道'); }, // 监听远端用户离开 onUserOffline: (connection, remoteUid, reason) { - print('rtc用户 $remoteUid 离开了频道 (原因: $reason)'); + debugPrint('rtc用户 $remoteUid 离开了频道 (原因: $reason)'); }, onTokenPrivilegeWillExpire: ( RtcConnection connection, @@ -1329,9 +1427,27 @@ class RealTimeCommunicationManager extends ChangeNotifier { onAudioVolumeIndication: initializeAudioVolumeIndicationCallback, ); rtcEngine.registerEventHandler(_rtcEngineEventHandler!); + await _configureAgoraAudioEngine(rtcEngine); return rtcEngine; } + Future _configureAgoraAudioEngine(RtcEngine rtcEngine) async { + if (_agoraAudioConfigured) { + return; + } + await rtcEngine.setAudioProfile( + profile: AudioProfileType.audioProfileSpeechStandard, + scenario: AudioScenarioType.audioScenarioGameStreaming, + ); + await rtcEngine.enableAudioVolumeIndication( + interval: 500, + smooth: 3, + reportVad: true, + ); + await rtcEngine.disableVideo(); + _agoraAudioConfigured = true; + } + Future releaseRtcEngineForAppTermination() async { final rtcEngine = engine; if (rtcEngine == null) { @@ -1341,6 +1457,9 @@ class RealTimeCommunicationManager extends ChangeNotifier { final eventHandler = _rtcEngineEventHandler; engine = null; _rtcEngineEventHandler = null; + _rtcEngineInitTask = null; + _rtcEnginePrewarmTask = null; + _agoraAudioConfigured = false; try { if (eventHandler != null) { @@ -1348,14 +1467,14 @@ class RealTimeCommunicationManager extends ChangeNotifier { } await rtcEngine.leaveChannel(); } catch (e) { - print('rtc销毁前离开频道出错: $e'); + debugPrint('rtc销毁前离开频道出错: $e'); } _resetAgoraTracking(); try { await rtcEngine.release(); } catch (e) { - print('rtc释放引擎出错: $e'); + debugPrint('rtc释放引擎出错: $e'); } } @@ -1506,35 +1625,16 @@ class RealTimeCommunicationManager extends ChangeNotifier { _setRoomStartupReady(); setRoomVisualEffectsEnabled(true); SCFloatIchart().remove(); - SCNavigatorUtils.push( - context, - '${VoiceRoomRoute.voiceRoom}?id=$roomId', - transition: TransitionType.custom, - transitionDuration: kOpenRoomTransitionDuration, - transitionBuilder: ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) { - return kOpenRoomTransitionBuilder( - context, - animation, - secondaryAnimation, - child, - ); - }, - ); + VoiceRoomRoute.openVoiceRoom(context); } else { SCFloatIchart().remove(); if (currenRoom != null) { - await exitCurrentVoiceRoomSession(false); + await exitCurrentVoiceRoomSession(false, deferRemoteCleanup: false); } if (!context.mounted) { return; } rtmProvider = Provider.of(context, listen: false); - SCLoadingManager.show(context: context); try { currenRoom = await SCAccountRepository().entryRoom( roomId, @@ -1550,8 +1650,6 @@ class RealTimeCommunicationManager extends ChangeNotifier { } catch (e) { await resetLocalRoomState(fallbackRtmProvider: rtmProvider); rethrow; - } finally { - SCLoadingManager.hide(); } } } @@ -1586,15 +1684,22 @@ class RealTimeCommunicationManager extends ChangeNotifier { } _setRoomStartupLoading(); setRoomVisualEffectsEnabled(true); - SCNavigatorUtils.push(context!, VoiceRoomRoute.voiceRoom, replace: false); + VoiceRoomRoute.openVoiceRoom(context!); unawaited(_bootstrapEnteredVoiceRoomSession()); } Future _bootstrapEnteredVoiceRoomSession() async { var failureType = RoomStartupFailureType.im; try { + final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? ""; - final joinResult = await rtmProvider?.joinRoomGroup(groupId, ""); + final joinGroupFuture = rtmProvider?.joinRoomGroup(groupId, ""); + final rtcTokenFuture = _captureStartupFuture( + _fetchRtcTokenForCurrentRoom(isPublisher: false), + ); + final microphoneListFuture = retrieveMicrophoneList(); + final onlineUsersFuture = fetchOnlineUsersList(); + final joinResult = await joinGroupFuture; if (joinResult == null || joinResult.code != 0) { debugPrint( '[RoomStartup] join IM group failed code=${joinResult?.code} desc=${joinResult?.desc}', @@ -1618,19 +1723,16 @@ class RealTimeCommunicationManager extends ChangeNotifier { ), ); - ///获取麦位 - retrieveMicrophoneList(); - fetchOnlineUsersList(); + await Future.wait([microphoneListFuture, onlineUsersFuture]); _startRoomStatePolling(); Provider.of( context!, listen: false, - ).fetchContributionLevelData( - currenRoom?.roomProfile?.roomProfile?.id ?? "", - ); + ).fetchContributionLevelData(roomId); failureType = RoomStartupFailureType.rtc; - await joinAgoraVoiceChannel(throwOnError: true); + final rtcToken = (await rtcTokenFuture).requireValue(); + await joinAgoraVoiceChannel(throwOnError: true, rtcToken: rtcToken); await _bootstrapRoomHeartbeatState(); _setRoomStartupReady(); @@ -1818,40 +1920,141 @@ class RealTimeCommunicationManager extends ChangeNotifier { return result; } - Future exitCurrentVoiceRoomSession(bool isLogout) async { + Future exitCurrentVoiceRoomSession( + bool isLogout, { + bool deferRemoteCleanup = true, + }) async { + if (_isExitingCurrentVoiceRoomSession) { + return; + } _setExitingCurrentVoiceRoomSession(true); _stopRoomStatePolling(); + final groupId = currenRoom?.roomProfile?.roomProfile?.roomAccount ?? ""; + final roomId = currenRoom?.roomProfile?.roomProfile?.id ?? ""; + final exitRtmProvider = rtmProvider; + final navigationContext = context; try { - rtmProvider?.msgAllListener = null; - rtmProvider?.msgChatListener = null; - rtmProvider?.msgGiftListener = null; - SCLoadingManager.show(context: context); + exitRtmProvider?.msgAllListener = null; + exitRtmProvider?.msgChatListener = null; + exitRtmProvider?.msgGiftListener = null; SCGiftVapSvgaManager().stopPlayback(); setRoomVisualEffectsEnabled(false); - SCHeartbeatUtils.scheduleHeartbeat(SCHeartbeatStatus.ONLINE.name, false); - SCHeartbeatUtils.cancelAnchorTimer(); - rtmProvider?.cleanRoomData(); - await _cleanupAgoraRoomState(); - await Future.delayed(Duration(milliseconds: 100)); - await rtmProvider?.quitGroup( - currenRoom!.roomProfile?.roomProfile?.roomAccount ?? "", - ); - await SCAccountRepository().quitRoom( - currenRoom!.roomProfile?.roomProfile?.id ?? "", - ); - _clearData(); - SCLoadingManager.hide(); - if (!isLogout) { - SCNavigatorUtils.popUntil(context!, ModalRoute.withName(SCRoutes.home)); - } + SCHeartbeatUtils.cancelTimer(); + exitRtmProvider?.cleanRoomData(); } catch (e) { - print('rtc退出房间出错: $e'); - _clearData(); - SCLoadingManager.hide(); - if (!isLogout) { - SCNavigatorUtils.popUntil(context!, ModalRoute.withName(SCRoutes.home)); + debugPrint('rtc退出房间本地清理出错: $e'); + } + + _clearData(); + if (!isLogout && navigationContext != null) { + SCNavigatorUtils.popUntil( + navigationContext, + ModalRoute.withName(SCRoutes.home), + ); + } + + final agoraLeaveTask = _leaveAgoraRoomForExit(); + final remoteCleanupTask = _cleanupExitedRoomRemotely( + exitRtmProvider: exitRtmProvider, + groupId: groupId, + roomId: roomId, + shouldSendOnlineHeartbeat: !isLogout, + ); + if (deferRemoteCleanup) { + unawaited(agoraLeaveTask); + unawaited(remoteCleanupTask); + } else { + await agoraLeaveTask; + await remoteCleanupTask; + } + } + + Future _cleanupExitedRoomRemotely({ + required RtmProvider? exitRtmProvider, + required String groupId, + required String roomId, + required bool shouldSendOnlineHeartbeat, + }) async { + final tasks = >[]; + if (shouldSendOnlineHeartbeat) { + tasks.add( + _retryExitNetworkRequest('heartbeat ONLINE', () async { + if (currenRoom != null) { + debugPrint('[RoomExit] skip ONLINE heartbeat: already in a room'); + return; + } + await SCHeartbeatUtils.scheduleHeartbeat( + SCHeartbeatStatus.ONLINE.name, + false, + rethrowOnError: true, + ); + }), + ); + } + if (groupId.isNotEmpty && exitRtmProvider != null) { + tasks.add( + _retryExitNetworkRequest('quit IM group $groupId', () async { + if (_isCurrentRoomGroup(groupId)) { + debugPrint('[RoomExit] skip quitGroup: re-entered group $groupId'); + return; + } + await exitRtmProvider.quitGroup(groupId); + }), + ); + } + if (roomId.isNotEmpty) { + tasks.add( + _retryExitNetworkRequest('quit room $roomId', () async { + if (_isCurrentRoomId(roomId)) { + debugPrint('[RoomExit] skip quitRoom: re-entered room $roomId'); + return; + } + final didQuit = await SCAccountRepository().quitRoom(roomId); + if (!didQuit) { + throw StateError('quitRoom returned false'); + } + }), + ); + } + await Future.wait(tasks); + } + + Future _retryExitNetworkRequest( + String name, + Future Function() action, + ) async { + Object? lastError; + StackTrace? lastStackTrace; + for (var attempt = 1; attempt <= _exitNetworkRetryLimit; attempt++) { + try { + await action(); + return; + } catch (error, stackTrace) { + lastError = error; + lastStackTrace = stackTrace; + debugPrint( + '[RoomExit] $name failed attempt ' + '$attempt/$_exitNetworkRetryLimit: $error', + ); + if (attempt < _exitNetworkRetryLimit) { + await Future.delayed(_exitNetworkRetryDelay * attempt); + } } } + debugPrint( + '[RoomExit] $name failed after $_exitNetworkRetryLimit attempts, ' + 'treat as backend/remote issue: $lastError\n$lastStackTrace', + ); + } + + bool _isCurrentRoomId(String roomId) { + return roomId.isNotEmpty && + currenRoom?.roomProfile?.roomProfile?.id == roomId; + } + + bool _isCurrentRoomGroup(String groupId) { + return groupId.isNotEmpty && + currenRoom?.roomProfile?.roomProfile?.roomAccount == groupId; } Future resetLocalRoomState({RtmProvider? fallbackRtmProvider}) async { @@ -1867,7 +2070,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { await _cleanupAgoraRoomState(); await Future.delayed(const Duration(milliseconds: 100)); } catch (e) { - print('rtc清理本地房间状态出错: $e'); + debugPrint('rtc清理本地房间状态出错: $e'); } finally { rtmProvider?.cleanRoomData(); _clearData(); @@ -1903,7 +2106,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { await SCAccountRepository().quitRoom(roomId); } } catch (e) { - print('rtc进房失败清理本地房间状态出错: $e'); + debugPrint('rtc进房失败清理本地房间状态出错: $e'); } finally { _clearData(); _isHandlingRoomStartupFailure = false; @@ -2098,12 +2301,12 @@ class RealTimeCommunicationManager extends ChangeNotifier { addSoundVoiceChangeListener(OnSoundVoiceChange onSoundVoiceChange) { _onSoundVoiceChangeList.add(onSoundVoiceChange); - print('添加监听:${_onSoundVoiceChangeList.length}'); + debugPrint('添加监听:${_onSoundVoiceChangeList.length}'); } removeSoundVoiceChangeListener(OnSoundVoiceChange onSoundVoiceChange) { _onSoundVoiceChangeList.remove(onSoundVoiceChange); - print('删除监听:${_onSoundVoiceChangeList.length}'); + debugPrint('删除监听:${_onSoundVoiceChangeList.length}'); } void shangMai(num index, {String? eventType, String? inviterId}) async { @@ -2286,7 +2489,7 @@ class RealTimeCommunicationManager extends ChangeNotifier { }); } catch (e) { SCTts.show("kill the user fail"); - print('踢人下麦失败'); + debugPrint('踢人下麦失败'); } } diff --git a/lib/services/music/room_music_manager.dart b/lib/services/music/room_music_manager.dart index 7d93e6d..75103f0 100644 --- a/lib/services/music/room_music_manager.dart +++ b/lib/services/music/room_music_manager.dart @@ -36,6 +36,7 @@ class RoomMusicManager extends ChangeNotifier { bool _isStarting = false; bool _isStoppingByUser = false; bool _lastLoopback = true; + bool _roomPlayerVisible = false; List get playlist => List.unmodifiable(_playlist); @@ -56,6 +57,10 @@ class RoomMusicManager extends ChangeNotifier { bool get hasCurrent => _current != null; + bool get roomPlayerVisible => _roomPlayerVisible; + + bool get isPublishingToRoom => _isPlaying && !_lastLoopback; + int get currentIndex { final currentId = _current?.id; if (currentId == null) { @@ -144,6 +149,7 @@ class RoomMusicManager extends ChangeNotifier { _durationMs = item.durationMs; _positionMs = startPositionMs; _isPaused = false; + _roomPlayerVisible = true; await _startCurrent(startPositionMs: startPositionMs); } @@ -188,6 +194,7 @@ class RoomMusicManager extends ChangeNotifier { if (clearCurrent) { _current = null; _durationMs = 0; + _roomPlayerVisible = false; } notifyListeners(); } @@ -261,6 +268,22 @@ class RoomMusicManager extends ChangeNotifier { } } + void toggleRoomPlayerVisible() { + if (_current == null) { + return; + } + _roomPlayerVisible = !_roomPlayerVisible; + notifyListeners(); + } + + void hideRoomPlayer() { + if (!_roomPlayerVisible) { + return; + } + _roomPlayerVisible = false; + notifyListeners(); + } + Future syncPlaybackRouting() async { final current = _current; final rtcProvider = _rtcProvider; @@ -430,6 +453,7 @@ class RoomMusicManager extends ChangeNotifier { _current = null; _isPlaying = false; _isPaused = false; + _roomPlayerVisible = false; _positionMs = 0; _durationMs = 0; _stopPositionTimer(); diff --git a/lib/shared/tools/sc_heartbeat_utils.dart b/lib/shared/tools/sc_heartbeat_utils.dart index f0d0e2e..b48bb0a 100644 --- a/lib/shared/tools/sc_heartbeat_utils.dart +++ b/lib/shared/tools/sc_heartbeat_utils.dart @@ -17,6 +17,7 @@ class SCHeartbeatUtils { String status, bool upMick, { String? roomId, + bool rethrowOnError = false, }) async { cancelTimer(); _c = status; @@ -40,6 +41,9 @@ class SCHeartbeatUtils { try { cancelTimer(); } catch (_) {} + if (rethrowOnError) { + rethrow; + } } } diff --git a/lib/shared/tools/sc_room_utils.dart b/lib/shared/tools/sc_room_utils.dart index 173a664..0574581 100644 --- a/lib/shared/tools/sc_room_utils.dart +++ b/lib/shared/tools/sc_room_utils.dart @@ -109,10 +109,7 @@ class SCChatRoomHelper { ).retrieveMicrophoneList(); Provider.of(context, listen: false) .closeFullGame = true; - SCNavigatorUtils.push( - context, - '${VoiceRoomRoute.voiceRoom}?id=${Provider.of(context, listen: false).currenRoom?.roomProfile?.roomProfile?.id}', - ); + VoiceRoomRoute.openVoiceRoom(context); } static double getCurrenProgress( diff --git a/lib/ui_kit/widgets/room/exit_min_room_page.dart b/lib/ui_kit/widgets/room/exit_min_room_page.dart index 97974fc..f898190 100644 --- a/lib/ui_kit/widgets/room/exit_min_room_page.dart +++ b/lib/ui_kit/widgets/room/exit_min_room_page.dart @@ -1,133 +1,96 @@ -import 'dart:convert'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; -import 'package:yumi/ui_kit/components/text/sc_text.dart'; -import 'package:yumi/shared/data_sources/sources/local/user_manager.dart'; -import 'package:yumi/shared/business_logic/models/res/room_res.dart'; -import 'package:provider/provider.dart'; -import 'package:yumi/app/routes/sc_routes.dart'; -import 'package:yumi/app/routes/sc_fluro_navigator.dart'; -import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart'; -import 'package:yumi/shared/business_logic/models/res/sc_user_identity_res.dart'; -import 'package:yumi/services/gift/gift_animation_manager.dart'; -import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/services/auth/user_profile_manager.dart'; -import 'package:yumi/modules/index/main_route.dart'; - -import '../../components/sc_float_ichart.dart'; - -class ExitMinRoomPage extends StatefulWidget { - bool isFz; - String roomId; - - ExitMinRoomPage(this.isFz, this.roomId); - - @override - _ExitMinRoomPageState createState() => _ExitMinRoomPageState(); -} - -class _ExitMinRoomPageState extends State { - SCUserIdentityRes? userIdentity; - bool isLoading = true; - - @override - void initState() { - super.initState(); - SCAccountRepository() - .userIdentity( - userId: - Provider.of( - context, - listen: false, - ).currenRoom?.roomProfile?.roomProfile?.userId, - ) - .then((v) { - userIdentity = v; - isLoading = false; - setState(() {}); - }) - .catchError((e) { - isLoading = false; - setState(() {}); - }); - } - - @override - Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - SCDebounceWidget( - child: Container(color: Colors.transparent), - onTap: () { - Navigator.of(context).pop(); - }, - ), - Column( - spacing: 55.w, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SCDebounceWidget( - child: Column( - children: [ - Image.asset( - "sc_images/room/sc_icon_min_room.png", - width: 48.w, - height: 48.w, - ), - SizedBox(height: 8.w), - text( - SCAppLocalizations.of(context)!.mInimize, - fontSize: 15.sp, - textColor: Colors.white, - fontWeight: FontWeight.w500, - ), - ], - ), - onTap: () { - SCFloatIchart().show(); - SCNavigatorUtils.popUntil( - context, - ModalRoute.withName(SCRoutes.home), - ); - Provider.of( - context, - listen: false, - ).cleanupAnimationResources(); - }, - ), - SCDebounceWidget( - child: Column( - children: [ - Image.asset( - "sc_images/room/sc_icon_exit_room.png", - width: 48.w, - height: 48.w, - ), - SizedBox(height: 8.w), - text( - SCAppLocalizations.of(context)!.exit, - fontSize: 15.sp, - textColor: Colors.white, - fontWeight: FontWeight.w500, - ), - ], - ), - onTap: () { - SCNavigatorUtils.goBack(context); - Provider.of( - context, - listen: false, - ).exitCurrentVoiceRoomSession(false); - }, - ), - ], - ), - ], - ); - } -} +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/ui_kit/components/sc_debounce_widget.dart'; +import 'package:yumi/ui_kit/components/text/sc_text.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/app/routes/sc_routes.dart'; +import 'package:yumi/app/routes/sc_fluro_navigator.dart'; +import 'package:yumi/services/gift/gift_animation_manager.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; + +import '../../components/sc_float_ichart.dart'; + +class ExitMinRoomPage extends StatefulWidget { + final bool isFz; + final String roomId; + + const ExitMinRoomPage(this.isFz, this.roomId, {super.key}); + + @override + State createState() => _ExitMinRoomPageState(); +} + +class _ExitMinRoomPageState extends State { + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + SCDebounceWidget( + child: Container(color: Colors.transparent), + onTap: () { + Navigator.of(context).pop(); + }, + ), + Column( + spacing: 55.w, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SCDebounceWidget( + child: Column( + children: [ + Image.asset( + "sc_images/room/sc_icon_min_room.png", + width: 48.w, + height: 48.w, + ), + SizedBox(height: 8.w), + text( + SCAppLocalizations.of(context)!.mInimize, + fontSize: 15.sp, + textColor: Colors.white, + fontWeight: FontWeight.w500, + ), + ], + ), + onTap: () { + final giftAnimationManager = + context.read(); + SCFloatIchart().show(); + SCNavigatorUtils.popUntil( + context, + ModalRoute.withName(SCRoutes.home), + ); + giftAnimationManager.cleanupAnimationResources(); + }, + ), + SCDebounceWidget( + child: Column( + children: [ + Image.asset( + "sc_images/room/sc_icon_exit_room.png", + width: 48.w, + height: 48.w, + ), + SizedBox(height: 8.w), + text( + SCAppLocalizations.of(context)!.exit, + fontSize: 15.sp, + textColor: Colors.white, + fontWeight: FontWeight.w500, + ), + ], + ), + onTap: () { + final rtcProvider = context.read(); + SCNavigatorUtils.goBack(context); + rtcProvider.exitCurrentVoiceRoomSession(false); + }, + ), + ], + ), + ], + ); + } +} diff --git a/lib/ui_kit/widgets/room/music/room_music_floating_entry.dart b/lib/ui_kit/widgets/room/music/room_music_floating_entry.dart index cabc315..c692bb9 100644 --- a/lib/ui_kit/widgets/room/music/room_music_floating_entry.dart +++ b/lib/ui_kit/widgets/room/music/room_music_floating_entry.dart @@ -1,66 +1,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:provider/provider.dart'; -import 'package:yumi/modules/room/voice_room_route.dart'; import 'package:yumi/services/music/room_music_manager.dart'; -class RoomMusicFloatingEntry extends StatefulWidget { +class RoomMusicFloatingEntry extends StatelessWidget { const RoomMusicFloatingEntry({super.key}); - @override - State createState() => _RoomMusicFloatingEntryState(); -} - -class _RoomMusicFloatingEntryState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 4), - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return Selector( - selector: - (_, manager) => _FloatingMusicSnapshot( - visible: manager.current != null, - playing: manager.isPlaying, - ), - builder: (context, snapshot, child) { - if (!snapshot.visible) { - _controller.stop(); + return Selector( + selector: (_, manager) => manager.current != null, + builder: (context, visible, child) { + if (!visible) { return const SizedBox.shrink(); } - if (snapshot.playing && !_controller.isAnimating) { - _controller.repeat(); - } else if (!snapshot.playing && _controller.isAnimating) { - _controller.stop(); - } - return PositionedDirectional( - end: 8.w, - top: 292.w, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => VoiceRoomRoute.openRoomMusic(context), - child: RotationTransition( - turns: _controller, - child: Image.asset( - "sc_images/room/sc_music_material_room_side.png", - width: 58.w, - height: 58.w, - ), + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: context.read().toggleRoomPlayerVisible, + child: SizedBox( + width: 44.w, + height: 44.w, + child: Image.asset( + "sc_images/room/sc_music_material_room_side.png", + fit: BoxFit.contain, ), ), ); @@ -68,20 +30,3 @@ class _RoomMusicFloatingEntryState extends State ); } } - -class _FloatingMusicSnapshot { - const _FloatingMusicSnapshot({required this.visible, required this.playing}); - - final bool visible; - final bool playing; - - @override - bool operator ==(Object other) { - return other is _FloatingMusicSnapshot && - other.visible == visible && - other.playing == playing; - } - - @override - int get hashCode => Object.hash(visible, playing); -} diff --git a/lib/ui_kit/widgets/room/music/room_music_player_bar.dart b/lib/ui_kit/widgets/room/music/room_music_player_bar.dart index a391ac4..0e8c583 100644 --- a/lib/ui_kit/widgets/room/music/room_music_player_bar.dart +++ b/lib/ui_kit/widgets/room/music/room_music_player_bar.dart @@ -74,9 +74,14 @@ class RoomMusicPlayerBar extends StatelessWidget { ], ), ), - IconButton( - onPressed: () => manager.stop(clearCurrent: true), - icon: const Icon(Icons.close, color: Colors.white), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => manager.stop(clearCurrent: true), + child: SizedBox( + width: 40.w, + height: 40.w, + child: const Icon(Icons.close, color: Colors.white), + ), ), ], ), diff --git a/lib/ui_kit/widgets/room/music/room_music_room_player.dart b/lib/ui_kit/widgets/room/music/room_music_room_player.dart new file mode 100644 index 0000000..f3ef590 --- /dev/null +++ b/lib/ui_kit/widgets/room/music/room_music_room_player.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/modules/room/voice_room_route.dart'; +import 'package:yumi/services/music/room_music_manager.dart'; + +class RoomMusicRoomPlayer extends StatelessWidget { + const RoomMusicRoomPlayer({super.key}); + + static const String _closeAsset = + "sc_images/room/sc_music_room_player_close.png"; + static const String _minimizeAsset = + "sc_images/room/sc_music_room_player_minimize.png"; + static const String _manageAsset = + "sc_images/room/sc_music_room_player_manage.png"; + static const String _previousAsset = + "sc_images/room/sc_music_room_player_previous.png"; + static const String _nextAsset = + "sc_images/room/sc_music_room_player_next.png"; + static const String _playAsset = "sc_images/room/sc_music_material_play.png"; + static const String _pauseAsset = + "sc_images/room/sc_music_material_pause.png"; + static const String _volumeAsset = + "sc_images/room/sc_music_material_volume.png"; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, manager, child) { + final current = manager.current; + if (current == null || !manager.roomPlayerVisible) { + return const SizedBox.shrink(); + } + final duration = manager.durationMs <= 0 ? 1 : manager.durationMs; + final position = manager.positionMs.clamp(0, duration).toInt(); + + return Container( + padding: EdgeInsets.fromLTRB(16.w, 10.w, 16.w, 10.w), + decoration: BoxDecoration( + color: const Color(0xE6072A24), + border: Border( + top: BorderSide(color: Colors.white.withValues(alpha: 0.10)), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + _PlainIconButton( + asset: _closeAsset, + size: 24.w, + onTap: () => manager.stop(clearCurrent: true), + ), + Expanded( + child: Text( + current.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + _PlainIconButton( + asset: _minimizeAsset, + size: 24.w, + onTap: manager.hideRoomPlayer, + ), + ], + ), + SizedBox(height: 6.w), + Row( + children: [ + Text( + _formatDuration(position), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: 10.sp, + ), + ), + SizedBox(width: 8.w), + Expanded( + child: _MusicSlider( + value: position.toDouble(), + max: duration.toDouble(), + onChanged: (value) => manager.seek(value.round()), + ), + ), + SizedBox(width: 8.w), + Text( + _formatDuration(duration), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: 10.sp, + ), + ), + ], + ), + SizedBox(height: 12.w), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _PlainIconButton( + asset: _modeAsset(manager.playMode), + size: 28.w, + onTap: manager.switchPlayMode, + ), + _PlainIconButton( + asset: _previousAsset, + size: 42.w, + onTap: manager.previous, + ), + _PlainIconButton( + asset: manager.isPlaying ? _pauseAsset : _playAsset, + size: 42.w, + onTap: () { + manager.isPlaying ? manager.pause() : manager.resume(); + }, + ), + _PlainIconButton( + asset: _nextAsset, + size: 42.w, + onTap: manager.next, + ), + _PlainIconButton( + asset: _manageAsset, + size: 28.w, + onTap: + () => VoiceRoomRoute.openRoomMusic( + context, + rootNavigator: true, + ), + ), + ], + ), + SizedBox(height: 12.w), + Row( + children: [ + Image.asset(_volumeAsset, width: 22.w, height: 22.w), + SizedBox(width: 8.w), + Expanded( + child: _MusicSlider( + value: manager.volume.toDouble(), + max: 100, + onChanged: (value) => manager.setVolume(value.round()), + ), + ), + SizedBox(width: 8.w), + SizedBox( + width: 38.w, + child: Text( + "${manager.volume}%", + textAlign: TextAlign.end, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.72), + fontSize: 10.sp, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + static String _formatDuration(int milliseconds) { + final totalSeconds = (milliseconds / 1000).floor(); + final minutes = totalSeconds ~/ 60; + final seconds = totalSeconds % 60; + return "$minutes:${seconds.toString().padLeft(2, '0')}"; + } + + static String _modeAsset(RoomMusicPlayMode mode) { + switch (mode) { + case RoomMusicPlayMode.list: + return "sc_images/room/sc_music_material_sequence.png"; + case RoomMusicPlayMode.shuffle: + return "sc_images/room/sc_music_material_shuffle.png"; + case RoomMusicPlayMode.single: + return "sc_images/room/sc_music_material_single_loop.png"; + } + } +} + +class _MusicSlider extends StatelessWidget { + const _MusicSlider({ + required this.value, + required this.max, + required this.onChanged, + }); + + final double value; + final double max; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final safeMax = max <= 0 ? 1.0 : max; + return SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3.w, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 5.w), + overlayShape: SliderComponentShape.noOverlay, + activeTrackColor: const Color(0xFF33E6A2), + inactiveTrackColor: Colors.white.withValues(alpha: 0.18), + thumbColor: Colors.white, + ), + child: Slider( + min: 0, + max: safeMax, + value: value.clamp(0, safeMax).toDouble(), + onChanged: onChanged, + ), + ); + } +} + +class _PlainIconButton extends StatelessWidget { + const _PlainIconButton({ + required this.asset, + required this.size, + required this.onTap, + }); + + final String asset; + final double size; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + width: size + 12.w, + height: size + 12.w, + child: Center(child: Image.asset(asset, width: size, height: size)), + ), + ); + } +} diff --git a/lib/ui_kit/widgets/room/room_menu_dialog.dart b/lib/ui_kit/widgets/room/room_menu_dialog.dart index 79d42e3..78ef5a5 100644 --- a/lib/ui_kit/widgets/room/room_menu_dialog.dart +++ b/lib/ui_kit/widgets/room/room_menu_dialog.dart @@ -198,7 +198,7 @@ class _RoomMenuDialogState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Image.asset("sc_images/room/${item.icon}", width: 45.w, height: 45.w), + _buildMenuIcon(item), SizedBox(height: 10.w), text( item.title, @@ -292,6 +292,30 @@ class _RoomMenuDialogState extends State { }, ); } + + Widget _buildMenuIcon(RoomMenu item) { + if (item.id != _menuMusic) { + return Image.asset( + "sc_images/room/${item.icon}", + width: 45.w, + height: 45.w, + ); + } + return Container( + width: 45.w, + height: 45.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.16), + ), + alignment: Alignment.center, + child: Image.asset( + "sc_images/room/${item.icon}", + width: 26.w, + height: 26.w, + ), + ); + } } class RoomMenu { diff --git a/lib/ui_kit/widgets/room/room_play_widget.dart b/lib/ui_kit/widgets/room/room_play_widget.dart index aed4762..a1d7e28 100644 --- a/lib/ui_kit/widgets/room/room_play_widget.dart +++ b/lib/ui_kit/widgets/room/room_play_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:yumi/ui_kit/widgets/room/room_banner_view.dart'; import 'package:yumi/ui_kit/widgets/room/room_game_bottom_sheet.dart'; +import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart'; class RoomPlayWidget extends StatefulWidget { const RoomPlayWidget({super.key}); @@ -16,6 +17,11 @@ class _RoomPlayWidgetState extends State { return SizedBox.expand( child: Stack( children: [ + PositionedDirectional( + end: 15.w, + bottom: 150.w, + child: const RoomMusicFloatingEntry(), + ), PositionedDirectional( end: 15.w, bottom: 100.w, diff --git a/sc_images/room/sc_music_room_player_close.png b/sc_images/room/sc_music_room_player_close.png new file mode 100644 index 0000000000000000000000000000000000000000..7521d68264b31896739704555df4e890545c88fa GIT binary patch literal 588 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}E~ycoX}-P; zT0k}j17mw80}DtA5K93u0|WB{Mh0de%?J`(zyz07Sip>6gA}f5OZp5{w$0PUF(iWX z?UaXshYfgIrPWSw$SEqPwDe8jSMnhhLnZjI!8n3tW>NLtO6|wK_ zeix^5+F!rxQOee+oS6r<`Ym|>o6TkQ`P#nO#oYC)-&si|oZFsY{B>n3_pOzGRP;L@ zvRRuRJY~cD#GLta#YwY?HLMvXCEIyr%YHa_1ix$ceb{BYAdvs!9qv}X3W2qkq*Tsw zaGTDehxq0On)HV zu{T<3lCio&W6y0)#%k+bmEp@S1a9OGHJ!C|X@VKUMa@sEyPmgIhdv0Y;W{p5u-i7& zm*2|O)bzMiKxw_eDM^Nlw~C`39vF5#SbfrJg8JMCa}=!PI%dWPZJMWaw|1_I^z19I z&wn|&U`a5?iRHp)g=e&%2x@%YaK)uqOn>&`DF26(OT#=Y4m4-ne$yMo67uBOMH|QD zk0(#>yxH*MyYid)y6et~RwW+oIPv1C?7FLU?t9qd6`y|))i@k=r*D7a;%n1eO)fF< z7pVzn=1%am-?q-ua}y9+ z87XmD=}3o}d0e$m&Y?4gp-;H3B(S$RIi~IUFiqv;<|h^RC4IuTKT&_k?zD&NN`*Yz ztGrd$8xO6`G7rwo%`7?NefFfM6|?Wc(q8Y9$67Jm^5zy_y^iqxb7OxV%x`qG{wi@fo2&gi&&8b!dcd`m`_j>vqCL`VFXy@3t>dfL@Rk2y z$LB4h!)_R8p`zQn;?ki@FJ?@iwVKD=WYNuxSmO}!cbDEL-JhEIz2+tFE~)Du3!7uL z&-o;=pU;U&|NQsPr2HE)DK=bI%~S6fhR<64RpapVb5Fz1X6LVanRlWjto+Q4meyhc zA@0p)%S?`RY%{pGAh2fv)4my>t{6gA}f5OZp5{=Huz&7!twx zc3R@ZLk>KxFL{|J1aMAe;G59EqSDZ0&FOK#v5Q&}%&P+7&oMvU<#O}ml9MJSxp+w}|!An|xaZlV8tfQ*;oeQk^tRa8MM$c`X<2sAD z>HjbLRUGx-G^vW!qS19)>h`j~jx2k;^$g!J*(u69HN4xg{jE}&sb4JbL(%wUE2qzh zoBi$GkDO)?(~Rf3&m@8H==d&+b#k^zgwrv%Atu}1#+FtZKY|bHfcB9q&car?- zQrEiflx;5UOtKeee0?tPSNx3%gIUfePX2qa(_z8!RYpJehd%h4$rVuOawWK1V&ZFF z_1%m|^{)Mn7MLh*Fs~ubDDPWPgX6x2NZEqTevD$4*B^PU3!cGrOv#+J$20GqGSe&j z3I2w?wWk_IPe^meF}9vxz+$|9)=Ixu2UhdlVeBvC?vpm#e;*XSp00i_>zopr0Q;$} A*8l(j literal 0 HcmV?d00001 diff --git a/sc_images/room/sc_music_room_player_next.png b/sc_images/room/sc_music_room_player_next.png new file mode 100644 index 0000000000000000000000000000000000000000..ee0f017033a987138a8ca7c1f67ea9e4c4d656a0 GIT binary patch literal 796 zcmeAS@N?(olHy`uVBq!ia0vp^E+EXo1|%(nCvO5$jKx9jP7LeL$-D%~q(*qA`T8rk6I|A10W*RPQh4Cz;r$E@Ol_Vnjv*C{ zZ)bG}9&!+9>r7_6;po-Cw}APrgSLQHfEJ_n1t90mgG-Dh4gb$=I3xIJ$LZD?r~g=Q zE?$3k$L~se{^J)r+?Z4xu%QPo4@#_T5Azo=$6Kh*Z5H^?cl}PMe_np_#R-kKw;fB0 z2{%XzvVLjvf5qw7e=8k>H>Ezv4E<;^|6unU>Dg0vhMxcXee%Pg2>>cz%UX?FyT> z_Y=D&eftr6$Jc4&U4h@>TMj0*-kx-G_QKCI8a>`i*4$H@azjN~Ajf;7L9Um^H|eCq zHxmSP7Ai6dT1eV>IBoQHQ@AlLNl|E5jnOv#*k{hW6>ohO)ZgO2>w{!_ne&$Ki~VYj zFJy}R*>vHD`&^!sGZ)-1xc^qXJ3;=yj7M9QUsRt9XwYGF%5!^soiEhm%_Pog9ZeSu zpGj|W7CtBxC}4Gr%SFW~-&u*v>G%zm$qPT2H{8f%&h3xAKY@o=N7`(o^E?liW4j}# zJM6n~ROH^1+>d!enY}vu9CqGfY~=oXaK`UHSMPf4dG@W!Wuac0fXPPbgSSLah;2-^ z-hRDUBsyK1<&H^K;>8sfsWrd<9>r|4SpV-6JG4XSapU7(=g-aKvx_mn8aaeU{ z^qc#4R@BtxU)PAsYszgEI%(d}a>dW;{Nu?Uj>(_&L)qdqC)UhcGx0O0`0c~#u7-D% h8HE(Ep%-ljrk6I|A10W*RPQh4Cz;r$E@Oii9Hjv*C{ zZ)bJ}9ySngWp-wqvOp-P=_SwtEe|UOEk>;dz96QO1S=2Dhq@CbHh)yLPB^(Ht*oRX z{965wW=}EhY_|g(5{$|TEIkc^223cN$$~fDma%?x^Vr>O$?;P=$$QF?RG-{QJ39m% zckNtvIHZHFLUhX@&w%TzvGwWAw`-TaZG6ZAi&H1p4x$uQv zcSh`ehaDHP1Y;x0|1Y_=N1^uJx}6MfQU&*34EUJt%>DaXr$SMD$D({+)%=q`CKgQH z8ZSHV&$D+2G;aL&XJxfj-(t}B(m#1pa#7<(rF!QbGj^>k?$49V7p&CUR8woGC6LhZ z{OR{rFm^eLEe-L_p@%Y zD5%bzT-cS{^QJ$Tm_ER$TF*5QN6Ex zN>JbSXf3l~;=XNff@j-s)H=A_{Vou8<;uzLCUeCul}4Tsyq3Csm3c%9~#~ zvNs1P3kbf-_Wsp8O_rlJLF8)WiI+CE%h#QA`t&gLA8+A7r|4s^C0r-@PieUlckD{k iI`otQOD6w}8~xviZ2TnNA-e>WDm`8OT-G@yGywpxbvH=> literal 0 HcmV?d00001 diff --git a/sc_images/room/sc_music_user_bgm.png b/sc_images/room/sc_music_user_bgm.png new file mode 100644 index 0000000000000000000000000000000000000000..b3931e7ac5907cd3dacee443daa91cebc53d2869 GIT binary patch literal 730 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(-uuz(rC1}QWNE&Rm5z~tiT;uvBf zm>L`#B;_dZ?w#O`5GDKk?5B#eg*(g}j_={P-d4Ciuo+3n9tHr9F~j~OedYW z?!_FL(qMY`<-L?Oy)##>oi}Ud``7Q*?{}@(_~`0%v8IxirQ95yv5OCc2rLiyl-?b_ zcjnwy*Tl&lQTL-`=PMU$y_p{H?AxMSj=xWQ+&JmZfoX2v)OLEmW}Gf8QSsS9nbmB` z8Fmgm?X+J%mzmGK&Zn>Rj5*;T??sIXPgrKNf9IUB-&yk4tfl%Kku}rR6umn(yXnuq zZl%stv-xIVk6w@b*M-y4-efQ0X1P|U$Xu{tgxWKRDlFGNoCk`Jl{d;Wq+dD0%QXpJee63m_}1>QLR{pG~o z(A6urcm2({K7U=fN#NXDTW-AVb$syo*3QoiKlG<1@H{wp;KARSlKidh-ER5Qx_@kc z^2~cayV7RUuGO>E9-P^FY1M{ZVc~uWOSL_3hs0aYdZWAE|DIh