bgm播放器 第二版

This commit is contained in:
roxy 2026-04-29 17:32:00 +08:00
parent ddb378f5b4
commit b0d68d2756
25 changed files with 1290 additions and 656 deletions

View File

@ -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<double> animation,
Animation<double> secondaryAnimation,
Widget child,) {
return SlideTransition(
position: Tween<Offset>(
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;

View File

@ -61,6 +61,14 @@ class _SCIndexPageState extends State<SCIndexPage> {
context,
listen: false,
).initializeRealTimeCommunicationManager(context);
unawaited(
Provider.of<RtcProvider>(
context,
listen: false,
).prewarmRtcEngine().catchError((error, stackTrace) {
debugPrint('[Agora] prewarm on index failed: $error');
}),
);
Provider.of<RtmProvider>(context, listen: false).init(context);
Provider.of<SocialChatUserProfileManager>(
context,

View File

@ -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<bool>(
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<bool>(
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),
),
],
),
),
);
},
),
),
);
}

View File

@ -44,54 +44,63 @@ class _RoomMusicPageState extends State<RoomMusicPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF071F1B),
body: SafeArea(
child: Stack(
children: [
Column(
children: [
_buildHeader(context),
_buildSearchField(),
Expanded(
child: Consumer<RoomMusicManager>(
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<RoomMusicManager>(
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<RoomMusicPage> {
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<RoomMusicPage> {
),
),
),
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<RoomMusicPage> {
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<RoomMusicPage> {
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<RoomMusicPage> {
);
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<RoomMusicPage> {
),
),
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<RoomMusicPage> {
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<RoomMusicPage> {
},
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<RoomMusicPage> {
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)),
);
}
}

View File

@ -42,34 +42,41 @@ class _RoomMusicSelectPageState extends State<RoomMusicSelectPage> {
@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<RoomMusicSelectPage> {
? 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<RoomMusicSelectPage> {
),
child: Row(
children: [
InkWell(
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
if (_allSelected) {
@ -193,21 +202,31 @@ class _RoomMusicSelectPageState extends State<RoomMusicSelectPage> {
),
),
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,
),
),
),
),
],

View File

@ -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<SCSeatItem> 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<RoomMusicManager, bool>(
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<SCSeatItem> 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,
);
}

View File

@ -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<VoiceRoomPage>
_tabController = TabController(length: _pages.length, vsync: this);
_enableRoomVisualEffects();
_tabController.addListener(_handleTabChange);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final rtcProvider = context.read<RtcProvider>();
rtcProvider.requestMicrophoneListRefresh(notifyIfUnchanged: true);
rtcProvider.fetchOnlineUsersList(notifyIfUnchanged: true);
});
_subscription = eventBus.on<SCGiveRoomLuckPageDisposeEvent>().listen((
event,
) {
@ -179,24 +169,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
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<VoiceRoomPage>
],
),
_buildRoomStartupLayer(),
const RoomMusicFloatingEntry(),
const PositionedDirectional(
start: 0,
end: 0,
bottom: 0,
child: RoomMusicRoomPlayer(),
),
// _buildPlayViews(),
///
LuckGiftNomorAnimWidget(),

View File

@ -21,11 +21,27 @@ class VoiceRoomRoute implements SCIRouterProvider {
static String roomBackgroundUpload = '/room/background/upload';
static String roomMusic = '/room/music';
static Route<T> _buildRoute<T>(Widget page) {
static Route<T> _buildRoute<T>(Widget page, {RouteSettings? settings}) {
if (Platform.isIOS) {
return CupertinoPageRoute<T>(builder: (_) => page);
return CupertinoPageRoute<T>(builder: (_) => page, settings: settings);
}
return MaterialPageRoute<T>(builder: (_) => page);
return MaterialPageRoute<T>(builder: (_) => page, settings: settings);
}
static Future<T?> openVoiceRoom<T>(
BuildContext context, {
bool replace = false,
bool rootNavigator = false,
}) {
final navigator = Navigator.of(context, rootNavigator: rootNavigator);
final route = _buildRoute<T>(
const VoiceRoomPage(),
settings: RouteSettings(name: voiceRoom),
);
if (replace) {
return navigator.pushReplacement<T, T>(route);
}
return navigator.push<T>(route);
}
static Future<T?> openRoomEdit<T>(

View File

@ -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<T> {
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<void>? _joinAgoraCompleter;
Future<RtcEngine>? _rtcEngineInitTask;
Future<void>? _rtcEnginePrewarmTask;
String? _joiningChannelId;
bool _isRefreshingMicList = false;
bool _isRefreshingOnlineUsers = false;
@ -948,6 +975,19 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_resyncHeartbeatFromAgoraState();
}
Future<void> _leaveAgoraRoomForExit() async {
final rtcEngine = engine;
_resetAgoraTracking();
if (rtcEngine == null) {
return;
}
try {
await rtcEngine.leaveChannel();
} catch (error) {
debugPrint('[RoomExit] leave Agora channel failed: $error');
}
}
Future<void> _handleAgoraJoinFailed(
Object error, {
required bool exitRoom,
@ -985,7 +1025,55 @@ class RealTimeCommunicationManager extends ChangeNotifier {
});
}
Future<void> joinAgoraVoiceChannel({bool throwOnError = false}) async {
Future<void> prewarmRtcEngine() {
final existingTask = _rtcEnginePrewarmTask;
if (existingTask != null) {
return existingTask;
}
final task = _prewarmRtcEngine();
_rtcEnginePrewarmTask = task;
return task;
}
Future<void> _prewarmRtcEngine() async {
try {
final rtcEngine = await _initAgoraRtcEngine();
await _configureAgoraAudioEngine(rtcEngine);
} catch (error, stackTrace) {
_rtcEnginePrewarmTask = null;
debugPrint('[Agora] prewarm failed: $error\n$stackTrace');
rethrow;
}
}
Future<String> _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<T>> _captureStartupFuture<T>(
Future<T> future,
) async {
try {
return _RoomStartupAsyncResult<T>.value(await future);
} catch (error, stackTrace) {
return _RoomStartupAsyncResult<T>.error(error, stackTrace);
}
}
Future<void> 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<RtcEngine> _initAgoraRtcEngine() async {
Future<RtcEngine> _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<void>(
(_) => clearInitTask(),
onError: (_, __) => clearInitTask(),
),
);
return task;
}
Future<RtcEngine> _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<void> _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<void> 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<double> animation,
Animation<double> 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<RtmProvider>(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<void> _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<SocialChatRoomManager>(
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<void> _cleanupExitedRoomRemotely({
required RtmProvider? exitRtmProvider,
required String groupId,
required String roomId,
required bool shouldSendOnlineHeartbeat,
}) async {
final tasks = <Future<void>>[];
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<void> _retryExitNetworkRequest(
String name,
Future<void> 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<void> 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('踢人下麦失败');
}
}

View File

@ -36,6 +36,7 @@ class RoomMusicManager extends ChangeNotifier {
bool _isStarting = false;
bool _isStoppingByUser = false;
bool _lastLoopback = true;
bool _roomPlayerVisible = false;
List<SCMusicMode> 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<void> 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();

View File

@ -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;
}
}
}

View File

@ -109,10 +109,7 @@ class SCChatRoomHelper {
).retrieveMicrophoneList();
Provider.of<RealTimeCommunicationManager>(context, listen: false)
.closeFullGame = true;
SCNavigatorUtils.push(
context,
'${VoiceRoomRoute.voiceRoom}?id=${Provider.of<RealTimeCommunicationManager>(context, listen: false).currenRoom?.roomProfile?.roomProfile?.id}',
);
VoiceRoomRoute.openVoiceRoom(context);
}
static double getCurrenProgress(

View File

@ -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<ExitMinRoomPage> {
SCUserIdentityRes? userIdentity;
bool isLoading = true;
@override
void initState() {
super.initState();
SCAccountRepository()
.userIdentity(
userId:
Provider.of<RtcProvider>(
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<GiftAnimationManager>(
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<RtcProvider>(
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<ExitMinRoomPage> createState() => _ExitMinRoomPageState();
}
class _ExitMinRoomPageState extends State<ExitMinRoomPage> {
@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<GiftAnimationManager>();
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<RtcProvider>();
SCNavigatorUtils.goBack(context);
rtcProvider.exitCurrentVoiceRoomSession(false);
},
),
],
),
],
);
}
}

View File

@ -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<RoomMusicFloatingEntry> createState() => _RoomMusicFloatingEntryState();
}
class _RoomMusicFloatingEntryState extends State<RoomMusicFloatingEntry>
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<RoomMusicManager, _FloatingMusicSnapshot>(
selector:
(_, manager) => _FloatingMusicSnapshot(
visible: manager.current != null,
playing: manager.isPlaying,
),
builder: (context, snapshot, child) {
if (!snapshot.visible) {
_controller.stop();
return Selector<RoomMusicManager, bool>(
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<RoomMusicManager>().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<RoomMusicFloatingEntry>
);
}
}
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);
}

View File

@ -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),
),
),
],
),

