音乐播放

This commit is contained in:
roxy 2026-04-29 14:52:09 +08:00
parent 4de26d7663
commit ddb378f5b4
38 changed files with 2325 additions and 79 deletions

View File

@ -3,14 +3,19 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"
tools:node="remove" />

View File

@ -657,6 +657,18 @@
"lightMode": "الوضع الفاتح",
"systemDefault": "النظام الافتراضي",
"pleaseGetOnTheMicFirst": "يرجى الصعود إلى الميكروفون أولاً.",
"roomMusicAddMusic": "إضافة موسيقى",
"roomMusicSelectAll": "تحديد الكل",
"roomMusicAddedCount": "تمت إضافة {1} أغنية",
"roomMusicEmpty": "لا توجد موسيقى",
"roomMusicScanFromPhone": "المسح من الهاتف",
"roomMusicTotalSongs": "الإجمالي: {1} أغنية",
"roomMusicNotFound": "لم يتم العثور على موسيقى",
"roomMusicDeleteConfirm": "هل تريد حذف هذه الموسيقى؟",
"roomMusicDeleted": "تم حذف الموسيقى",
"roomMusicListMode": "تشغيل بالترتيب",
"roomMusicShuffleMode": "تشغيل عشوائي",
"roomMusicSingleMode": "تكرار أغنية واحدة",
"welcomeMessage": "مرحبًا بك في تطبيقنا، {name}!",
"operationFail": "فشلت العملية.",
"enterRoomFailedRetry": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.",

View File

@ -662,5 +662,17 @@
"lightMode": "লাইট মোড",
"systemDefault": "সিস্টেম ডিফল্ট",
"pleaseGetOnTheMicFirst": "অনুগ্রহ করে আগে মাইকে উঠুন।",
"roomMusicAddMusic": "মিউজিক যোগ করুন",
"roomMusicSelectAll": "সব নির্বাচন করুন",
"roomMusicAddedCount": "{1}টি গান যোগ হয়েছে",
"roomMusicEmpty": "কোনো মিউজিক নেই",
"roomMusicScanFromPhone": "ফোন থেকে স্ক্যান করুন",
"roomMusicTotalSongs": "মোট: {1}টি গান",
"roomMusicNotFound": "কোনো মিউজিক পাওয়া যায়নি",
"roomMusicDeleteConfirm": "এই মিউজিক মুছে ফেলবেন?",
"roomMusicDeleted": "মিউজিক মুছে ফেলা হয়েছে",
"roomMusicListMode": "ক্রম অনুসারে চালান",
"roomMusicShuffleMode": "শাফল চালান",
"roomMusicSingleMode": "একক গান পুনরাবৃত্তি",
"duration2": "সময়:{1}"
}

View File

@ -662,5 +662,17 @@
"lightMode": "Light Mode",
"systemDefault": "System Default",
"pleaseGetOnTheMicFirst": "Please get on the mic first.",
"roomMusicAddMusic": "Add Music",
"roomMusicSelectAll": "Select all",
"roomMusicAddedCount": "Added {1} songs",
"roomMusicEmpty": "No music",
"roomMusicScanFromPhone": "Scan from phone",
"roomMusicTotalSongs": "Total: {1} songs",
"roomMusicNotFound": "No music found",
"roomMusicDeleteConfirm": "Delete this music?",
"roomMusicDeleted": "Music deleted",
"roomMusicListMode": "Play in order",
"roomMusicShuffleMode": "Shuffle play",
"roomMusicSingleMode": "Single loop",
"duration2": "Duration:{1}"
}

View File

@ -661,6 +661,18 @@
"darkMode": "Karanlık Mod",
"lightMode": "Açık Mod",
"systemDefault": "Sistem Varsayılanı",
"pleaseGetOnTheMicFirst": "Lütfen önce mikroya çıkın.",
"duration2": "Süre:{1}"
}
"pleaseGetOnTheMicFirst": "Lütfen önce mikroya çıkın.",
"roomMusicAddMusic": "Müzik Ekle",
"roomMusicSelectAll": "Tümünü seç",
"roomMusicAddedCount": "{1} şarkı eklendi",
"roomMusicEmpty": "Müzik yok",
"roomMusicScanFromPhone": "Telefondan tara",
"roomMusicTotalSongs": "Toplam: {1} şarkı",
"roomMusicNotFound": "Müzik bulunamadı",
"roomMusicDeleteConfirm": "Bu müziği silmek istiyor musunuz?",
"roomMusicDeleted": "Müzik silindi",
"roomMusicListMode": "Sırayla çal",
"roomMusicShuffleMode": "Karışık çal",
"roomMusicSingleMode": "Tek şarkı döngüsü",
"duration2": "Süre:{1}"
}

View File

