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 和声网都完成后才进入完整房间”的体验存在差异。