diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index cfec910..3f2ccca 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -3,14 +3,19 @@
-
+
-
+
+
diff --git a/assets/l10n/intl_ar.json b/assets/l10n/intl_ar.json
index e18fd57..3c01056 100644
--- a/assets/l10n/intl_ar.json
+++ b/assets/l10n/intl_ar.json
@@ -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": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.",
diff --git a/assets/l10n/intl_bn.json b/assets/l10n/intl_bn.json
index 109e334..1ace482 100644
--- a/assets/l10n/intl_bn.json
+++ b/assets/l10n/intl_bn.json
@@ -662,5 +662,17 @@
"lightMode": "লাইট মোড",
"systemDefault": "সিস্টেম ডিফল্ট",
"pleaseGetOnTheMicFirst": "অনুগ্রহ করে আগে মাইকে উঠুন।",
+ "roomMusicAddMusic": "মিউজিক যোগ করুন",
+ "roomMusicSelectAll": "সব নির্বাচন করুন",
+ "roomMusicAddedCount": "{1}টি গান যোগ হয়েছে",
+ "roomMusicEmpty": "কোনো মিউজিক নেই",
+ "roomMusicScanFromPhone": "ফোন থেকে স্ক্যান করুন",
+ "roomMusicTotalSongs": "মোট: {1}টি গান",
+ "roomMusicNotFound": "কোনো মিউজিক পাওয়া যায়নি",
+ "roomMusicDeleteConfirm": "এই মিউজিক মুছে ফেলবেন?",
+ "roomMusicDeleted": "মিউজিক মুছে ফেলা হয়েছে",
+ "roomMusicListMode": "ক্রম অনুসারে চালান",
+ "roomMusicShuffleMode": "শাফল চালান",
+ "roomMusicSingleMode": "একক গান পুনরাবৃত্তি",
"duration2": "সময়:{1}"
}
diff --git a/assets/l10n/intl_en.json b/assets/l10n/intl_en.json
index 5f1facc..6710fb2 100644
--- a/assets/l10n/intl_en.json
+++ b/assets/l10n/intl_en.json
@@ -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}"
}
diff --git a/assets/l10n/intl_tr.json b/assets/l10n/intl_tr.json
index fd5eb76..e09dc8c 100644
--- a/assets/l10n/intl_tr.json
+++ b/assets/l10n/intl_tr.json
@@ -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}"
+}
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 0208bd7..cdaa4c8 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -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
diff --git a/lib/main.dart b/lib/main.dart
index 3b0e05a..848e63a 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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(
+ lazy: true,
+ create: (context) => RoomMusicManager(),
+ ),
// 房间与社交功能Provider - 懒加载
ChangeNotifierProvider(
diff --git a/lib/modules/room/music/room_music_folder_page.dart b/lib/modules/room/music/room_music_folder_page.dart
new file mode 100644
index 0000000..3ca30e6
--- /dev/null
+++ b/lib/modules/room/music/room_music_folder_page.dart
@@ -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 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(
+ MaterialPageRoute(
+ builder: (_) => RoomMusicSelectPage(folder: folder),
+ ),
+ );
+ if (changed == true && context.mounted) {
+ Navigator.pop(context, true);
+ }
+ },
+ child: SizedBox(
+ height: 82.w,
+ child: Row(
+ children: [
+ Image.asset(
+ "sc_images/room/sc_music_material_folder.png",
+ width: 44.w,
+ height: 44.w,
+ ),
+ SizedBox(width: 12.w),
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ folder.name,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 14.sp,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ if (folder.addedCount > 0)
+ Text(
+ RoomMusicTexts.t(
+ context,
+ "roomMusicAddedCount",
+ "已添加 ${folder.addedCount} 首",
+ ).replaceAll(
+ "{1}",
+ folder.addedCount.toString(),
+ ),
+ style: TextStyle(
+ color: const Color(0xFF33E6A2),
+ fontSize: 11.sp,
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: 6.w),
+ Text(
+ "${folder.musicCount} Music | ${folder.path}",
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textDirection: TextDirection.ltr,
+ style: TextStyle(
+ color: Colors.white.withValues(alpha: 0.52),
+ fontSize: 11.sp,
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: 8.w),
+ Icon(
+ Icons.chevron_right,
+ color: Colors.white.withValues(alpha: 0.55),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/modules/room/music/room_music_page.dart b/lib/modules/room/music/room_music_page.dart
new file mode 100644
index 0000000..5455dc0
--- /dev/null
+++ b/lib/modules/room/music/room_music_page.dart
@@ -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 createState() => _RoomMusicPageState();
+}
+
+class _RoomMusicPageState extends State {
+ 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();
+ manager.attachRtcProvider(context.read());
+ 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(
+ 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 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 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 _startAddFlow() async {
+ if (_isScanning) {
+ return;
+ }
+ setState(() {
+ _isScanning = true;
+ });
+ final manager = context.read();
+ final folders = await _scanner.scanMp3Folders(addedSongs: manager.playlist);
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _isScanning = false;
+ });
+ final songCount = folders.fold(
+ 0,
+ (sum, item) => sum + item.musicCount,
+ );
+ if (songCount == 0) {
+ SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐"));
+ return;
+ }
+ final changed = await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => RoomMusicFolderPage(folders: folders)),
+ );
+ if (changed == true && mounted) {
+ await manager.reload();
+ }
+ }
+
+ Future _confirmDelete(SCMusicMode item) async {
+ final result = await showDialog(
+ 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();
+ await manager.deleteMusic(item);
+ SCTts.show(deletedText);
+ }
+ return false;
+ }
+
+ List _filteredSongs(List 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')}";
+ }
+}
diff --git a/lib/modules/room/music/room_music_select_page.dart b/lib/modules/room/music/room_music_select_page.dart
new file mode 100644
index 0000000..0f96c84
--- /dev/null
+++ b/lib/modules/room/music/room_music_select_page.dart
@@ -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 createState() => _RoomMusicSelectPageState();
+}
+
+class _RoomMusicSelectPageState extends State {
+ late Set _initialSelectedIds;
+ late Set _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()
+ .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 _submit() async {
+ await context.read().applyFolderSelection(
+ folder: widget.folder,
+ selectedIds: _selectedIds,
+ );
+ if (!mounted) {
+ return;
+ }
+ Navigator.pop(context, true);
+ }
+
+ bool _setEquals(Set a, Set 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')}";
+ }
+}
diff --git a/lib/modules/room/music/room_music_texts.dart b/lib/modules/room/music/room_music_texts.dart
new file mode 100644
index 0000000..a2a8779
--- /dev/null
+++ b/lib/modules/room/music/room_music_texts.dart
@@ -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;
+ }
+}
diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart
index 826c8a5..6dc686d 100644
--- a/lib/modules/room/voice_room_page.dart
+++ b/lib/modules/room/voice_room_page.dart
@@ -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
OverlayManager().removeRoom();
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
SCGiftVapSvgaManager().stopPlayback();
+ unawaited(
+ Provider.of(context, listen: false).stopForRoomExit(),
+ );
}
Widget _buildRoomStartupLayer() {
@@ -324,6 +329,7 @@ class _VoiceRoomPageState extends State
],
),
_buildRoomStartupLayer(),
+ const RoomMusicFloatingEntry(),
// _buildPlayViews(),
///幸运礼物中奖动画
LuckGiftNomorAnimWidget(),
diff --git a/lib/modules/room/voice_room_route.dart b/lib/modules/room/voice_room_route.dart
index 4eb9dc7..070c227 100644
--- a/lib/modules/room/voice_room_route.dart
+++ b/lib/modules/room/voice_room_route.dart
@@ -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 _buildRoute(Widget page) {
if (Platform.isIOS) {
@@ -80,6 +82,16 @@ class VoiceRoomRoute implements SCIRouterProvider {
);
}
+ static Future openRoomMusic(
+ BuildContext context, {
+ bool rootNavigator = false,
+ }) {
+ return Navigator.of(
+ context,
+ rootNavigator: rootNavigator,
+ ).push(_buildRoute(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(
diff --git a/lib/services/audio/rtc_manager.dart b/lib/services/audio/rtc_manager.dart
index 3252ae0..ddbf9a5 100644
--- a/lib/services/audio/rtc_manager.dart
+++ b/lib/services/audio/rtc_manager.dart
@@ -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 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 pauseRoomMusicAudioMixing() async {
+ await engine?.pauseAudioMixing();
+ }
+
+ Future resumeRoomMusicAudioMixing() async {
+ await engine?.resumeAudioMixing();
+ }
+
+ Future stopRoomMusicAudioMixing() async {
+ await engine?.stopAudioMixing();
+ }
+
+ Future setRoomMusicAudioMixingPosition(int positionMs) async {
+ await engine?.setAudioMixingPosition(positionMs);
+ }
+
+ Future getRoomMusicAudioMixingPosition() async {
+ return await engine?.getAudioMixingCurrentPosition() ?? 0;
+ }
+
+ Future getRoomMusicAudioMixingDuration() async {
+ return await engine?.getAudioMixingDuration() ?? 0;
+ }
+
+ Future setRoomMusicAudioMixingVolume(int volume) async {
+ final safeVolume = volume.clamp(0, 100).toInt();
+ await engine?.adjustAudioMixingPlayoutVolume(safeVolume);
+ await engine?.adjustAudioMixingPublishVolume(safeVolume);
+ }
}
diff --git a/lib/services/music/local_music_scanner.dart b/lib/services/music/local_music_scanner.dart
new file mode 100644
index 0000000..6dfd398
--- /dev/null
+++ b/lib/services/music/local_music_scanner.dart
@@ -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> scanMp3Folders({
+ List addedSongs = const [],
+ }) async {
+ final hasPermission = await SCPermissionUtils.checkAudioPermission();
+ if (!hasPermission) {
+ return const [];
+ }
+
+ final List songs;
+ try {
+ songs = await _audioQuery.querySongs(
+ sortType: SongSortType.TITLE,
+ orderType: OrderType.ASC_OR_SMALLER,
+ uriType: UriType.EXTERNAL,
+ ignoreCase: true,
+ );
+ } catch (_) {
+ return const [];
+ }
+
+ final addedIds = addedSongs.map((item) => item.id).toSet();
+ final addedPaths = addedSongs.map((item) => item.localPath).toSet();
+ final byFolder = >{};
+ final seen = {};
+
+ 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, () => []).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.unmodifiable(folderSongs),
+ addedCount: addedCount,
+ );
+ }).toList()
+ ..sort(
+ (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
+ );
+
+ return List.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;
+ }
+ }
+}
diff --git a/lib/services/music/room_music_manager.dart b/lib/services/music/room_music_manager.dart
new file mode 100644
index 0000000..7d93e6d
--- /dev/null
+++ b/lib/services/music/room_music_manager.dart
@@ -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 _playlist = [];
+ 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 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 reload() async {
+ _playlist = _repository.loadSongs();
+ _restoreCurrentAfterPlaylistChange();
+ notifyListeners();
+ }
+
+ Future applyFolderSelection({
+ required SCMusicFolderMode folder,
+ required Set selectedIds,
+ }) async {
+ final currentById = {for (final item in _playlist) item.id: item};
+ final folderIds = folder.songs.map((item) => item.id).toSet();
+ final next = [
+ ..._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 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 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 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 pause() async {
+ if (!_isPlaying || _rtcProvider == null) {
+ return;
+ }
+ await _rtcProvider?.pauseRoomMusicAudioMixing();
+ _isPlaying = false;
+ _isPaused = true;
+ _stopPositionTimer();
+ notifyListeners();
+ }
+
+ Future 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 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 stopForRoomExit() async {
+ await stop(clearCurrent: true);
+ }
+
+ Future previous() async {
+ if (_playlist.isEmpty) {
+ return;
+ }
+ final index = currentIndex;
+ final targetIndex = index <= 0 ? _playlist.length - 1 : index - 1;
+ await play(_playlist[targetIndex]);
+ }
+
+ Future 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 seek(int positionMs) async {
+ final safePosition = positionMs.clamp(0, durationMs).toInt();
+ _positionMs = safePosition;
+ await _rtcProvider?.setRoomMusicAudioMixingPosition(safePosition);
+ notifyListeners();
+ }
+
+ Future 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 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 _savePlaylist(List songs) async {
+ await _repository.saveSongs(songs);
+ _playlist = _repository.loadSongs();
+ _restoreCurrentAfterPlaylistChange();
+ notifyListeners();
+ }
+
+ Future _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 _waitForStartInterval() async {
+ final now = DateTime.now().millisecondsSinceEpoch;
+ final elapsed = now - _lastStartAtMs;
+ if (elapsed < 550) {
+ await Future.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 _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();
+ }
+}
diff --git a/lib/services/music/room_music_repository.dart b/lib/services/music/room_music_repository.dart
new file mode 100644
index 0000000..9294ff6
--- /dev/null
+++ b/lib/services/music/room_music_repository.dart
@@ -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 loadSongs() {
+ final raw = DataPersistence.getUserRoomMusic();
+ if (raw.trim().isEmpty) {
+ return [];
+ }
+ try {
+ final decoded = jsonDecode(raw);
+ if (decoded is! List) {
+ return [];
+ }
+ 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 [];
+ }
+ }
+
+ Future saveSongs(List songs) {
+ final normalized = _normalizeSort(_dedupeSongs(songs));
+ return DataPersistence.setUserRoomMusic(
+ jsonEncode(normalized.map((item) => item.toJson()).toList()),
+ );
+ }
+
+ List _dedupeSongs(List songs) {
+ final seen = {};
+ final result = [];
+ 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 _normalizeSort(List songs) {
+ return List.generate(
+ songs.length,
+ (index) => songs[index].copyWith(sortIndex: index),
+ growable: true,
+ );
+ }
+}
diff --git a/lib/shared/data_sources/models/sc_music_folder_mode.dart b/lib/shared/data_sources/models/sc_music_folder_mode.dart
new file mode 100644
index 0000000..fe4831c
--- /dev/null
+++ b/lib/shared/data_sources/models/sc_music_folder_mode.dart
@@ -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 songs;
+ final int addedCount;
+
+ int get musicCount => songs.length;
+
+ SCMusicFolderMode copyWith({List? songs, int? addedCount}) {
+ return SCMusicFolderMode(
+ id: id,
+ name: name,
+ path: path,
+ songs: songs ?? this.songs,
+ addedCount: addedCount ?? this.addedCount,
+ );
+ }
+}
diff --git a/lib/shared/data_sources/models/sc_music_mode.dart b/lib/shared/data_sources/models/sc_music_mode.dart
index cf177a0..82de3cb 100644
--- a/lib/shared/data_sources/models/sc_music_mode.dart
+++ b/lib/shared/data_sources/models/sc_music_mode.dart
@@ -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 toJson() {
- final map = {};
- 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 toJson() {
+ final map = {};
+ 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() ?? "");
+ }
+}
diff --git a/lib/shared/tools/sc_permission_utils.dart b/lib/shared/tools/sc_permission_utils.dart
index df305c7..f1fc454 100644
--- a/lib/shared/tools/sc_permission_utils.dart
+++ b/lib/shared/tools/sc_permission_utils.dart
@@ -1,23 +1,42 @@
-import 'package:permission_handler/permission_handler.dart';
-
-class SCPermissionUtils{
- static Future checkMicrophonePermission() async {
- // 检查当前权限状态
- var status = await Permission.microphone.status;
- if (status.isDenied) {
- // 请求权限(弹出系统弹窗)
- status = await Permission.microphone.request();
- }
- return status.isGranted;
- }
-
- static Future checkPhotosPermission() async {
- // 检查当前权限状态
- var status = await Permission.photos.status;
- if (status.isDenied) {
- // 请求权限(弹出系统弹窗)
- status = await Permission.photos.request();
- }
- return status.isGranted;
- }
-}
\ No newline at end of file
+import 'dart:io';
+
+import 'package:device_info_plus/device_info_plus.dart';
+import 'package:permission_handler/permission_handler.dart';
+
+class SCPermissionUtils {
+ static Future checkMicrophonePermission() async {
+ // 检查当前权限状态
+ var status = await Permission.microphone.status;
+ if (status.isDenied) {
+ // 请求权限(弹出系统弹窗)
+ status = await Permission.microphone.request();
+ }
+ return status.isGranted;
+ }
+
+ static Future checkPhotosPermission() async {
+ // 检查当前权限状态
+ var status = await Permission.photos.status;
+ if (status.isDenied) {
+ // 请求权限(弹出系统弹窗)
+ status = await Permission.photos.request();
+ }
+ return status.isGranted;
+ }
+
+ static Future 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;
+ }
+}
diff --git a/lib/ui_kit/widgets/room/music/room_music_floating_entry.dart b/lib/ui_kit/widgets/room/music/room_music_floating_entry.dart
new file mode 100644
index 0000000..cabc315
--- /dev/null
+++ b/lib/ui_kit/widgets/room/music/room_music_floating_entry.dart
@@ -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 createState() => _RoomMusicFloatingEntryState();
+}
+
+class _RoomMusicFloatingEntryState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 4),
+ );
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Selector(
+ selector:
+ (_, manager) => _FloatingMusicSnapshot(
+ visible: manager.current != null,
+ playing: manager.isPlaying,
+ ),
+ builder: (context, snapshot, child) {
+ if (!snapshot.visible) {
+ _controller.stop();
+ return 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);
+}
diff --git a/lib/ui_kit/widgets/room/music/room_music_player_bar.dart b/lib/ui_kit/widgets/room/music/room_music_player_bar.dart
new file mode 100644
index 0000000..a391ac4
--- /dev/null
+++ b/lib/ui_kit/widgets/room/music/room_music_player_bar.dart
@@ -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(
+ 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)),
+ ),
+ );
+ }
+}
diff --git a/lib/ui_kit/widgets/room/room_menu_dialog.dart b/lib/ui_kit/widgets/room/room_menu_dialog.dart
index 2c3c0df..79d42e3 100644
--- a/lib/ui_kit/widgets/room/room_menu_dialog.dart
+++ b/lib/ui_kit/widgets/room/room_menu_dialog.dart
@@ -37,9 +37,9 @@ class _RoomMenuDialogState extends State {
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 items1 = [];
List items2 = [];
@override
@@ -49,14 +49,6 @@ class _RoomMenuDialogState extends State {
@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 {
"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(context, listen: false).isFz()) {
items2.add(
@@ -206,7 +213,13 @@ class _RoomMenuDialogState extends State {
],
),
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",
diff --git a/pubspec.lock b/pubspec.lock
index 7e25cb1..9e5e689 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -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"
diff --git a/pubspec.yaml b/pubspec.yaml
index 17068a0..4fedae8 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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
diff --git a/sc_images/room/sc_music_material_delete.png b/sc_images/room/sc_music_material_delete.png
new file mode 100644
index 0000000..c3412d1
Binary files /dev/null and b/sc_images/room/sc_music_material_delete.png differ
diff --git a/sc_images/room/sc_music_material_edit.png b/sc_images/room/sc_music_material_edit.png
new file mode 100644
index 0000000..59a46d1
Binary files /dev/null and b/sc_images/room/sc_music_material_edit.png differ
diff --git a/sc_images/room/sc_music_material_folder.png b/sc_images/room/sc_music_material_folder.png
new file mode 100644
index 0000000..05e6966
Binary files /dev/null and b/sc_images/room/sc_music_material_folder.png differ
diff --git a/sc_images/room/sc_music_material_more.png b/sc_images/room/sc_music_material_more.png
new file mode 100644
index 0000000..0111c63
Binary files /dev/null and b/sc_images/room/sc_music_material_more.png differ
diff --git a/sc_images/room/sc_music_material_pause.png b/sc_images/room/sc_music_material_pause.png
new file mode 100644
index 0000000..77314c7
Binary files /dev/null and b/sc_images/room/sc_music_material_pause.png differ
diff --git a/sc_images/room/sc_music_material_play.png b/sc_images/room/sc_music_material_play.png
new file mode 100644
index 0000000..1acb7c0
Binary files /dev/null and b/sc_images/room/sc_music_material_play.png differ
diff --git a/sc_images/room/sc_music_material_room_menu.png b/sc_images/room/sc_music_material_room_menu.png
new file mode 100644
index 0000000..a807c59
Binary files /dev/null and b/sc_images/room/sc_music_material_room_menu.png differ
diff --git a/sc_images/room/sc_music_material_room_side.png b/sc_images/room/sc_music_material_room_side.png
new file mode 100644
index 0000000..4a8d850
Binary files /dev/null and b/sc_images/room/sc_music_material_room_side.png differ
diff --git a/sc_images/room/sc_music_material_sequence.png b/sc_images/room/sc_music_material_sequence.png
new file mode 100644
index 0000000..381c5a5
Binary files /dev/null and b/sc_images/room/sc_music_material_sequence.png differ
diff --git a/sc_images/room/sc_music_material_shuffle.png b/sc_images/room/sc_music_material_shuffle.png
new file mode 100644
index 0000000..2c4418e
Binary files /dev/null and b/sc_images/room/sc_music_material_shuffle.png differ
diff --git a/sc_images/room/sc_music_material_single_loop.png b/sc_images/room/sc_music_material_single_loop.png
new file mode 100644
index 0000000..ecfd68a
Binary files /dev/null and b/sc_images/room/sc_music_material_single_loop.png differ
diff --git a/sc_images/room/sc_music_material_volume.png b/sc_images/room/sc_music_material_volume.png
new file mode 100644
index 0000000..e1f60eb
Binary files /dev/null and b/sc_images/room/sc_music_material_volume.png differ
diff --git a/需求进度.md b/需求进度.md
index e6de69f..21a5f81 100644
--- a/需求进度.md
+++ b/需求进度.md
@@ -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 和声网都完成后才进入完整房间”的体验存在差异。