@ -209,6 +209,9 @@ PODS:
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- on_audio_query_ios (0.0.1):
- Flutter
- SwiftyBeaver
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
@ -231,6 +234,7 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyBeaver (1.9.5)
- tancent_vap (0.0.1):
- Flutter
- tencent_cloud_chat_sdk (8.0.0):
@ -269,6 +273,7 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
- on_audio_query_ios (from `.symlinks/plugins/on_audio_query_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@ -314,6 +319,7 @@ SPEC REPOS:
- RecaptchaInterop
- SDWebImage
- SDWebImageWebPCoder
- SwiftyBeaver
- TOCropViewController
- TXIMSDK_Plus_iOS_XCFramework
@ -348,6 +354,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
iris_method_channel:
:path: ".symlinks/plugins/iris_method_channel/ios"
on_audio_query_ios:
:path: ".symlinks/plugins/on_audio_query_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
@ -414,6 +422,7 @@ SPEC CHECKSUMS:
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
on_audio_query_ios: 28a780e2d0d85d92d500ba6e12c6c8167022b2fa
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@ -424,6 +433,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyBeaver: 84069991dd5dca07d7069100985badaca7f0ce82
tancent_vap: fa8ad93814a9f950514a7074c662d2c937084c68
tencent_cloud_chat_sdk: 55e5fffe20f6b7937a26a674ccccb639563a9790
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863

View File

@ -35,6 +35,7 @@ import 'services/gift/gift_animation_manager.dart';
import 'services/gift/gift_system_manager.dart';
import 'services/payment/google_payment_manager.dart';
import 'services/localization/localization_manager.dart';
import 'services/music/room_music_manager.dart';
import 'services/room/rc_room_manager.dart';
import 'services/audio/rtc_manager.dart';
import 'services/audio/rtm_manager.dart';
@ -235,6 +236,10 @@ class RootAppWithProviders extends StatelessWidget {
lazy: true,
create: (context) => RealTimeMessagingManager(),
),
ChangeNotifierProvider<RoomMusicManager>(
lazy: true,
create: (context) => RoomMusicManager(),
),
// Provider -
ChangeNotifierProvider<SocialChatRoomManager>(

View File

@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/modules/room/music/room_music_select_page.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
class RoomMusicFolderPage extends StatelessWidget {
const RoomMusicFolderPage({super.key, required this.folders});
final List<SCMusicFolderMode> folders;
@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),
),
),
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),
),
],
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_folder_page.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/music/local_music_scanner.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
import 'package:yumi/ui_kit/widgets/room/music/room_music_player_bar.dart';
class RoomMusicPage extends StatefulWidget {
const RoomMusicPage({super.key});
@override
State<RoomMusicPage> createState() => _RoomMusicPageState();
}
class _RoomMusicPageState extends State<RoomMusicPage> {
final TextEditingController _searchController = TextEditingController();
final LocalMusicScanner _scanner = LocalMusicScanner();
bool _isScanning = false;
bool _isEditing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final manager = context.read<RoomMusicManager>();
manager.attachRtcProvider(context.read<RtcProvider>());
manager.reload();
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@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),
),
],
);
},
),
),
],
),
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)),
),
),
),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return SizedBox(
height: 54.w,
child: Row(
children: [
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
),
Expanded(
child: Text(
RoomMusicTexts.t(context, "music", "音乐"),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
onPressed: _startAddFlow,
icon: 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,
),
),
],
),
);
}
Widget _buildSearchField() {
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 4.w, 16.w, 10.w),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
style: TextStyle(color: Colors.white, fontSize: 13.sp),
decoration: InputDecoration(
isDense: true,
hintText: RoomMusicTexts.t(context, "search", "搜索"),
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.42),
fontSize: 13.sp,
),
prefixIcon: Icon(
Icons.search,
color: Colors.white.withValues(alpha: 0.42),
size: 20.w,
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.08),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18.w),
borderSide: BorderSide.none,
),
),
),
);
}
Widget _buildTotal(BuildContext context, int count) {
final template = RoomMusicTexts.t(
context,
"roomMusicTotalSongs",
"总计:{1} 首歌曲",
);
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,
),
),
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.only(bottom: 120.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
"sc_images/room/sc_icon_room_music_empty.png",
width: 86.w,
height: 86.w,
),
SizedBox(height: 12.w),
Text(
RoomMusicTexts.t(context, "roomMusicEmpty", "没有音乐"),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 14.sp,
fontWeight: FontWeight.w600,
),
),
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(
borderRadius: BorderRadius.circular(22.w),
),
),
child: Text(
RoomMusicTexts.t(context, "roomMusicScanFromPhone", "从手机扫描添加"),
style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w700),
),
),
],
),
),
);
}
Widget _buildSongList(RoomMusicManager manager, List<SCMusicMode> songs) {
final bottomPadding = manager.current == null ? 20.w : 190.w;
return ListView.builder(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
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,
),
),
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,
),
);
},
);
}
Widget _buildReorderableList(
RoomMusicManager manager,
List<SCMusicMode> songs,
) {
final bottomPadding = manager.current == null ? 20.w : 190.w;
return ReorderableListView.builder(
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
itemCount: songs.length,
onReorder: (oldIndex, newIndex) {
manager.reorder(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final item = songs[index];
return _SongTile(
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,
),
),
);
},
);
}
Future<void> _startAddFlow() async {
if (_isScanning) {
return;
}
setState(() {
_isScanning = true;
});
final manager = context.read<RoomMusicManager>();
final folders = await _scanner.scanMp3Folders(addedSongs: manager.playlist);
if (!mounted) {
return;
}
setState(() {
_isScanning = false;
});
final songCount = folders.fold<int>(
0,
(sum, item) => sum + item.musicCount,
);
if (songCount == 0) {
SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐"));
return;
}
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => RoomMusicFolderPage(folders: folders)),
);
if (changed == true && mounted) {
await manager.reload();
}
}
Future<bool> _confirmDelete(SCMusicMode item) async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
backgroundColor: const Color(0xFF10352E),
title: Text(
RoomMusicTexts.t(context, "roomMusicDeleteConfirm", "确定删除音乐?"),
style: const TextStyle(color: Colors.white),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(RoomMusicTexts.t(context, "cancel", "取消")),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(RoomMusicTexts.t(context, "confirm", "确认")),
),
],
);
},
);
if (result == true && mounted) {
final deletedText = RoomMusicTexts.t(
context,
"roomMusicDeleted",
"音乐已删除",
);
final manager = context.read<RoomMusicManager>();
await manager.deleteMusic(item);
SCTts.show(deletedText);
}
return false;
}
List<SCMusicMode> _filteredSongs(List<SCMusicMode> songs) {
final keyword = _searchController.text.trim().toLowerCase();
if (keyword.isEmpty) {
return songs;
}
return songs.where((item) {
return item.title.toLowerCase().contains(keyword) ||
item.fileName.toLowerCase().contains(keyword);
}).toList();
}
}
class _SongTile extends StatelessWidget {
const _SongTile({
super.key,
required this.item,
required this.selected,
required this.playing,
required this.onTap,
this.trailing,
});
final SCMusicMode item;
final bool selected;
final bool playing;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return InkWell(
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)),
),
),
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,
),
),
SizedBox(height: 5.w),
Text(
_formatDuration(item.durationMs),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.52),
fontSize: 11.sp,
),
),
],
),
),
if (trailing != null) trailing!,
],
),
),
);
}
String _formatDuration(int milliseconds) {
final seconds = (milliseconds / 1000).floor();
final minutes = seconds ~/ 60;
final rest = seconds % 60;
return "$minutes:${rest.toString().padLeft(2, '0')}";
}
}

View File

@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
class RoomMusicSelectPage extends StatefulWidget {
const RoomMusicSelectPage({super.key, required this.folder});
final SCMusicFolderMode folder;
@override
State<RoomMusicSelectPage> createState() => _RoomMusicSelectPageState();
}
class _RoomMusicSelectPageState extends State<RoomMusicSelectPage> {
late Set<String> _initialSelectedIds;
late Set<String> _selectedIds;
bool get _hasChanged => !_setEquals(_initialSelectedIds, _selectedIds);
bool get _allSelected => _selectedIds.length == widget.folder.songs.length;
@override
void initState() {
super.initState();
final currentIds =
context
.read<RoomMusicManager>()
.playlist
.map((item) => item.id)
.toSet();
_initialSelectedIds =
widget.folder.songs
.where((item) => currentIds.contains(item.id))
.map((item) => item.id)
.toSet();
_selectedIds = {..._initialSelectedIds};
}
@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),
),
),
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),
],
),
);
}
Widget _buildSongItem(SCMusicMode item) {
final selected = _selectedIds.contains(item.id);
final alreadyAdded = _initialSelectedIds.contains(item.id);
final checkColor =
!selected
? Colors.transparent
: alreadyAdded
? Colors.white.withValues(alpha: 0.34)
: const Color(0xFF33E6A2);
return InkWell(
onTap: () {
setState(() {
if (selected) {
_selectedIds.remove(item.id);
} else {
_selectedIds.add(item.id);
}
});
},
child: SizedBox(
height: 64.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: 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,
),
),
],
),
),
Container(
width: 22.w,
height: 22.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: checkColor,
border: Border.all(
color:
selected
? checkColor
: Colors.white.withValues(alpha: 0.28),
),
),
child:
selected
? Icon(Icons.check, size: 15.w, color: Colors.white)
: null,
),
],
),
),
);
}
Widget _buildBottomBar(BuildContext context) {
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.fromLTRB(16.w, 12.w, 16.w, 12.w),
decoration: BoxDecoration(
color: const Color(0xFF082820),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
),
),
child: Row(
children: [
InkWell(
onTap: () {
setState(() {
if (_allSelected) {
_selectedIds.clear();
} else {
_selectedIds =
widget.folder.songs.map((item) => item.id).toSet();
}
});
},
child: Row(
children: [
Icon(
_allSelected
? Icons.check_circle
: Icons.radio_button_unchecked,
color:
_allSelected
? const Color(0xFF33E6A2)
: Colors.white.withValues(alpha: 0.55),
size: 22.w,
),
SizedBox(width: 8.w),
Text(
RoomMusicTexts.t(context, "roomMusicSelectAll", "全选"),
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
],
),
),
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(
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),
),
),
],
),
),
);
}
Future<void> _submit() async {
await context.read<RoomMusicManager>().applyFolderSelection(
folder: widget.folder,
selectedIds: _selectedIds,
);
if (!mounted) {
return;
}
Navigator.pop(context, true);
}
bool _setEquals(Set<String> a, Set<String> b) {
return a.length == b.length && a.containsAll(b);
}
String _formatDuration(int milliseconds) {
final seconds = (milliseconds / 1000).floor();
final minutes = seconds ~/ 60;
final rest = seconds % 60;
return "$minutes:${rest.toString().padLeft(2, '0')}";
}
}

View File

@ -0,0 +1,11 @@
import 'package:flutter/widgets.dart';
import 'package:yumi/app_localizations.dart';
class RoomMusicTexts {
const RoomMusicTexts._();
static String t(BuildContext context, String key, String fallback) {
final value = SCAppLocalizations.of(context)?.translate(key) ?? key;
return value == key ? fallback : value;
}
}

View File

@ -15,6 +15,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
import 'package:yumi/services/gift/gift_animation_manager.dart';
import 'package:yumi/services/gift/gift_system_manager.dart';
import 'package:yumi/services/music/room_music_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
@ -30,6 +31,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/shared/data_sources/models/enum/sc_gift_type.dart';
import '../../ui_kit/components/sc_float_ichart.dart';
@ -156,6 +158,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
unawaited(
Provider.of<RoomMusicManager>(context, listen: false).stopForRoomExit(),
);
}
Widget _buildRoomStartupLayer() {
@ -324,6 +329,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
],
),
_buildRoomStartupLayer(),
const RoomMusicFloatingEntry(),
// _buildPlayViews(),
///
LuckGiftNomorAnimWidget(),

View File

@ -7,6 +7,7 @@ import 'package:yumi/modules/room/background/room_background_preview_page.dart';
import 'package:yumi/modules/room/background/room_background_select_page.dart';
import 'package:yumi/modules/room/background/room_background_upload_page.dart';
import 'package:yumi/modules/room/edit/room_edit_page.dart';
import 'package:yumi/modules/room/music/room_music_page.dart';
import 'package:yumi/modules/room/them/room_theme_page.dart';
import 'package:yumi/modules/room/voice_room_page.dart';
import 'package:yumi/app/routes/sc_router_init.dart';
@ -18,6 +19,7 @@ class VoiceRoomRoute implements SCIRouterProvider {
static String roomBackgroundSelect = '/room/background/select';
static String roomBackgroundPreview = '/room/background/preview';
static String roomBackgroundUpload = '/room/background/upload';
static String roomMusic = '/room/music';
static Route<T> _buildRoute<T>(Widget page) {
if (Platform.isIOS) {
@ -80,6 +82,16 @@ class VoiceRoomRoute implements SCIRouterProvider {
);
}
static Future<T?> openRoomMusic<T>(
BuildContext context, {
bool rootNavigator = false,
}) {
return Navigator.of(
context,
rootNavigator: rootNavigator,
).push<T>(_buildRoute<T>(const RoomMusicPage()));
}
@override
void initRouter(FluroRouter router) {
router.define(
@ -108,6 +120,10 @@ class VoiceRoomRoute implements SCIRouterProvider {
handlerFunc: (_, params) => const RoomBackgroundUploadPage(),
),
);
router.define(
roomMusic,
handler: Handler(handlerFunc: (_, params) => const RoomMusicPage()),
);
router.define(
roomEdit,
handler: Handler(

View File

@ -45,6 +45,9 @@ import '../../ui_kit/components/sc_float_ichart.dart';
typedef OnSoundVoiceChange = Function(num index, int volum);
typedef RtcProvider = RealTimeCommunicationManager;
typedef RoomMusicMixingStateListener =
void Function(AudioMixingStateType state, AudioMixingReasonType reason);
typedef RoomMusicMicRouteChangedListener = void Function(bool isOnMic);
enum RoomStartupStatus { idle, loading, ready, failed }
@ -109,6 +112,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
RtcEngine? engine;
RtcEngineEventHandler? _rtcEngineEventHandler;
RoomMusicMixingStateListener? _roomMusicMixingStateListener;
RoomMusicMicRouteChangedListener? _roomMusicMicRouteChangedListener;
BuildContext? context;
RtmProvider? rtmProvider;
@ -706,6 +711,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
clientRole: ClientRoleType.clientRoleAudience,
muted: true,
);
_roomMusicMicRouteChangedListener?.call(false);
return;
}
@ -721,6 +727,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
clientRole: ClientRoleType.clientRoleBroadcaster,
muted: (currentUserMic.micMute ?? false) || isMic,
);
_roomMusicMicRouteChangedListener?.call(true);
}
void _syncRoomHeartbeatState({
@ -1299,6 +1306,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
default:
break;
}
_roomMusicMixingStateListener?.call(state, reason);
},
onUserJoined: (connection, remoteUid, elapsed) {
print('rtc用户 $remoteUid 加入了频道');
@ -2607,4 +2615,61 @@ class RealTimeCommunicationManager extends ChangeNotifier {
void adjustRecordingSignalVolume(int volume) {
engine?.adjustRecordingSignalVolume(volume);
}
void setRoomMusicMixingStateListener(RoomMusicMixingStateListener? listener) {
_roomMusicMixingStateListener = listener;
}
void setRoomMusicMicRouteChangedListener(
RoomMusicMicRouteChangedListener? listener,
) {
_roomMusicMicRouteChangedListener = listener;
}
Future<void> startRoomMusicAudioMixing({
required String filePath,
required bool loopback,
int cycle = 1,
int startPos = 0,
}) async {
if (filePath.trim().isEmpty) {
return;
}
await engine?.startAudioMixing(
filePath: filePath,
loopback: loopback,
cycle: cycle,
startPos: startPos,
);
}
Future<void> pauseRoomMusicAudioMixing() async {
await engine?.pauseAudioMixing();
}
Future<void> resumeRoomMusicAudioMixing() async {
await engine?.resumeAudioMixing();
}
Future<void> stopRoomMusicAudioMixing() async {
await engine?.stopAudioMixing();
}
Future<void> setRoomMusicAudioMixingPosition(int positionMs) async {
await engine?.setAudioMixingPosition(positionMs);
}
Future<int> getRoomMusicAudioMixingPosition() async {
return await engine?.getAudioMixingCurrentPosition() ?? 0;
}
Future<int> getRoomMusicAudioMixingDuration() async {
return await engine?.getAudioMixingDuration() ?? 0;
}
Future<void> setRoomMusicAudioMixingVolume(int volume) async {
final safeVolume = volume.clamp(0, 100).toInt();
await engine?.adjustAudioMixingPlayoutVolume(safeVolume);
await engine?.adjustAudioMixingPublishVolume(safeVolume);
}
}

View File

@ -0,0 +1,165 @@
import 'dart:io';
import 'package:on_audio_query/on_audio_query.dart';
import 'package:path/path.dart' as p;
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/shared/tools/sc_permission_utils.dart';
class LocalMusicScanner {
LocalMusicScanner({OnAudioQuery? audioQuery})
: _audioQuery = audioQuery ?? OnAudioQuery();
final OnAudioQuery _audioQuery;
Future<List<SCMusicFolderMode>> scanMp3Folders({
List<SCMusicMode> addedSongs = const [],
}) async {
final hasPermission = await SCPermissionUtils.checkAudioPermission();
if (!hasPermission) {
return const <SCMusicFolderMode>[];
}
final List<SongModel> songs;
try {
songs = await _audioQuery.querySongs(
sortType: SongSortType.TITLE,
orderType: OrderType.ASC_OR_SMALLER,
uriType: UriType.EXTERNAL,
ignoreCase: true,
);
} catch (_) {
return const <SCMusicFolderMode>[];
}
final addedIds = addedSongs.map((item) => item.id).toSet();
final addedPaths = addedSongs.map((item) => item.localPath).toSet();
final byFolder = <String, List<SCMusicMode>>{};
final seen = <String>{};
for (final song in songs) {
if (!_isMp3(song)) {
continue;
}
final localPath = _songString(song, "_data").trim();
if (localPath.isEmpty || !_fileExists(localPath)) {
continue;
}
final id = _songString(song, "_id");
final key = id.isNotEmpty ? id : localPath;
if (!seen.add(key)) {
continue;
}
final folderPath = p.dirname(localPath);
final fileName = _safeFileName(song, localPath);
final title = _safeTitle(song, fileName);
final model = SCMusicMode(
id: key,
title: title,
artist: _songString(song, "artist"),
durationMs: _songInt(song, "duration"),
localPath: localPath,
contentUri: _songString(song, "_uri"),
folderPath: folderPath,
fileName: fileName,
size: _songInt(song, "_size"),
addedAt: DateTime.now().millisecondsSinceEpoch,
);
byFolder.putIfAbsent(folderPath, () => <SCMusicMode>[]).add(model);
}
final folders =
byFolder.entries.map((entry) {
final path = entry.key;
final folderSongs =
entry.value..sort(
(a, b) =>
a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
final addedCount =
folderSongs.where((song) {
return addedIds.contains(song.id) ||
addedPaths.contains(song.localPath);
}).length;
return SCMusicFolderMode(
id: path,
name: p.basename(path).isEmpty ? path : p.basename(path),
path: path,
songs: List<SCMusicMode>.unmodifiable(folderSongs),
addedCount: addedCount,
);
}).toList()
..sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
return List<SCMusicFolderMode>.unmodifiable(folders);
}
bool _isMp3(SongModel song) {
final extension = _songString(
song,
"file_extension",
).toLowerCase().replaceFirst(".", "");
if (extension == "mp3") {
return true;
}
return _songString(song, "_display_name").toLowerCase().endsWith(".mp3") ||
_songString(song, "_data").toLowerCase().endsWith(".mp3");
}
bool _fileExists(String path) {
try {
return File(path).existsSync();
} catch (_) {
return false;
}
}
String _safeFileName(SongModel song, String localPath) {
final displayName = _songString(song, "_display_name").trim();
if (displayName.isNotEmpty) {
return displayName;
}
return p.basename(localPath);
}
String _safeTitle(SongModel song, String fileName) {
final title = _songString(song, "title").trim();
if (title.isNotEmpty) {
return title;
}
final displayNameWOExt = _songString(song, "_display_name_wo_ext").trim();
if (displayNameWOExt.isNotEmpty) {
return displayNameWOExt;
}
return p.basenameWithoutExtension(fileName);
}
String _songString(SongModel song, String key) {
final value = _songValue(song, key);
if (value == null) {
return "";
}
return value.toString();
}
int _songInt(SongModel song, String key) {
final value = _songValue(song, key);
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? "") ?? 0;
}
Object? _songValue(SongModel song, String key) {
try {
return song.getMap[key];
} catch (_) {
return null;
}
}
}

View File

@ -0,0 +1,445 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/foundation.dart';
import 'package:yumi/services/audio/rtc_manager.dart';
import 'package:yumi/services/music/room_music_repository.dart';
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/ui_kit/components/sc_tts.dart';
enum RoomMusicPlayMode { list, shuffle, single }
class RoomMusicManager extends ChangeNotifier {
RoomMusicManager({RoomMusicRepository? repository})
: _repository = repository ?? RoomMusicRepository() {
_playlist = _repository.loadSongs();
}
final RoomMusicRepository _repository;
final Random _random = Random();
RtcProvider? _rtcProvider;
Timer? _positionTimer;
List<SCMusicMode> _playlist = <SCMusicMode>[];
SCMusicMode? _current;
RoomMusicPlayMode _playMode = RoomMusicPlayMode.list;
int _volume = 70;
int _positionMs = 0;
int _durationMs = 0;
int _lastStartAtMs = 0;
int _ignoreStoppedUntilMs = 0;
bool _isPlaying = false;
bool _isPaused = false;
bool _isStarting = false;
bool _isStoppingByUser = false;
bool _lastLoopback = true;
List<SCMusicMode> get playlist => List.unmodifiable(_playlist);
SCMusicMode? get current => _current;
RoomMusicPlayMode get playMode => _playMode;
int get volume => _volume;
int get positionMs => _positionMs;
int get durationMs =>
_durationMs > 0 ? _durationMs : (_current?.durationMs ?? 0);
bool get isPlaying => _isPlaying;
bool get isPaused => _isPaused;
bool get hasCurrent => _current != null;
int get currentIndex {
final currentId = _current?.id;
if (currentId == null) {
return -1;
}
return _playlist.indexWhere((item) => item.id == currentId);
}
void attachRtcProvider(RtcProvider provider) {
if (_rtcProvider == provider) {
return;
}
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
_rtcProvider = provider;
provider.setRoomMusicMixingStateListener(_handleMixingStateChanged);
provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged);
}
Future<void> reload() async {
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> applyFolderSelection({
required SCMusicFolderMode folder,
required Set<String> selectedIds,
}) async {
final currentById = {for (final item in _playlist) item.id: item};
final folderIds = folder.songs.map((item) => item.id).toSet();
final next = <SCMusicMode>[
..._playlist.where((item) => !folderIds.contains(item.id)),
];
final now = DateTime.now().millisecondsSinceEpoch;
for (final song in folder.songs) {
if (!selectedIds.contains(song.id)) {
continue;
}
final existing = currentById[song.id];
next.add(
(existing ?? song).copyWith(
addedAt:
existing?.addedAt == null || existing!.addedAt <= 0
? now
: existing.addedAt,
),
);
}
await _savePlaylist(next);
}
Future<void> deleteMusic(SCMusicMode item) async {
final deletingCurrent = _current?.id == item.id;
if (deletingCurrent) {
await stop(clearCurrent: true);
}
await _savePlaylist(_playlist.where((song) => song.id != item.id).toList());
}
Future<void> reorder(int oldIndex, int newIndex) async {
if (oldIndex < 0 || oldIndex >= _playlist.length) {
return;
}
final next = [..._playlist];
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = next.removeAt(oldIndex);
next.insert(newIndex.clamp(0, next.length).toInt(), item);
await _savePlaylist(next);
}
Future<void> play(SCMusicMode item, {int startPositionMs = 0}) async {
if (item.playPath.isEmpty) {
SCTts.show("Music file not found");
return;
}
if (!item.playPath.startsWith("content://") &&
!File(item.localPath).existsSync()) {
SCTts.show("Music file not found");
await deleteMusic(item);
return;
}
_current = item;
_durationMs = item.durationMs;
_positionMs = startPositionMs;
_isPaused = false;
await _startCurrent(startPositionMs: startPositionMs);
}
Future<void> pause() async {
if (!_isPlaying || _rtcProvider == null) {
return;
}
await _rtcProvider?.pauseRoomMusicAudioMixing();
_isPlaying = false;
_isPaused = true;
_stopPositionTimer();
notifyListeners();
}
Future<void> resume() async {
if (_current == null || _rtcProvider == null) {
return;
}
if (_isPaused) {
await _rtcProvider?.resumeRoomMusicAudioMixing();
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
return;
}
await _startCurrent(startPositionMs: _positionMs);
}
Future<void> stop({bool clearCurrent = false}) async {
_isStoppingByUser = true;
try {
await _rtcProvider?.stopRoomMusicAudioMixing();
} catch (_) {
// Ignore stop failures; local state must still be reset.
} finally {
_isStoppingByUser = false;
_stopPositionTimer();
_isPlaying = false;
_isPaused = false;
_positionMs = 0;
if (clearCurrent) {
_current = null;
_durationMs = 0;
}
notifyListeners();
}
}
Future<void> stopForRoomExit() async {
await stop(clearCurrent: true);
}
Future<void> previous() async {
if (_playlist.isEmpty) {
return;
}
final index = currentIndex;
final targetIndex = index <= 0 ? _playlist.length - 1 : index - 1;
await play(_playlist[targetIndex]);
}
Future<void> next({bool fromAutoComplete = false}) async {
if (_playlist.isEmpty) {
await stop(clearCurrent: true);
return;
}
if (_playMode == RoomMusicPlayMode.single && fromAutoComplete) {
await play(_current ?? _playlist.first);
return;
}
int targetIndex = 0;
final index = currentIndex;
if (_playMode == RoomMusicPlayMode.shuffle) {
if (_playlist.length == 1) {
targetIndex = 0;
} else {
do {
targetIndex = _random.nextInt(_playlist.length);
} while (targetIndex == index);
}
} else {
targetIndex = index < 0 ? 0 : (index + 1) % _playlist.length;
}
await play(_playlist[targetIndex]);
}
Future<void> seek(int positionMs) async {
final safePosition = positionMs.clamp(0, durationMs).toInt();
_positionMs = safePosition;
await _rtcProvider?.setRoomMusicAudioMixingPosition(safePosition);
notifyListeners();
}
Future<void> setVolume(int volume) async {
_volume = volume.clamp(0, 100).toInt();
await _rtcProvider?.setRoomMusicAudioMixingVolume(_volume);
notifyListeners();
}
String switchPlayMode() {
switch (_playMode) {
case RoomMusicPlayMode.list:
_playMode = RoomMusicPlayMode.shuffle;
notifyListeners();
return "随机播放";
case RoomMusicPlayMode.shuffle:
_playMode = RoomMusicPlayMode.single;
notifyListeners();
return "单曲循环播放";
case RoomMusicPlayMode.single:
_playMode = RoomMusicPlayMode.list;
notifyListeners();
return "列表顺序播放";
}
}
Future<void> syncPlaybackRouting() async {
final current = _current;
final rtcProvider = _rtcProvider;
if (current == null || rtcProvider == null || !_isPlaying) {
return;
}
final nextLoopback = !rtcProvider.isOnMai();
if (nextLoopback == _lastLoopback) {
return;
}
final position = await rtcProvider
.getRoomMusicAudioMixingPosition()
.catchError((_) => _positionMs);
_ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900;
try {
await rtcProvider.stopRoomMusicAudioMixing();
await _startCurrent(startPositionMs: position);
} catch (_) {
_isPlaying = false;
_isPaused = false;
notifyListeners();
}
}
Future<void> _savePlaylist(List<SCMusicMode> songs) async {
await _repository.saveSongs(songs);
_playlist = _repository.loadSongs();
_restoreCurrentAfterPlaylistChange();
notifyListeners();
}
Future<void> _startCurrent({required int startPositionMs}) async {
final current = _current;
final rtcProvider = _rtcProvider;
if (current == null || rtcProvider == null || _isStarting) {
return;
}
_isStarting = true;
try {
await _waitForStartInterval();
_lastLoopback = !rtcProvider.isOnMai();
await rtcProvider.startRoomMusicAudioMixing(
filePath: current.playPath,
loopback: _lastLoopback,
cycle: 1,
startPos: startPositionMs,
);
_lastStartAtMs = DateTime.now().millisecondsSinceEpoch;
await rtcProvider.setRoomMusicAudioMixingVolume(_volume);
_positionMs = startPositionMs;
_durationMs =
current.durationMs > 0
? current.durationMs
: await rtcProvider.getRoomMusicAudioMixingDuration();
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
} catch (_) {
_isPlaying = false;
_isPaused = false;
_stopPositionTimer();
SCTts.show("Music playback failed");
notifyListeners();
} finally {
_isStarting = false;
}
}
Future<void> _waitForStartInterval() async {
final now = DateTime.now().millisecondsSinceEpoch;
final elapsed = now - _lastStartAtMs;
if (elapsed < 550) {
await Future<void>.delayed(Duration(milliseconds: 550 - elapsed));
}
}
void _startPositionTimer() {
_positionTimer?.cancel();
_positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
unawaited(_refreshPosition());
});
}
void _stopPositionTimer() {
_positionTimer?.cancel();
_positionTimer = null;
}
Future<void> _refreshPosition() async {
if (!_isPlaying || _rtcProvider == null) {
return;
}
try {
final position = await _rtcProvider!.getRoomMusicAudioMixingPosition();
final duration = await _rtcProvider!.getRoomMusicAudioMixingDuration();
if (position >= 0) {
_positionMs = position;
}
if (duration > 0) {
_durationMs = duration;
}
notifyListeners();
} catch (_) {
// Position polling is best-effort only.
}
}
void _handleMixingStateChanged(
AudioMixingStateType state,
AudioMixingReasonType reason,
) {
switch (state) {
case AudioMixingStateType.audioMixingStatePlaying:
_isPlaying = true;
_isPaused = false;
_startPositionTimer();
notifyListeners();
break;
case AudioMixingStateType.audioMixingStatePaused:
_isPlaying = false;
_isPaused = true;
_stopPositionTimer();
notifyListeners();
break;
case AudioMixingStateType.audioMixingStateStopped:
if (DateTime.now().millisecondsSinceEpoch < _ignoreStoppedUntilMs) {
return;
}
_stopPositionTimer();
if (reason ==
AudioMixingReasonType.audioMixingReasonAllLoopsCompleted) {
unawaited(next(fromAutoComplete: true));
return;
}
if (!_isStoppingByUser) {
_isPlaying = false;
_isPaused = false;
_positionMs = 0;
notifyListeners();
}
break;
case AudioMixingStateType.audioMixingStateFailed:
_stopPositionTimer();
_isPlaying = false;
_isPaused = false;
SCTts.show("Music playback failed");
notifyListeners();
break;
}
}
void _handleMicRouteChanged(bool isOnMic) {
unawaited(syncPlaybackRouting());
}
void _restoreCurrentAfterPlaylistChange() {
final currentId = _current?.id;
if (currentId == null) {
return;
}
final index = _playlist.indexWhere((item) => item.id == currentId);
if (index >= 0) {
_current = _playlist[index];
return;
}
_current = null;
_isPlaying = false;
_isPaused = false;
_positionMs = 0;
_durationMs = 0;
_stopPositionTimer();
}
@override
void dispose() {
_positionTimer?.cancel();
_rtcProvider?.setRoomMusicMixingStateListener(null);
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
super.dispose();
}
}

View File

@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
class RoomMusicRepository {
List<SCMusicMode> loadSongs() {
final raw = DataPersistence.getUserRoomMusic();
if (raw.trim().isEmpty) {
return <SCMusicMode>[];
}
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return <SCMusicMode>[];
}
final songs =
decoded
.map((item) => SCMusicMode.fromJson(item))
.where((item) => item.hasPlayablePath)
.toList()
..sort((a, b) {
final sort = a.sortIndex.compareTo(b.sortIndex);
if (sort != 0) {
return sort;
}
return a.addedAt.compareTo(b.addedAt);
});
return _normalizeSort(songs);
} catch (_) {
return <SCMusicMode>[];
}
}
Future<bool> saveSongs(List<SCMusicMode> songs) {
final normalized = _normalizeSort(_dedupeSongs(songs));
return DataPersistence.setUserRoomMusic(
jsonEncode(normalized.map((item) => item.toJson()).toList()),
);
}
List<SCMusicMode> _dedupeSongs(List<SCMusicMode> songs) {
final seen = <String>{};
final result = <SCMusicMode>[];
for (final song in songs) {
final key = song.id.isNotEmpty ? song.id : song.localPath;
if (key.isEmpty || !seen.add(key)) {
continue;
}
result.add(song);
}
return result;
}
List<SCMusicMode> _normalizeSort(List<SCMusicMode> songs) {
return List<SCMusicMode>.generate(
songs.length,
(index) => songs[index].copyWith(sortIndex: index),
growable: true,
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
class SCMusicFolderMode {
const SCMusicFolderMode({
required this.id,
required this.name,
required this.path,
required this.songs,
this.addedCount = 0,
});
final String id;
final String name;
final String path;
final List<SCMusicMode> songs;
final int addedCount;
int get musicCount => songs.length;
SCMusicFolderMode copyWith({List<SCMusicMode>? songs, int? addedCount}) {
return SCMusicFolderMode(
id: id,
name: name,
path: path,
songs: songs ?? this.songs,
addedCount: addedCount ?? this.addedCount,
);
}
}

View File

@ -1,37 +1,149 @@
class SCMusicMode {
SCMusicMode({String? title, String? artist,int? duration,String? localPath}) {
_title = title;
_artist = artist;
_duration = duration;
_localPath = localPath;
}
String? _title;
String? _artist;
int? _duration;
String? _localPath;
String? get title => _title;
String? get artist => _artist;
int? get duration => _duration;
String? get localPath => _localPath;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['title'] = _title;
map['artist'] = _artist;
map['duration'] = _duration;
map['localPath'] = _localPath;
return map;
}
SCMusicMode.fromJson(dynamic json) {
_title = json['title'];
_artist = json['artist'];
_duration = json['duration'];
_localPath = json['localPath'];
}
}
import 'dart:convert';
class SCMusicMode {
SCMusicMode({
String? id,
String? title,
String? artist,
int? duration,
int? durationMs,
String? localPath,
String? contentUri,
String? folderPath,
String? fileName,
int? size,
int? sortIndex,
int? addedAt,
}) {
_id = id;
_title = title;
_artist = artist;
_durationMs = durationMs ?? duration;
_localPath = localPath;
_contentUri = contentUri;
_folderPath = folderPath;
_fileName = fileName;
_size = size;
_sortIndex = sortIndex;
_addedAt = addedAt;
}
String? _id;
String? _title;
String? _artist;
int? _durationMs;
String? _localPath;
String? _contentUri;
String? _folderPath;
String? _fileName;
int? _size;
int? _sortIndex;
int? _addedAt;
String get id {
final rawId = (_id ?? "").trim();
if (rawId.isNotEmpty) {
return rawId;
}
final source = playPath.isNotEmpty ? playPath : title;
return base64Url.encode(utf8.encode(source)).replaceAll('=', '');
}
String get title => (_title ?? _fileName ?? "").trim();
String get artist => (_artist ?? "").trim();
int get durationMs => _durationMs ?? 0;
int get duration => durationMs;
String get localPath => (_localPath ?? "").trim();
String get contentUri => (_contentUri ?? "").trim();
String get folderPath => (_folderPath ?? "").trim();
String get fileName => (_fileName ?? title).trim();
int get size => _size ?? 0;
int get sortIndex => _sortIndex ?? 0;
int get addedAt => _addedAt ?? 0;
String get playPath => contentUri.isNotEmpty ? contentUri : localPath;
bool get hasPlayablePath => playPath.isNotEmpty;
SCMusicMode copyWith({
String? id,
String? title,
String? artist,
int? durationMs,
String? localPath,
String? contentUri,
String? folderPath,
String? fileName,
int? size,
int? sortIndex,
int? addedAt,
}) {
return SCMusicMode(
id: id ?? _id,
title: title ?? _title,
artist: artist ?? _artist,
durationMs: durationMs ?? _durationMs,
localPath: localPath ?? _localPath,
contentUri: contentUri ?? _contentUri,
folderPath: folderPath ?? _folderPath,
fileName: fileName ?? _fileName,
size: size ?? _size,
sortIndex: sortIndex ?? _sortIndex,
addedAt: addedAt ?? _addedAt,
);
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['id'] = _id;
map['title'] = _title;
map['artist'] = _artist;
map['duration'] = _durationMs;
map['durationMs'] = _durationMs;
map['localPath'] = _localPath;
map['contentUri'] = _contentUri;
map['folderPath'] = _folderPath;
map['fileName'] = _fileName;
map['size'] = _size;
map['sortIndex'] = _sortIndex;
map['addedAt'] = _addedAt;
return map;
}
SCMusicMode.fromJson(dynamic json) {
if (json is! Map) {
return;
}
_id = json['id']?.toString();
_title = json['title']?.toString();
_artist = json['artist']?.toString();
_durationMs = _asInt(json['durationMs'] ?? json['duration']);
_localPath = json['localPath']?.toString();
_contentUri = json['contentUri']?.toString();
_folderPath = json['folderPath']?.toString();
_fileName = json['fileName']?.toString();
_size = _asInt(json['size']);
_sortIndex = _asInt(json['sortIndex']);
_addedAt = _asInt(json['addedAt']);
}
static int? _asInt(dynamic value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value?.toString() ?? "");
}
}

View File

@ -1,23 +1,42 @@
import 'package:permission_handler/permission_handler.dart';
class SCPermissionUtils{
static Future<bool> checkMicrophonePermission() async {
//
var status = await Permission.microphone.status;
if (status.isDenied) {
//
status = await Permission.microphone.request();
}
return status.isGranted;
}
static Future<bool> checkPhotosPermission() async {
//
var status = await Permission.photos.status;
if (status.isDenied) {
//
status = await Permission.photos.request();
}
return status.isGranted;
}
}
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class SCPermissionUtils {
static Future<bool> checkMicrophonePermission() async {
//
var status = await Permission.microphone.status;
if (status.isDenied) {
//
status = await Permission.microphone.request();
}
return status.isGranted;
}
static Future<bool> checkPhotosPermission() async {
//
var status = await Permission.photos.status;
if (status.isDenied) {
//
status = await Permission.photos.request();
}
return status.isGranted;
}
static Future<bool> checkAudioPermission() async {
if (!Platform.isAndroid) {
return true;
}
final androidInfo = await DeviceInfoPlugin().androidInfo;
final permission =
androidInfo.version.sdkInt >= 33
? Permission.audio
: Permission.storage;
var status = await permission.status;
if (status.isDenied || status.isRestricted || status.isLimited) {
status = await permission.request();
}
return status.isGranted;
}
}

View File

@ -0,0 +1,87 @@
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 {
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 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,
),
),
),
);
},
);
}
}
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

@ -0,0 +1,258 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:yumi/modules/room/music/room_music_texts.dart';
import 'package:yumi/services/music/room_music_manager.dart';
class RoomMusicPlayerBar extends StatelessWidget {
const RoomMusicPlayerBar({super.key});
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 _previousAsset =
"sc_images/room/sc_icon_room_music_previous.png";
static const String _nextAsset = "sc_images/room/sc_icon_room_music_next.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) {
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, 12.w, 16.w, 12.w),
decoration: BoxDecoration(
color: const Color(0xF20B3029),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 18.w,
offset: Offset(0, -6.w),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
current.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4.w),
Text(
_formatDuration(position),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 11.sp,
),
),
],
),
),
IconButton(
onPressed: () => manager.stop(clearCurrent: true),
icon: const Icon(Icons.close, color: Colors.white),
),
],
),
Row(
children: [
Text(
_formatDuration(position),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3.w,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 6.w,
),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(
alpha: 0.16,
),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: duration.toDouble(),
value: position.toDouble(),
onChanged:
(value) => manager.seek(value.round().toInt()),
),
),
),
Text(
_formatDuration(duration),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 10.sp,
),
),
],
),
SizedBox(height: 4.w),
Row(
children: [
_IconButton(
asset: _modeAsset(manager.playMode),
size: 34.w,
onTap: () {
final message = manager.switchPlayMode();
ScaffoldMessenger.maybeOf(
context,
)?.hideCurrentSnackBar();
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(_localizedModeText(context, message)),
duration: const Duration(milliseconds: 900),
),
);
},
),
const Spacer(),
_IconButton(
asset: _previousAsset,
size: 34.w,
onTap: () {
manager.previous();
},
),
SizedBox(width: 16.w),
_IconButton(
asset: manager.isPlaying ? _pauseAsset : _playAsset,
size: 48.w,
onTap: () {
manager.isPlaying ? manager.pause() : manager.resume();
},
),
SizedBox(width: 16.w),
_IconButton(
asset: _nextAsset,
size: 34.w,
onTap: () {
manager.next();
},
),
const Spacer(),
Image.asset(_volumeAsset, width: 22.w, height: 22.w),
SizedBox(
width: 82.w,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2.w,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 5.w,
),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: const Color(0xFF33E6A2),
inactiveTrackColor: Colors.white.withValues(
alpha: 0.16,
),
thumbColor: Colors.white,
),
child: Slider(
min: 0,
max: 100,
value: manager.volume.toDouble(),
onChanged: (value) {
manager.setVolume(value.round());
},
),
),
),
],
),
],
),
),
);
},
);
}
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";
}
}
static String _localizedModeText(BuildContext context, String message) {
switch (message) {
case "随机播放":
return RoomMusicTexts.t(context, "roomMusicShuffleMode", message);
case "单曲循环播放":
return RoomMusicTexts.t(context, "roomMusicSingleMode", message);
case "列表顺序播放":
default:
return RoomMusicTexts.t(context, "roomMusicListMode", message);
}
}
}
class _IconButton extends StatelessWidget {
const _IconButton({
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,
height: size,
child: Center(child: Image.asset(asset, width: size, height: size)),
),
);
}
}

View File

@ -37,9 +37,9 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
static const int _menuSound = 4;
static const int _menuReport = 5;
static const int _menuBackground = 6;
static const int _menuMusic = 7;
static bool get _showBackgroundFeature => true;
List<RoomMenu> items1 = [];
List<RoomMenu> items2 = [];
@override
@ -49,14 +49,6 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
@override
Widget build(BuildContext context) {
// items1.clear();
// items1.add(
// RoomMenu(
// 0,
// SCAppLocalizations.of(context)!.music,
// "sc_icon_room_music_tag.png",
// ),
// );
// items1.add(
// RoomMenu(
// 1,
@ -129,6 +121,21 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
"sc_icon_room_background.png",
),
);
items2.add(
RoomMenu(
_menuMusic,
SCAppLocalizations.of(context)!.music,
"sc_music_material_room_menu.png",
),
);
} else {
items2.add(
RoomMenu(
_menuMusic,
SCAppLocalizations.of(context)!.music,
"sc_music_material_room_menu.png",
),
);
}
if (!Provider.of<RtcProvider>(context, listen: false).isFz()) {
items2.add(
@ -206,7 +213,13 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
],
),
onTap: () async {
if (item.id == _menuMicManagement) {
if (item.id == _menuMusic) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
VoiceRoomRoute.openRoomMusic(
navigatorKey.currentState!.context,
rootNavigator: true,
);
} else if (item.id == _menuMicManagement) {
SmartDialog.dismiss(tag: "showRoomMenuDialog");
SmartDialog.show(
tag: "showRoomMicSwitch",

View File

@ -765,6 +765,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
id3:
dependency: transitive
description:
name: id3
sha256: "24176a6e08db6297c8450079e94569cd8387f913c817e5e3d862be7dc191e0b8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.2"
image:
dependency: transitive
description:
@ -1068,6 +1076,44 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.0"
on_audio_query:
dependency: "direct main"
description:
path: "local_packages/on_audio_query-2.9.0"
relative: true
source: path
version: "2.9.0"
on_audio_query_android:
dependency: transitive
description:
path: "local_packages/on_audio_query_android-1.1.0"
relative: true
source: path
version: "1.1.0"
on_audio_query_ios:
dependency: transitive
description:
name: on_audio_query_ios
sha256: "9b3efa39a656fa3720980e3c6a1f55b7257d0032a45ffeb3f70eaa2c7f10f929"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
on_audio_query_platform_interface:
dependency: transitive
description:
name: on_audio_query_platform_interface
sha256: c23e019a31bd0774828476e428fd33b0dd1d82c9d4791dba80429358fc65dcd3
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.7.0"
on_audio_query_web:
dependency: transitive
description:
name: on_audio_query_web
sha256: "990efa52d879e6caffa97f24b34acd9caa1ce2c4c4cb873fe5a899a9b1af02c7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
package_config:
dependency: transitive
description:
@ -1093,7 +1139,7 @@ packages:
source: hosted
version: "3.2.1"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"

View File

@ -118,9 +118,12 @@ dependencies:
#缩略图
video_thumbnail: ^0.5.6
badges: ^3.1.2
#本地路径
path_provider: ^2.1.4
photo_view: ^0.15.0
#本地路径
path_provider: ^2.1.4
path: ^1.9.0
on_audio_query:
path: ./local_packages/on_audio_query-2.9.0
photo_view: ^0.15.0
flutter_staggered_grid_view: ^0.7.0
marquee: ^2.3.0
extended_nested_scroll_view: ^6.2.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 B

View File

@ -2,6 +2,21 @@
## 当前总目标
- 控制当前 Flutter Android 发包体积,持续定位冗余组件、超大资源和不合理构建配置,并把每一步处理结果落盘记录。
- 按桌面《Android音乐添加流程.md》在 Android 语言房内补齐本地 MP3 音乐功能,并持续记录素材接入、扫描添加、列表管理、播放器和房间播放链路进度。
## 本轮 Android 语言房音乐功能(进行中)
- 已查阅桌面《Android音乐添加流程.md》并产出可执行方案文档 `docs/android-room-music-execution-plan.md`覆盖功能范围、模块拆分、Android 权限、声网混音链路、UI 流程、风险点和 GPT 5.5 预估开发工时。
- 已导入桌面 `音乐素材` 文件夹内的 12 张音乐相关 PNG并统一落到 `sc_images/room/sc_music_material_*`,当前用于房间菜单入口、播放器按钮、模式切换、音量、文件夹、更多、删除和编辑等 UI 位。
- 已恢复并接入 Android 本地音频扫描能力:`pubspec.yaml` 新增本地 `on_audio_query` 依赖Android 清单补齐 `READ_MEDIA_AUDIO`,旧版 Android 继续兼容 `READ_EXTERNAL_STORAGE`;权限申请已统一收口到 `SCUtils.checkAudioPermission()`
- 已扩展音乐数据结构:`SCMusicMode` 已从旧的简单歌曲字段扩展为可持久化的本地歌曲模型,新增 `SCMusicFolderMode`;同时新增本地扫描器与仓库层,支持扫描 MP3、按文件夹分组、去重保存、排序归一、删除和本地 JSON 持久化。
- 已新增语言房音乐全局管理器 `RoomMusicManager`,统一管理播放列表、当前歌曲、播放/暂停/停止、上一首/下一首、播放进度、音量、顺序播放/随机播放/单曲循环,并根据麦上/麦下状态切换声网混音的 `loopback` 行为:麦下仅本地播放,麦上可推送到房间。
- 已在声网管理层补齐房间音乐专用封装:新增音乐混音状态回调、麦上状态变化回调,以及开始播放、暂停、恢复、停止、设置进度、读取进度、设置本地和远端混音音量等方法,供房间音乐管理器调用。
- 已完成首版页面链路:新增音乐主页、文件夹列表页、文件夹歌曲选择页、底部播放器和房间内悬浮音乐入口;当前支持空态、扫描添加、批量选择、已添加状态识别、搜索、左滑删除、编辑排序、播放器进度条、音量条、模式切换、暂停/播放和上下首切换。
- 已接入语言房入口:房间底部菜单已恢复 `Music` 入口并跳转音乐页;语音房页面已挂载音乐悬浮入口,离开房间时会主动停止当前房间音乐,避免混音残留到房外页面。
- 已按最新反馈调整房间菜单位置:`Music` 不再单独占用菜单第一行,而是并入常规菜单网格,紧跟在 `Background` 图标之后;背景入口功能开关保持打开状态。
- 已修正本地音乐扫描器的兼容性风险:扫描 MP3 时不再直接读取 `SongModel.fileExtension/displayName/title` 等可能因 Android MediaStore 返回空值而触发异常的 getter改为从插件原始 map 安全取值,并对文件存在性检查和扫描失败做兜底。
- 已补齐 `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`
## 本轮语言房进房加载逻辑优化(已完成)
- 已按竞品加载思路梳理当前语言房进房链路:当前业务 `entryRoom` 成功后会先进入房间页IM 加群、声网入会、麦位与在线用户刷新在房间页展示后继续进行这与“IM 和声网都完成后才进入完整房间”的体验存在差异。