From ddb378f5b49491cd4b1e98594afc448651385f9f Mon Sep 17 00:00:00 2001 From: roxy Date: Wed, 29 Apr 2026 14:52:09 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9F=B3=E4=B9=90=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 9 +- assets/l10n/intl_ar.json | 12 + assets/l10n/intl_bn.json | 12 + assets/l10n/intl_en.json | 12 + assets/l10n/intl_tr.json | 18 +- ios/Podfile.lock | 10 + lib/main.dart | 5 + .../room/music/room_music_folder_page.dart | 116 +++++ lib/modules/room/music/room_music_page.dart | 470 ++++++++++++++++++ .../room/music/room_music_select_page.dart | 240 +++++++++ lib/modules/room/music/room_music_texts.dart | 11 + lib/modules/room/voice_room_page.dart | 6 + lib/modules/room/voice_room_route.dart | 16 + lib/services/audio/rtc_manager.dart | 65 +++ lib/services/music/local_music_scanner.dart | 165 ++++++ lib/services/music/room_music_manager.dart | 445 +++++++++++++++++ lib/services/music/room_music_repository.dart | 62 +++ .../models/sc_music_folder_mode.dart | 29 ++ .../data_sources/models/sc_music_mode.dart | 186 +++++-- lib/shared/tools/sc_permission_utils.dart | 65 ++- .../room/music/room_music_floating_entry.dart | 87 ++++ .../room/music/room_music_player_bar.dart | 258 ++++++++++ lib/ui_kit/widgets/room/room_menu_dialog.dart | 33 +- pubspec.lock | 48 +- pubspec.yaml | 9 +- sc_images/room/sc_music_material_delete.png | Bin 0 -> 578 bytes sc_images/room/sc_music_material_edit.png | Bin 0 -> 551 bytes sc_images/room/sc_music_material_folder.png | Bin 0 -> 3139 bytes sc_images/room/sc_music_material_more.png | Bin 0 -> 446 bytes sc_images/room/sc_music_material_pause.png | Bin 0 -> 1472 bytes sc_images/room/sc_music_material_play.png | Bin 0 -> 1062 bytes .../room/sc_music_material_room_menu.png | Bin 0 -> 1453 bytes .../room/sc_music_material_room_side.png | Bin 0 -> 64275 bytes sc_images/room/sc_music_material_sequence.png | Bin 0 -> 678 bytes sc_images/room/sc_music_material_shuffle.png | Bin 0 -> 586 bytes .../room/sc_music_material_single_loop.png | Bin 0 -> 729 bytes sc_images/room/sc_music_material_volume.png | Bin 0 -> 691 bytes 需求进度.md | 15 + 38 files changed, 2325 insertions(+), 79 deletions(-) create mode 100644 lib/modules/room/music/room_music_folder_page.dart create mode 100644 lib/modules/room/music/room_music_page.dart create mode 100644 lib/modules/room/music/room_music_select_page.dart create mode 100644 lib/modules/room/music/room_music_texts.dart create mode 100644 lib/services/music/local_music_scanner.dart create mode 100644 lib/services/music/room_music_manager.dart create mode 100644 lib/services/music/room_music_repository.dart create mode 100644 lib/shared/data_sources/models/sc_music_folder_mode.dart create mode 100644 lib/ui_kit/widgets/room/music/room_music_floating_entry.dart create mode 100644 lib/ui_kit/widgets/room/music/room_music_player_bar.dart create mode 100644 sc_images/room/sc_music_material_delete.png create mode 100644 sc_images/room/sc_music_material_edit.png create mode 100644 sc_images/room/sc_music_material_folder.png create mode 100644 sc_images/room/sc_music_material_more.png create mode 100644 sc_images/room/sc_music_material_pause.png create mode 100644 sc_images/room/sc_music_material_play.png create mode 100644 sc_images/room/sc_music_material_room_menu.png create mode 100644 sc_images/room/sc_music_material_room_side.png create mode 100644 sc_images/room/sc_music_material_sequence.png create mode 100644 sc_images/room/sc_music_material_shuffle.png create mode 100644 sc_images/room/sc_music_material_single_loop.png create mode 100644 sc_images/room/sc_music_material_volume.png 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 0000000000000000000000000000000000000000..c3412d1ad06564e2b56094e649da84da88cd83c3 GIT binary patch literal 578 zcmeAS@N?(olHy`uVBq!ia0vp^8bGYg!3HF`vQ8ZaQjEnx?oJHr&dIz4a$Hg)JkxxA z8MJ_G4hF{dOa>N^5+IfWVg?501&j>LK$;OGwtxvPtFeF?&ejGgv~`oI0V-SL>Eaj? z!TC0#v9Q^Ir^U~~`~pX8qwgip9eEdcC;4Wmdzc@PE@AogNWi^>FD!J@k9F7PDxI$K zJ|(T*zWS8LV~es2`7h#^x-%I~+j?-qhURygd)F3)UKLZYvS$9CbTG=$^hlTQqYDod zzNj9QX7Hc4ytgK@WmA4Z66Y233w*ndYr0BppR@53hw9CJQhvlAc#^W`kko%CrAO;{VLb&-t#sFKEVoF?(r~{_D$P zE&pcikyHDe^Y3s$^Mx4`LL3V^7*)=hiOqlRQShN%!tYVi=KGH>Ey$UAU5st-nUlK? zEiibvwbfC-c+)xq4WEkK#0#hAtkLG-5{`?O_+q5~nydLx%k_WqY#*79glp{Xp6dAM zR7R6-w$C!|bCSZ{pJhI}EAcyvt(-2%7E$q5N=4xh<5RU|ZV6jid}-LOB`@S_J5f1a*>F6*2UngGtX B<#_-A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59a46d1b9a494aa9e3fee2b68156b280c17cf8e9 GIT binary patch literal 551 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^R}E~ycoX}-P; zT0k}j17mw80}DtA5K93u0|WB{Mh0de%?J`(zyy~ySip>6gB0F2&*1?oo9OA{7?Q#I zc1CaSAp@RP&0UOnEgKlM@35IO&7LCoPR(Ptr1Sx{z3CI()VwbLxis&IN8p@W|B~#v zTy14iI+zwUXee+2sRQp!;uAZF2Sco27TNcs59#6lar|c>MdB&;+T} zXRe|PaJ?a|74QM@@pHEY?-wD-9& z&lZcnb>P|2=_VVx9~DdA@cn4X`)1XTD{MvG>6O0!dYkgw7cHw{7QAovDwAiQ#dj$u z(bKzk|6BUE@MK`Y--AWP|7X-1xyepm>AZ|9@S%NjoRi_Y{uwjMcKmy^SJj?3l&6H@ zK$_`=M`A@@*}JE*-Fmg#Z@Pa%?BZ>L@-r)b9X;U_>l55PuR;0gS{7T`*Y*j2j^6!{ z_UK9Z-Z(T+ql=FSf>c Ur`)!W_Mk}eboFyt=akR{0OM-U{Qv*} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..05e6966602359b335ad645aabc8f70f7180c0ea6 GIT binary patch literal 3139 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a807c594d15e0d2620d907c45d1541bdf97a7caa GIT binary patch literal 1453 zcmV;e1ycHnP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91Dxd=Z1ONa40RR91DgXcg0Mr_#3jhEG7fD1xR9FeUnT=KBRuD!*8i-Rt zln%TVtkMA;tU7>HAf*D_f!qP40+9+J6+AkCR6y=EZ?wfGR z4vujL3okHQ3nmz-U!~)5m_QQ&7+UBiW5|RqJzKbiml6L_Nv#?NhP`@NZ5k*ac_cMB zjY;qhI-tG;y~L*t>R{9xV4xm{#nOirIXci2+zpN#V1f?$4T>%F9Ge}`Q>JN$^ev2SCUxlM z5)6BfUc#bxaDeJ177a^cS0vXCS{l~V8{=O?n^y1=rmVNffPQ_eGM{q;6AX-tDB}+4 zB-l5wanK>39uFmyHsY?K7jx)9LaEBQQr8D#yUM-Gl(AF2$h&dSJ1B-};IT8Yn_b4s zk0#JRGRw-+(%=YKhB<)9xyT+$j2Bab0` zA&RT70+XZ}t2|9p7-oXSb*g#Q79rRv9Mius*#5jZpS?@VNaG=&D?4ScW zSjtqFUZRT@=;tu-dTDs-@FEk8@35%xZfPypLNUM?GNE5bVRYlP1*-7vNxYkgKz@HW z_M(;_sC5n9Tzsg$<+Jv2A?Q5}Y>sv!VtS+YXz&7ubqz{m9p%Sb;K-{=E4D%NF1uAwf5pE;N#iADH4WD zc=AyZ<8Un;;1t$A`C2Xc&jxSoS5^(Dc*1ZGl~Sx=m8m|(Er!4NomKhy;P$=_TUdVB zIL?AOZm?cD+3bxTDx4eI^ec=F#`d1D$$R)Oyj5p|+7{z~JAq0ABD#Yt00000NkvXX Hu0mjfA0d-g literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4a8d850505701d18dcff8388eb2eb9686b61413d GIT binary patch literal 64275 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 Iol`;+07b;-a{vGU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ecfd68a0e44c54b3d3764f8e5a01fc2413df9c4e GIT binary patch literal 729 zcmeAS@N?(olHy`uVBq!ia0vp^8X(NU1|)m_?Z^dEjKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(G%g|`iOjy~SaH$l*%$^8P`SB^Uh_YO!cl-@FXg>(u_N^=VH_wOYx{ed2xpThqM zzKe5U;^!C0Iezl;<+R&u+jw)Ncf4jR+a6#)|NQwEEDH9w^0xn;-FN@F#2W^-fX$6A zH9uNR-ZnIb8FxHXY`*j&)#$4Ace#w&jBgs{9*7%ExczdS^QD(lN-rlAu3Ttwpy77m z%4dsgUft@NUC6Q4=@HY_9i?lxp0$~xyYh7VvqLT+ya8*^9Qg15Cu*&J&5=)UW%mh4 zF$>S#w{cBx+YYk}S8r(6ACg{S$Fh_0L|k@MdFSN~?93C6MhO@FT)lDqN1K0#`HST) ze*AlA`(gK9_jTcZm#12^ICptFPk844XtCPY{5#iYw=}A@sIpdUn(=vuegDOu^1t^i zd)YIO|M{h5H)0Bvj%l|4m_J3^Qh7!1Hfh$&4`V9AbS5UP6yB-$v^YJ+sWZNJCBuph zY3o(G{O52bdB&JJY|T5SIAzCX!>%v2yHe(}wXA6uO3`0%W5$lQEiFpvWfuNwil#OU z+fO9Q9I1Ra*Wly9&%ylfFEjgnUj44)=V_xgS~Vgw8Gq*(-(6aJ@x_6B0lljl*A!p8 zm+pJ~JH3>dcWRbc_IUTB!9x@dDC8>i^PAG#BwwSt0`-QoGG__20(+NBy@P zu4qrVJ?Avz$#$s&g8TOF&^c!Rnmg#gy?sKvS&PDEmD|tt%RitO(!R&3SpV*hPw`jE z-yZARrn!fqHc05iouoO|(nq#4D(yTiDjgr@eq)Jz@%g>dB_8=V!sVXjIR0_o6n1D* zVpQ~z{a0t)$y;3JE^GXtB~goix8KqRXZ=M=Du>U;a2c))TzP)QyA_Ms-lWWZRuR67 zbIDC^-lJK+;(00rCC==6(-0Q@fBU~~#@@2k`*xeP1#T`s@VV`FcB|Z@xpvNbJ%t`T zoWGDo>2|Z#pI6?=SFU&M+>^{Ug>C8Vme`9A{NkDPuULsyRq@44kd28u5UYLK@<`ss zn5hdsDIDdwVeo|6N==?cJK=v`%)7O6_hO!Fm^D8=;MG|8YWcYvDJ5p70_p_0^c()p zn|6a$;AHK(`nF&W!J9ue2A(&W;5+5ORGVuokG49S8Lm5$bpAm=-rv?n6L$UPCv6)- ziqD=7G<_Gp?M7Fvp7_SF4=Jx#OQ;{c#0G=MZXXaAdww(S2$zW-D8YET`njxgN@xNA D1-}|S literal 0 HcmV?d00001 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 和声网都完成后才进入完整房间”的体验存在差异。