View File

@ -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<RoomMusicManager>(
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<double> 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)),
),
);
}
}

View File

@ -198,7 +198,7 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
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<RoomMenuDialog> {
},
);
}
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 {

View File

@ -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<RoomPlayWidget> {
return SizedBox.expand(
child: Stack(
children: [
PositionedDirectional(
end: 15.w,
bottom: 150.w,
child: const RoomMusicFloatingEntry(),
),
PositionedDirectional(
end: 15.w,
bottom: 100.w,

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

View File

@ -15,8 +15,10 @@
- 已接入语言房入口:房间底部菜单已恢复 `Music` 入口并跳转音乐页;语音房页面已挂载音乐悬浮入口,离开房间时会主动停止当前房间音乐,避免混音残留到房外页面。
- 已按最新反馈调整房间菜单位置:`Music` 不再单独占用菜单第一行,而是并入常规菜单网格,紧跟在 `Background` 图标之后;背景入口功能开关保持打开状态。
- 已修正本地音乐扫描器的兼容性风险:扫描 MP3 时不再直接读取 `SongModel.fileExtension/displayName/title` 等可能因 Android MediaStore 返回空值而触发异常的 getter改为从插件原始 map 安全取值,并对文件存在性检查和扫描失败做兜底。
- 已按最新音乐页与房间播放器设计继续对稿:音乐管理页右上角只保留添加按钮,排序/编辑按钮移到 `Total songs` 行末;空态不再展示搜索框,搜索框圆角收到 `17`;歌曲 item 改为 `#08251E` 背景和纵向渐隐描边,播放/暂停按钮移到右侧;左滑删除改为固定 `60` 宽删除按钮,需要点击删除按钮后才删除;音乐相关页面点击波浪反馈已收口移除。
- 已重做房间内音乐入口与播放器:右侧音乐浮标不再旋转,并移动到游戏图标上方;点击浮标改为在房间内打开/隐藏播放器,不再直接进入歌曲管理页;房间播放器使用新增的关闭、最小化、上一首、下一首、进入管理页等素材;当前用户在麦上推送音乐时,自己麦位头像左下角会显示并旋转 `用户正在播放bgm` 标识。
- 已补齐 `en/ar/tr/bn` 四套现有多语言文案;当前项目声明支持 `zh`,但仓库里暂无 `assets/l10n/intl_zh.json`,因此中文兜底仍由页面内 fallback 文案承担。
- 验证进度:`flutter pub get` 已通过;新音乐相关目录和接线文件的定向 `flutter analyze` 无 error/warning`rtc_manager.dart` 保留项目原有 `avoid_print` info全量 `flutter analyze` 仍被仓库既有测试依赖缺失和测试 API 问题阻断;`flutter build apk --debug` 已通过并生成 `build/app/outputs/flutter-apk/app-debug.apk`
- 验证进度:`flutter pub get` 已通过;音乐相关目录与房间接线文件的定向 `flutter analyze` 无 error/warning当前仅 `rtc_manager.dart``sc_seat_item.dart` 保留项目原有 info 级别风格/废弃 API 提示;全量 `flutter analyze` 仍被仓库既有测试依赖缺失和测试 API 问题阻断;`flutter build apk --debug` 已通过并生成 `build/app/outputs/flutter-apk/app-debug.apk`
## 本轮语言房进房加载逻辑优化(已完成)
- 已按竞品加载思路梳理当前语言房进房链路:当前业务 `entryRoom` 成功后会先进入房间页IM 加群、声网入会、麦位与在线用户刷新在房间页展示后继续进行这与“IM 和声网都完成后才进入完整房间”的体验存在差异。