音乐播放
@ -3,14 +3,19 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"
|
||||
tools:node="remove" />
|
||||
|
||||
@ -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": "فشل دخول الغرفة. يرجى المحاولة مرة أخرى.",
|
||||
|
||||
@ -662,5 +662,17 @@
|
||||
"lightMode": "লাইট মোড",
|
||||
"systemDefault": "সিস্টেম ডিফল্ট",
|
||||
"pleaseGetOnTheMicFirst": "অনুগ্রহ করে আগে মাইকে উঠুন।",
|
||||
"roomMusicAddMusic": "মিউজিক যোগ করুন",
|
||||
"roomMusicSelectAll": "সব নির্বাচন করুন",
|
||||
"roomMusicAddedCount": "{1}টি গান যোগ হয়েছে",
|
||||
"roomMusicEmpty": "কোনো মিউজিক নেই",
|
||||
"roomMusicScanFromPhone": "ফোন থেকে স্ক্যান করুন",
|
||||
"roomMusicTotalSongs": "মোট: {1}টি গান",
|
||||
"roomMusicNotFound": "কোনো মিউজিক পাওয়া যায়নি",
|
||||
"roomMusicDeleteConfirm": "এই মিউজিক মুছে ফেলবেন?",
|
||||
"roomMusicDeleted": "মিউজিক মুছে ফেলা হয়েছে",
|
||||
"roomMusicListMode": "ক্রম অনুসারে চালান",
|
||||
"roomMusicShuffleMode": "শাফল চালান",
|
||||
"roomMusicSingleMode": "একক গান পুনরাবৃত্তি",
|
||||
"duration2": "সময়:{1}"
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -35,6 +35,7 @@ import 'services/gift/gift_animation_manager.dart';
|
||||
import 'services/gift/gift_system_manager.dart';
|
||||
import 'services/payment/google_payment_manager.dart';
|
||||
import 'services/localization/localization_manager.dart';
|
||||
import 'services/music/room_music_manager.dart';
|
||||
import 'services/room/rc_room_manager.dart';
|
||||
import 'services/audio/rtc_manager.dart';
|
||||
import 'services/audio/rtm_manager.dart';
|
||||
@ -235,6 +236,10 @@ class RootAppWithProviders extends StatelessWidget {
|
||||
lazy: true,
|
||||
create: (context) => RealTimeMessagingManager(),
|
||||
),
|
||||
ChangeNotifierProvider<RoomMusicManager>(
|
||||
lazy: true,
|
||||
create: (context) => RoomMusicManager(),
|
||||
),
|
||||
|
||||
// 房间与社交功能Provider - 懒加载
|
||||
ChangeNotifierProvider<SocialChatRoomManager>(
|
||||
|
||||
116
lib/modules/room/music/room_music_folder_page.dart
Normal file
@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:yumi/modules/room/music/room_music_select_page.dart';
|
||||
import 'package:yumi/modules/room/music/room_music_texts.dart';
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
|
||||
|
||||
class RoomMusicFolderPage extends StatelessWidget {
|
||||
const RoomMusicFolderPage({super.key, required this.folders});
|
||||
|
||||
final List<SCMusicFolderMode> folders;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF071F1B),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF071F1B),
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"),
|
||||
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
body: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 20.w),
|
||||
itemCount: folders.length,
|
||||
separatorBuilder:
|
||||
(_, __) =>
|
||||
Divider(color: Colors.white.withValues(alpha: 0.08), height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final folder = folders[index];
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
final changed = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RoomMusicSelectPage(folder: folder),
|
||||
),
|
||||
);
|
||||
if (changed == true && context.mounted) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 82.w,
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
"sc_images/room/sc_music_material_folder.png",
|
||||
width: 44.w,
|
||||
height: 44.w,
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
folder.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (folder.addedCount > 0)
|
||||
Text(
|
||||
RoomMusicTexts.t(
|
||||
context,
|
||||
"roomMusicAddedCount",
|
||||
"已添加 ${folder.addedCount} 首",
|
||||
).replaceAll(
|
||||
"{1}",
|
||||
folder.addedCount.toString(),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: const Color(0xFF33E6A2),
|
||||
fontSize: 11.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 6.w),
|
||||
Text(
|
||||
"${folder.musicCount} Music | ${folder.path}",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textDirection: TextDirection.ltr,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.52),
|
||||
fontSize: 11.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
470
lib/modules/room/music/room_music_page.dart
Normal file
@ -0,0 +1,470 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:yumi/modules/room/music/room_music_folder_page.dart';
|
||||
import 'package:yumi/modules/room/music/room_music_texts.dart';
|
||||
import 'package:yumi/services/audio/rtc_manager.dart';
|
||||
import 'package:yumi/services/music/local_music_scanner.dart';
|
||||
import 'package:yumi/services/music/room_music_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/music/room_music_player_bar.dart';
|
||||
|
||||
class RoomMusicPage extends StatefulWidget {
|
||||
const RoomMusicPage({super.key});
|
||||
|
||||
@override
|
||||
State<RoomMusicPage> createState() => _RoomMusicPageState();
|
||||
}
|
||||
|
||||
class _RoomMusicPageState extends State<RoomMusicPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final LocalMusicScanner _scanner = LocalMusicScanner();
|
||||
bool _isScanning = false;
|
||||
bool _isEditing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final manager = context.read<RoomMusicManager>();
|
||||
manager.attachRtcProvider(context.read<RtcProvider>());
|
||||
manager.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF071F1B),
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
_buildSearchField(),
|
||||
Expanded(
|
||||
child: Consumer<RoomMusicManager>(
|
||||
builder: (context, manager, child) {
|
||||
final songs = _filteredSongs(manager.playlist);
|
||||
if (manager.playlist.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_buildTotal(context, manager.playlist.length),
|
||||
Expanded(
|
||||
child:
|
||||
_isEditing && _searchController.text.isEmpty
|
||||
? _buildReorderableList(manager, songs)
|
||||
: _buildSongList(manager, songs),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: const RoomMusicPlayerBar(),
|
||||
),
|
||||
if (_isScanning)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF33E6A2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 54.w,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
RoomMusicTexts.t(context, "music", "音乐"),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _startAddFlow,
|
||||
icon: Image.asset(
|
||||
"sc_images/room/sc_icon_room_music_add.png",
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isEditing = !_isEditing;
|
||||
});
|
||||
},
|
||||
icon: Image.asset(
|
||||
"sc_images/room/sc_music_material_edit.png",
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 4.w, 16.w, 10.w),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: TextStyle(color: Colors.white, fontSize: 13.sp),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: RoomMusicTexts.t(context, "search", "搜索"),
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.42),
|
||||
fontSize: 13.sp,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: Colors.white.withValues(alpha: 0.42),
|
||||
size: 20.w,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.08),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(18.w),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotal(BuildContext context, int count) {
|
||||
final template = RoomMusicTexts.t(
|
||||
context,
|
||||
"roomMusicTotalSongs",
|
||||
"总计:{1} 首歌曲",
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 8.w),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
template.replaceAll("{1}", count.toString()),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 120.w),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
"sc_images/room/sc_icon_room_music_empty.png",
|
||||
width: 86.w,
|
||||
height: 86.w,
|
||||
),
|
||||
SizedBox(height: 12.w),
|
||||
Text(
|
||||
RoomMusicTexts.t(context, "roomMusicEmpty", "没有音乐"),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.w),
|
||||
ElevatedButton(
|
||||
onPressed: _startAddFlow,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF33E6A2),
|
||||
foregroundColor: const Color(0xFF06251F),
|
||||
minimumSize: Size(164.w, 42.w),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22.w),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
RoomMusicTexts.t(context, "roomMusicScanFromPhone", "从手机扫描添加"),
|
||||
style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSongList(RoomMusicManager manager, List<SCMusicMode> songs) {
|
||||
final bottomPadding = manager.current == null ? 20.w : 190.w;
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
|
||||
itemCount: songs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = songs[index];
|
||||
return Dismissible(
|
||||
key: ValueKey("music-${item.id}"),
|
||||
direction: DismissDirection.endToStart,
|
||||
confirmDismiss: (_) => _confirmDelete(item),
|
||||
background: Container(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
padding: EdgeInsetsDirectional.only(end: 18.w),
|
||||
color: const Color(0xFFE64A4A),
|
||||
child: Image.asset(
|
||||
"sc_images/room/sc_music_material_delete.png",
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
),
|
||||
),
|
||||
child: _SongTile(
|
||||
item: item,
|
||||
selected: manager.current?.id == item.id,
|
||||
playing: manager.current?.id == item.id && manager.isPlaying,
|
||||
onTap: () => manager.play(item),
|
||||
trailing:
|
||||
_isEditing
|
||||
? Image.asset(
|
||||
"sc_images/room/sc_music_material_more.png",
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReorderableList(
|
||||
RoomMusicManager manager,
|
||||
List<SCMusicMode> songs,
|
||||
) {
|
||||
final bottomPadding = manager.current == null ? 20.w : 190.w;
|
||||
return ReorderableListView.builder(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, bottomPadding),
|
||||
itemCount: songs.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
manager.reorder(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = songs[index];
|
||||
return _SongTile(
|
||||
key: ValueKey("reorder-${item.id}"),
|
||||
item: item,
|
||||
selected: manager.current?.id == item.id,
|
||||
playing: manager.current?.id == item.id && manager.isPlaying,
|
||||
onTap: () => manager.play(item),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Image.asset(
|
||||
"sc_images/room/sc_music_material_more.png",
|
||||
width: 24.w,
|
||||
height: 24.w,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startAddFlow() async {
|
||||
if (_isScanning) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isScanning = true;
|
||||
});
|
||||
final manager = context.read<RoomMusicManager>();
|
||||
final folders = await _scanner.scanMp3Folders(addedSongs: manager.playlist);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
final songCount = folders.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.musicCount,
|
||||
);
|
||||
if (songCount == 0) {
|
||||
SCTts.show(RoomMusicTexts.t(context, "roomMusicNotFound", "未找到音乐"));
|
||||
return;
|
||||
}
|
||||
final changed = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(builder: (_) => RoomMusicFolderPage(folders: folders)),
|
||||
);
|
||||
if (changed == true && mounted) {
|
||||
await manager.reload();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirmDelete(SCMusicMode item) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFF10352E),
|
||||
title: Text(
|
||||
RoomMusicTexts.t(context, "roomMusicDeleteConfirm", "确定删除音乐?"),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(RoomMusicTexts.t(context, "cancel", "取消")),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(RoomMusicTexts.t(context, "confirm", "确认")),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result == true && mounted) {
|
||||
final deletedText = RoomMusicTexts.t(
|
||||
context,
|
||||
"roomMusicDeleted",
|
||||
"音乐已删除",
|
||||
);
|
||||
final manager = context.read<RoomMusicManager>();
|
||||
await manager.deleteMusic(item);
|
||||
SCTts.show(deletedText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<SCMusicMode> _filteredSongs(List<SCMusicMode> songs) {
|
||||
final keyword = _searchController.text.trim().toLowerCase();
|
||||
if (keyword.isEmpty) {
|
||||
return songs;
|
||||
}
|
||||
return songs.where((item) {
|
||||
return item.title.toLowerCase().contains(keyword) ||
|
||||
item.fileName.toLowerCase().contains(keyword);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _SongTile extends StatelessWidget {
|
||||
const _SongTile({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.playing,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final SCMusicMode item;
|
||||
final bool selected;
|
||||
final bool playing;
|
||||
final VoidCallback onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 62.w,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
selected
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
playing
|
||||
? "sc_images/room/sc_music_material_pause.png"
|
||||
: "sc_images/room/sc_music_material_play.png",
|
||||
width: 28.w,
|
||||
height: 28.w,
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: selected ? const Color(0xFF33E6A2) : Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5.w),
|
||||
Text(
|
||||
_formatDuration(item.durationMs),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.52),
|
||||
fontSize: 11.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int milliseconds) {
|
||||
final seconds = (milliseconds / 1000).floor();
|
||||
final minutes = seconds ~/ 60;
|
||||
final rest = seconds % 60;
|
||||
return "$minutes:${rest.toString().padLeft(2, '0')}";
|
||||
}
|
||||
}
|
||||
240
lib/modules/room/music/room_music_select_page.dart
Normal file
@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:yumi/modules/room/music/room_music_texts.dart';
|
||||
import 'package:yumi/services/music/room_music_manager.dart';
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
|
||||
|
||||
class RoomMusicSelectPage extends StatefulWidget {
|
||||
const RoomMusicSelectPage({super.key, required this.folder});
|
||||
|
||||
final SCMusicFolderMode folder;
|
||||
|
||||
@override
|
||||
State<RoomMusicSelectPage> createState() => _RoomMusicSelectPageState();
|
||||
}
|
||||
|
||||
class _RoomMusicSelectPageState extends State<RoomMusicSelectPage> {
|
||||
late Set<String> _initialSelectedIds;
|
||||
late Set<String> _selectedIds;
|
||||
|
||||
bool get _hasChanged => !_setEquals(_initialSelectedIds, _selectedIds);
|
||||
|
||||
bool get _allSelected => _selectedIds.length == widget.folder.songs.length;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final currentIds =
|
||||
context
|
||||
.read<RoomMusicManager>()
|
||||
.playlist
|
||||
.map((item) => item.id)
|
||||
.toSet();
|
||||
_initialSelectedIds =
|
||||
widget.folder.songs
|
||||
.where((item) => currentIds.contains(item.id))
|
||||
.map((item) => item.id)
|
||||
.toSet();
|
||||
_selectedIds = {..._initialSelectedIds};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF071F1B),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF071F1B),
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
RoomMusicTexts.t(context, "roomMusicAddMusic", "添加音乐"),
|
||||
style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 8.w, 16.w, 16.w),
|
||||
itemCount: widget.folder.songs.length,
|
||||
separatorBuilder:
|
||||
(_, __) => Divider(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
height: 1,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildSongItem(widget.folder.songs[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSongItem(SCMusicMode item) {
|
||||
final selected = _selectedIds.contains(item.id);
|
||||
final alreadyAdded = _initialSelectedIds.contains(item.id);
|
||||
final checkColor =
|
||||
!selected
|
||||
? Colors.transparent
|
||||
: alreadyAdded
|
||||
? Colors.white.withValues(alpha: 0.34)
|
||||
: const Color(0xFF33E6A2);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedIds.remove(item.id);
|
||||
} else {
|
||||
_selectedIds.add(item.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 64.w,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5.w),
|
||||
Text(
|
||||
_formatDuration(item.durationMs),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.52),
|
||||
fontSize: 11.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 22.w,
|
||||
height: 22.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: checkColor,
|
||||
border: Border.all(
|
||||
color:
|
||||
selected
|
||||
? checkColor
|
||||
: Colors.white.withValues(alpha: 0.28),
|
||||
),
|
||||
),
|
||||
child:
|
||||
selected
|
||||
? Icon(Icons.check, size: 15.w, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 12.w, 16.w, 12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF082820),
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_allSelected) {
|
||||
_selectedIds.clear();
|
||||
} else {
|
||||
_selectedIds =
|
||||
widget.folder.songs.map((item) => item.id).toSet();
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_allSelected
|
||||
? Icons.check_circle
|
||||
: Icons.radio_button_unchecked,
|
||||
color:
|
||||
_allSelected
|
||||
? const Color(0xFF33E6A2)
|
||||
: Colors.white.withValues(alpha: 0.55),
|
||||
size: 22.w,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
RoomMusicTexts.t(context, "roomMusicSelectAll", "全选"),
|
||||
style: TextStyle(color: Colors.white, fontSize: 14.sp),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton(
|
||||
onPressed: _hasChanged ? _submit : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF33E6A2),
|
||||
disabledBackgroundColor: Colors.white.withValues(alpha: 0.18),
|
||||
foregroundColor: const Color(0xFF06251F),
|
||||
disabledForegroundColor: Colors.white.withValues(alpha: 0.38),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20.w),
|
||||
),
|
||||
minimumSize: Size(104.w, 40.w),
|
||||
),
|
||||
child: Text(
|
||||
RoomMusicTexts.t(context, "add", "添加"),
|
||||
style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
await context.read<RoomMusicManager>().applyFolderSelection(
|
||||
folder: widget.folder,
|
||||
selectedIds: _selectedIds,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
bool _setEquals(Set<String> a, Set<String> b) {
|
||||
return a.length == b.length && a.containsAll(b);
|
||||
}
|
||||
|
||||
String _formatDuration(int milliseconds) {
|
||||
final seconds = (milliseconds / 1000).floor();
|
||||
final minutes = seconds ~/ 60;
|
||||
final rest = seconds % 60;
|
||||
return "$minutes:${rest.toString().padLeft(2, '0')}";
|
||||
}
|
||||
}
|
||||
11
lib/modules/room/music/room_music_texts.dart
Normal file
@ -0,0 +1,11 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:yumi/app_localizations.dart';
|
||||
|
||||
class RoomMusicTexts {
|
||||
const RoomMusicTexts._();
|
||||
|
||||
static String t(BuildContext context, String key, String fallback) {
|
||||
final value = SCAppLocalizations.of(context)?.translate(key) ?? key;
|
||||
return value == key ? fallback : value;
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ import 'package:yumi/app/routes/sc_fluro_navigator.dart';
|
||||
import 'package:yumi/shared/business_logic/models/res/join_room_res.dart';
|
||||
import 'package:yumi/services/gift/gift_animation_manager.dart';
|
||||
import 'package:yumi/services/gift/gift_system_manager.dart';
|
||||
import 'package:yumi/services/music/room_music_manager.dart';
|
||||
import 'package:yumi/services/audio/rtm_manager.dart';
|
||||
import 'package:yumi/shared/tools/sc_gift_vap_svga_manager.dart';
|
||||
import 'package:yumi/shared/tools/sc_network_image_utils.dart';
|
||||
@ -30,6 +31,7 @@ import 'package:yumi/ui_kit/widgets/room/room_head_widget.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/room_online_user_widget.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/room_play_widget.dart';
|
||||
import 'package:yumi/ui_kit/widgets/room/music/room_music_floating_entry.dart';
|
||||
import 'package:yumi/shared/data_sources/models/enum/sc_gift_type.dart';
|
||||
|
||||
import '../../ui_kit/components/sc_float_ichart.dart';
|
||||
@ -156,6 +158,9 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
OverlayManager().removeRoom();
|
||||
SCRoomEffectScheduler().clearDeferredTasks(reason: 'voice_room_suspend');
|
||||
SCGiftVapSvgaManager().stopPlayback();
|
||||
unawaited(
|
||||
Provider.of<RoomMusicManager>(context, listen: false).stopForRoomExit(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoomStartupLayer() {
|
||||
@ -324,6 +329,7 @@ class _VoiceRoomPageState extends State<VoiceRoomPage>
|
||||
],
|
||||
),
|
||||
_buildRoomStartupLayer(),
|
||||
const RoomMusicFloatingEntry(),
|
||||
// _buildPlayViews(),
|
||||
///幸运礼物中奖动画
|
||||
LuckGiftNomorAnimWidget(),
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:yumi/modules/room/background/room_background_preview_page.dart';
|
||||
import 'package:yumi/modules/room/background/room_background_select_page.dart';
|
||||
import 'package:yumi/modules/room/background/room_background_upload_page.dart';
|
||||
import 'package:yumi/modules/room/edit/room_edit_page.dart';
|
||||
import 'package:yumi/modules/room/music/room_music_page.dart';
|
||||
import 'package:yumi/modules/room/them/room_theme_page.dart';
|
||||
import 'package:yumi/modules/room/voice_room_page.dart';
|
||||
import 'package:yumi/app/routes/sc_router_init.dart';
|
||||
@ -18,6 +19,7 @@ class VoiceRoomRoute implements SCIRouterProvider {
|
||||
static String roomBackgroundSelect = '/room/background/select';
|
||||
static String roomBackgroundPreview = '/room/background/preview';
|
||||
static String roomBackgroundUpload = '/room/background/upload';
|
||||
static String roomMusic = '/room/music';
|
||||
|
||||
static Route<T> _buildRoute<T>(Widget page) {
|
||||
if (Platform.isIOS) {
|
||||
@ -80,6 +82,16 @@ class VoiceRoomRoute implements SCIRouterProvider {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<T?> openRoomMusic<T>(
|
||||
BuildContext context, {
|
||||
bool rootNavigator = false,
|
||||
}) {
|
||||
return Navigator.of(
|
||||
context,
|
||||
rootNavigator: rootNavigator,
|
||||
).push<T>(_buildRoute<T>(const RoomMusicPage()));
|
||||
}
|
||||
|
||||
@override
|
||||
void initRouter(FluroRouter router) {
|
||||
router.define(
|
||||
@ -108,6 +120,10 @@ class VoiceRoomRoute implements SCIRouterProvider {
|
||||
handlerFunc: (_, params) => const RoomBackgroundUploadPage(),
|
||||
),
|
||||
);
|
||||
router.define(
|
||||
roomMusic,
|
||||
handler: Handler(handlerFunc: (_, params) => const RoomMusicPage()),
|
||||
);
|
||||
router.define(
|
||||
roomEdit,
|
||||
handler: Handler(
|
||||
|
||||
@ -45,6 +45,9 @@ import '../../ui_kit/components/sc_float_ichart.dart';
|
||||
|
||||
typedef OnSoundVoiceChange = Function(num index, int volum);
|
||||
typedef RtcProvider = RealTimeCommunicationManager;
|
||||
typedef RoomMusicMixingStateListener =
|
||||
void Function(AudioMixingStateType state, AudioMixingReasonType reason);
|
||||
typedef RoomMusicMicRouteChangedListener = void Function(bool isOnMic);
|
||||
|
||||
enum RoomStartupStatus { idle, loading, ready, failed }
|
||||
|
||||
@ -109,6 +112,8 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
|
||||
RtcEngine? engine;
|
||||
RtcEngineEventHandler? _rtcEngineEventHandler;
|
||||
RoomMusicMixingStateListener? _roomMusicMixingStateListener;
|
||||
RoomMusicMicRouteChangedListener? _roomMusicMicRouteChangedListener;
|
||||
BuildContext? context;
|
||||
RtmProvider? rtmProvider;
|
||||
|
||||
@ -706,6 +711,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
clientRole: ClientRoleType.clientRoleAudience,
|
||||
muted: true,
|
||||
);
|
||||
_roomMusicMicRouteChangedListener?.call(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -721,6 +727,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
clientRole: ClientRoleType.clientRoleBroadcaster,
|
||||
muted: (currentUserMic.micMute ?? false) || isMic,
|
||||
);
|
||||
_roomMusicMicRouteChangedListener?.call(true);
|
||||
}
|
||||
|
||||
void _syncRoomHeartbeatState({
|
||||
@ -1299,6 +1306,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
_roomMusicMixingStateListener?.call(state, reason);
|
||||
},
|
||||
onUserJoined: (connection, remoteUid, elapsed) {
|
||||
print('rtc用户 $remoteUid 加入了频道');
|
||||
@ -2607,4 +2615,61 @@ class RealTimeCommunicationManager extends ChangeNotifier {
|
||||
void adjustRecordingSignalVolume(int volume) {
|
||||
engine?.adjustRecordingSignalVolume(volume);
|
||||
}
|
||||
|
||||
void setRoomMusicMixingStateListener(RoomMusicMixingStateListener? listener) {
|
||||
_roomMusicMixingStateListener = listener;
|
||||
}
|
||||
|
||||
void setRoomMusicMicRouteChangedListener(
|
||||
RoomMusicMicRouteChangedListener? listener,
|
||||
) {
|
||||
_roomMusicMicRouteChangedListener = listener;
|
||||
}
|
||||
|
||||
Future<void> startRoomMusicAudioMixing({
|
||||
required String filePath,
|
||||
required bool loopback,
|
||||
int cycle = 1,
|
||||
int startPos = 0,
|
||||
}) async {
|
||||
if (filePath.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
await engine?.startAudioMixing(
|
||||
filePath: filePath,
|
||||
loopback: loopback,
|
||||
cycle: cycle,
|
||||
startPos: startPos,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pauseRoomMusicAudioMixing() async {
|
||||
await engine?.pauseAudioMixing();
|
||||
}
|
||||
|
||||
Future<void> resumeRoomMusicAudioMixing() async {
|
||||
await engine?.resumeAudioMixing();
|
||||
}
|
||||
|
||||
Future<void> stopRoomMusicAudioMixing() async {
|
||||
await engine?.stopAudioMixing();
|
||||
}
|
||||
|
||||
Future<void> setRoomMusicAudioMixingPosition(int positionMs) async {
|
||||
await engine?.setAudioMixingPosition(positionMs);
|
||||
}
|
||||
|
||||
Future<int> getRoomMusicAudioMixingPosition() async {
|
||||
return await engine?.getAudioMixingCurrentPosition() ?? 0;
|
||||
}
|
||||
|
||||
Future<int> getRoomMusicAudioMixingDuration() async {
|
||||
return await engine?.getAudioMixingDuration() ?? 0;
|
||||
}
|
||||
|
||||
Future<void> setRoomMusicAudioMixingVolume(int volume) async {
|
||||
final safeVolume = volume.clamp(0, 100).toInt();
|
||||
await engine?.adjustAudioMixingPlayoutVolume(safeVolume);
|
||||
await engine?.adjustAudioMixingPublishVolume(safeVolume);
|
||||
}
|
||||
}
|
||||
|
||||
165
lib/services/music/local_music_scanner.dart
Normal file
@ -0,0 +1,165 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:on_audio_query/on_audio_query.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
|
||||
import 'package:yumi/shared/tools/sc_permission_utils.dart';
|
||||
|
||||
class LocalMusicScanner {
|
||||
LocalMusicScanner({OnAudioQuery? audioQuery})
|
||||
: _audioQuery = audioQuery ?? OnAudioQuery();
|
||||
|
||||
final OnAudioQuery _audioQuery;
|
||||
|
||||
Future<List<SCMusicFolderMode>> scanMp3Folders({
|
||||
List<SCMusicMode> addedSongs = const [],
|
||||
}) async {
|
||||
final hasPermission = await SCPermissionUtils.checkAudioPermission();
|
||||
if (!hasPermission) {
|
||||
return const <SCMusicFolderMode>[];
|
||||
}
|
||||
|
||||
final List<SongModel> songs;
|
||||
try {
|
||||
songs = await _audioQuery.querySongs(
|
||||
sortType: SongSortType.TITLE,
|
||||
orderType: OrderType.ASC_OR_SMALLER,
|
||||
uriType: UriType.EXTERNAL,
|
||||
ignoreCase: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return const <SCMusicFolderMode>[];
|
||||
}
|
||||
|
||||
final addedIds = addedSongs.map((item) => item.id).toSet();
|
||||
final addedPaths = addedSongs.map((item) => item.localPath).toSet();
|
||||
final byFolder = <String, List<SCMusicMode>>{};
|
||||
final seen = <String>{};
|
||||
|
||||
for (final song in songs) {
|
||||
if (!_isMp3(song)) {
|
||||
continue;
|
||||
}
|
||||
final localPath = _songString(song, "_data").trim();
|
||||
if (localPath.isEmpty || !_fileExists(localPath)) {
|
||||
continue;
|
||||
}
|
||||
final id = _songString(song, "_id");
|
||||
final key = id.isNotEmpty ? id : localPath;
|
||||
if (!seen.add(key)) {
|
||||
continue;
|
||||
}
|
||||
final folderPath = p.dirname(localPath);
|
||||
final fileName = _safeFileName(song, localPath);
|
||||
final title = _safeTitle(song, fileName);
|
||||
final model = SCMusicMode(
|
||||
id: key,
|
||||
title: title,
|
||||
artist: _songString(song, "artist"),
|
||||
durationMs: _songInt(song, "duration"),
|
||||
localPath: localPath,
|
||||
contentUri: _songString(song, "_uri"),
|
||||
folderPath: folderPath,
|
||||
fileName: fileName,
|
||||
size: _songInt(song, "_size"),
|
||||
addedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
byFolder.putIfAbsent(folderPath, () => <SCMusicMode>[]).add(model);
|
||||
}
|
||||
|
||||
final folders =
|
||||
byFolder.entries.map((entry) {
|
||||
final path = entry.key;
|
||||
final folderSongs =
|
||||
entry.value..sort(
|
||||
(a, b) =>
|
||||
a.title.toLowerCase().compareTo(b.title.toLowerCase()),
|
||||
);
|
||||
final addedCount =
|
||||
folderSongs.where((song) {
|
||||
return addedIds.contains(song.id) ||
|
||||
addedPaths.contains(song.localPath);
|
||||
}).length;
|
||||
return SCMusicFolderMode(
|
||||
id: path,
|
||||
name: p.basename(path).isEmpty ? path : p.basename(path),
|
||||
path: path,
|
||||
songs: List<SCMusicMode>.unmodifiable(folderSongs),
|
||||
addedCount: addedCount,
|
||||
);
|
||||
}).toList()
|
||||
..sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
|
||||
return List<SCMusicFolderMode>.unmodifiable(folders);
|
||||
}
|
||||
|
||||
bool _isMp3(SongModel song) {
|
||||
final extension = _songString(
|
||||
song,
|
||||
"file_extension",
|
||||
).toLowerCase().replaceFirst(".", "");
|
||||
if (extension == "mp3") {
|
||||
return true;
|
||||
}
|
||||
return _songString(song, "_display_name").toLowerCase().endsWith(".mp3") ||
|
||||
_songString(song, "_data").toLowerCase().endsWith(".mp3");
|
||||
}
|
||||
|
||||
bool _fileExists(String path) {
|
||||
try {
|
||||
return File(path).existsSync();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _safeFileName(SongModel song, String localPath) {
|
||||
final displayName = _songString(song, "_display_name").trim();
|
||||
if (displayName.isNotEmpty) {
|
||||
return displayName;
|
||||
}
|
||||
return p.basename(localPath);
|
||||
}
|
||||
|
||||
String _safeTitle(SongModel song, String fileName) {
|
||||
final title = _songString(song, "title").trim();
|
||||
if (title.isNotEmpty) {
|
||||
return title;
|
||||
}
|
||||
final displayNameWOExt = _songString(song, "_display_name_wo_ext").trim();
|
||||
if (displayNameWOExt.isNotEmpty) {
|
||||
return displayNameWOExt;
|
||||
}
|
||||
return p.basenameWithoutExtension(fileName);
|
||||
}
|
||||
|
||||
String _songString(SongModel song, String key) {
|
||||
final value = _songValue(song, key);
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
int _songInt(SongModel song, String key) {
|
||||
final value = _songValue(song, key);
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
return int.tryParse(value?.toString() ?? "") ?? 0;
|
||||
}
|
||||
|
||||
Object? _songValue(SongModel song, String key) {
|
||||
try {
|
||||
return song.getMap[key];
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
445
lib/services/music/room_music_manager.dart
Normal file
@ -0,0 +1,445 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:yumi/services/audio/rtc_manager.dart';
|
||||
import 'package:yumi/services/music/room_music_repository.dart';
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart';
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
|
||||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||||
|
||||
enum RoomMusicPlayMode { list, shuffle, single }
|
||||
|
||||
class RoomMusicManager extends ChangeNotifier {
|
||||
RoomMusicManager({RoomMusicRepository? repository})
|
||||
: _repository = repository ?? RoomMusicRepository() {
|
||||
_playlist = _repository.loadSongs();
|
||||
}
|
||||
|
||||
final RoomMusicRepository _repository;
|
||||
final Random _random = Random();
|
||||
|
||||
RtcProvider? _rtcProvider;
|
||||
Timer? _positionTimer;
|
||||
List<SCMusicMode> _playlist = <SCMusicMode>[];
|
||||
SCMusicMode? _current;
|
||||
RoomMusicPlayMode _playMode = RoomMusicPlayMode.list;
|
||||
int _volume = 70;
|
||||
int _positionMs = 0;
|
||||
int _durationMs = 0;
|
||||
int _lastStartAtMs = 0;
|
||||
int _ignoreStoppedUntilMs = 0;
|
||||
bool _isPlaying = false;
|
||||
bool _isPaused = false;
|
||||
bool _isStarting = false;
|
||||
bool _isStoppingByUser = false;
|
||||
bool _lastLoopback = true;
|
||||
|
||||
List<SCMusicMode> get playlist => List.unmodifiable(_playlist);
|
||||
|
||||
SCMusicMode? get current => _current;
|
||||
|
||||
RoomMusicPlayMode get playMode => _playMode;
|
||||
|
||||
int get volume => _volume;
|
||||
|
||||
int get positionMs => _positionMs;
|
||||
|
||||
int get durationMs =>
|
||||
_durationMs > 0 ? _durationMs : (_current?.durationMs ?? 0);
|
||||
|
||||
bool get isPlaying => _isPlaying;
|
||||
|
||||
bool get isPaused => _isPaused;
|
||||
|
||||
bool get hasCurrent => _current != null;
|
||||
|
||||
int get currentIndex {
|
||||
final currentId = _current?.id;
|
||||
if (currentId == null) {
|
||||
return -1;
|
||||
}
|
||||
return _playlist.indexWhere((item) => item.id == currentId);
|
||||
}
|
||||
|
||||
void attachRtcProvider(RtcProvider provider) {
|
||||
if (_rtcProvider == provider) {
|
||||
return;
|
||||
}
|
||||
_rtcProvider?.setRoomMusicMixingStateListener(null);
|
||||
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
|
||||
_rtcProvider = provider;
|
||||
provider.setRoomMusicMixingStateListener(_handleMixingStateChanged);
|
||||
provider.setRoomMusicMicRouteChangedListener(_handleMicRouteChanged);
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
_playlist = _repository.loadSongs();
|
||||
_restoreCurrentAfterPlaylistChange();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> applyFolderSelection({
|
||||
required SCMusicFolderMode folder,
|
||||
required Set<String> selectedIds,
|
||||
}) async {
|
||||
final currentById = {for (final item in _playlist) item.id: item};
|
||||
final folderIds = folder.songs.map((item) => item.id).toSet();
|
||||
final next = <SCMusicMode>[
|
||||
..._playlist.where((item) => !folderIds.contains(item.id)),
|
||||
];
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
for (final song in folder.songs) {
|
||||
if (!selectedIds.contains(song.id)) {
|
||||
continue;
|
||||
}
|
||||
final existing = currentById[song.id];
|
||||
next.add(
|
||||
(existing ?? song).copyWith(
|
||||
addedAt:
|
||||
existing?.addedAt == null || existing!.addedAt <= 0
|
||||
? now
|
||||
: existing.addedAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
await _savePlaylist(next);
|
||||
}
|
||||
|
||||
Future<void> deleteMusic(SCMusicMode item) async {
|
||||
final deletingCurrent = _current?.id == item.id;
|
||||
if (deletingCurrent) {
|
||||
await stop(clearCurrent: true);
|
||||
}
|
||||
await _savePlaylist(_playlist.where((song) => song.id != item.id).toList());
|
||||
}
|
||||
|
||||
Future<void> reorder(int oldIndex, int newIndex) async {
|
||||
if (oldIndex < 0 || oldIndex >= _playlist.length) {
|
||||
return;
|
||||
}
|
||||
final next = [..._playlist];
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final item = next.removeAt(oldIndex);
|
||||
next.insert(newIndex.clamp(0, next.length).toInt(), item);
|
||||
await _savePlaylist(next);
|
||||
}
|
||||
|
||||
Future<void> play(SCMusicMode item, {int startPositionMs = 0}) async {
|
||||
if (item.playPath.isEmpty) {
|
||||
SCTts.show("Music file not found");
|
||||
return;
|
||||
}
|
||||
if (!item.playPath.startsWith("content://") &&
|
||||
!File(item.localPath).existsSync()) {
|
||||
SCTts.show("Music file not found");
|
||||
await deleteMusic(item);
|
||||
return;
|
||||
}
|
||||
_current = item;
|
||||
_durationMs = item.durationMs;
|
||||
_positionMs = startPositionMs;
|
||||
_isPaused = false;
|
||||
await _startCurrent(startPositionMs: startPositionMs);
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
if (!_isPlaying || _rtcProvider == null) {
|
||||
return;
|
||||
}
|
||||
await _rtcProvider?.pauseRoomMusicAudioMixing();
|
||||
_isPlaying = false;
|
||||
_isPaused = true;
|
||||
_stopPositionTimer();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
if (_current == null || _rtcProvider == null) {
|
||||
return;
|
||||
}
|
||||
if (_isPaused) {
|
||||
await _rtcProvider?.resumeRoomMusicAudioMixing();
|
||||
_isPlaying = true;
|
||||
_isPaused = false;
|
||||
_startPositionTimer();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
await _startCurrent(startPositionMs: _positionMs);
|
||||
}
|
||||
|
||||
Future<void> stop({bool clearCurrent = false}) async {
|
||||
_isStoppingByUser = true;
|
||||
try {
|
||||
await _rtcProvider?.stopRoomMusicAudioMixing();
|
||||
} catch (_) {
|
||||
// Ignore stop failures; local state must still be reset.
|
||||
} finally {
|
||||
_isStoppingByUser = false;
|
||||
_stopPositionTimer();
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
_positionMs = 0;
|
||||
if (clearCurrent) {
|
||||
_current = null;
|
||||
_durationMs = 0;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopForRoomExit() async {
|
||||
await stop(clearCurrent: true);
|
||||
}
|
||||
|
||||
Future<void> previous() async {
|
||||
if (_playlist.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final index = currentIndex;
|
||||
final targetIndex = index <= 0 ? _playlist.length - 1 : index - 1;
|
||||
await play(_playlist[targetIndex]);
|
||||
}
|
||||
|
||||
Future<void> next({bool fromAutoComplete = false}) async {
|
||||
if (_playlist.isEmpty) {
|
||||
await stop(clearCurrent: true);
|
||||
return;
|
||||
}
|
||||
if (_playMode == RoomMusicPlayMode.single && fromAutoComplete) {
|
||||
await play(_current ?? _playlist.first);
|
||||
return;
|
||||
}
|
||||
int targetIndex = 0;
|
||||
final index = currentIndex;
|
||||
if (_playMode == RoomMusicPlayMode.shuffle) {
|
||||
if (_playlist.length == 1) {
|
||||
targetIndex = 0;
|
||||
} else {
|
||||
do {
|
||||
targetIndex = _random.nextInt(_playlist.length);
|
||||
} while (targetIndex == index);
|
||||
}
|
||||
} else {
|
||||
targetIndex = index < 0 ? 0 : (index + 1) % _playlist.length;
|
||||
}
|
||||
await play(_playlist[targetIndex]);
|
||||
}
|
||||
|
||||
Future<void> seek(int positionMs) async {
|
||||
final safePosition = positionMs.clamp(0, durationMs).toInt();
|
||||
_positionMs = safePosition;
|
||||
await _rtcProvider?.setRoomMusicAudioMixingPosition(safePosition);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setVolume(int volume) async {
|
||||
_volume = volume.clamp(0, 100).toInt();
|
||||
await _rtcProvider?.setRoomMusicAudioMixingVolume(_volume);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String switchPlayMode() {
|
||||
switch (_playMode) {
|
||||
case RoomMusicPlayMode.list:
|
||||
_playMode = RoomMusicPlayMode.shuffle;
|
||||
notifyListeners();
|
||||
return "随机播放";
|
||||
case RoomMusicPlayMode.shuffle:
|
||||
_playMode = RoomMusicPlayMode.single;
|
||||
notifyListeners();
|
||||
return "单曲循环播放";
|
||||
case RoomMusicPlayMode.single:
|
||||
_playMode = RoomMusicPlayMode.list;
|
||||
notifyListeners();
|
||||
return "列表顺序播放";
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncPlaybackRouting() async {
|
||||
final current = _current;
|
||||
final rtcProvider = _rtcProvider;
|
||||
if (current == null || rtcProvider == null || !_isPlaying) {
|
||||
return;
|
||||
}
|
||||
final nextLoopback = !rtcProvider.isOnMai();
|
||||
if (nextLoopback == _lastLoopback) {
|
||||
return;
|
||||
}
|
||||
final position = await rtcProvider
|
||||
.getRoomMusicAudioMixingPosition()
|
||||
.catchError((_) => _positionMs);
|
||||
_ignoreStoppedUntilMs = DateTime.now().millisecondsSinceEpoch + 900;
|
||||
try {
|
||||
await rtcProvider.stopRoomMusicAudioMixing();
|
||||
await _startCurrent(startPositionMs: position);
|
||||
} catch (_) {
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _savePlaylist(List<SCMusicMode> songs) async {
|
||||
await _repository.saveSongs(songs);
|
||||
_playlist = _repository.loadSongs();
|
||||
_restoreCurrentAfterPlaylistChange();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _startCurrent({required int startPositionMs}) async {
|
||||
final current = _current;
|
||||
final rtcProvider = _rtcProvider;
|
||||
if (current == null || rtcProvider == null || _isStarting) {
|
||||
return;
|
||||
}
|
||||
_isStarting = true;
|
||||
try {
|
||||
await _waitForStartInterval();
|
||||
_lastLoopback = !rtcProvider.isOnMai();
|
||||
await rtcProvider.startRoomMusicAudioMixing(
|
||||
filePath: current.playPath,
|
||||
loopback: _lastLoopback,
|
||||
cycle: 1,
|
||||
startPos: startPositionMs,
|
||||
);
|
||||
_lastStartAtMs = DateTime.now().millisecondsSinceEpoch;
|
||||
await rtcProvider.setRoomMusicAudioMixingVolume(_volume);
|
||||
_positionMs = startPositionMs;
|
||||
_durationMs =
|
||||
current.durationMs > 0
|
||||
? current.durationMs
|
||||
: await rtcProvider.getRoomMusicAudioMixingDuration();
|
||||
_isPlaying = true;
|
||||
_isPaused = false;
|
||||
_startPositionTimer();
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
_stopPositionTimer();
|
||||
SCTts.show("Music playback failed");
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_isStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForStartInterval() async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final elapsed = now - _lastStartAtMs;
|
||||
if (elapsed < 550) {
|
||||
await Future<void>.delayed(Duration(milliseconds: 550 - elapsed));
|
||||
}
|
||||
}
|
||||
|
||||
void _startPositionTimer() {
|
||||
_positionTimer?.cancel();
|
||||
_positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) {
|
||||
unawaited(_refreshPosition());
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPositionTimer() {
|
||||
_positionTimer?.cancel();
|
||||
_positionTimer = null;
|
||||
}
|
||||
|
||||
Future<void> _refreshPosition() async {
|
||||
if (!_isPlaying || _rtcProvider == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final position = await _rtcProvider!.getRoomMusicAudioMixingPosition();
|
||||
final duration = await _rtcProvider!.getRoomMusicAudioMixingDuration();
|
||||
if (position >= 0) {
|
||||
_positionMs = position;
|
||||
}
|
||||
if (duration > 0) {
|
||||
_durationMs = duration;
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// Position polling is best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMixingStateChanged(
|
||||
AudioMixingStateType state,
|
||||
AudioMixingReasonType reason,
|
||||
) {
|
||||
switch (state) {
|
||||
case AudioMixingStateType.audioMixingStatePlaying:
|
||||
_isPlaying = true;
|
||||
_isPaused = false;
|
||||
_startPositionTimer();
|
||||
notifyListeners();
|
||||
break;
|
||||
case AudioMixingStateType.audioMixingStatePaused:
|
||||
_isPlaying = false;
|
||||
_isPaused = true;
|
||||
_stopPositionTimer();
|
||||
notifyListeners();
|
||||
break;
|
||||
case AudioMixingStateType.audioMixingStateStopped:
|
||||
if (DateTime.now().millisecondsSinceEpoch < _ignoreStoppedUntilMs) {
|
||||
return;
|
||||
}
|
||||
_stopPositionTimer();
|
||||
if (reason ==
|
||||
AudioMixingReasonType.audioMixingReasonAllLoopsCompleted) {
|
||||
unawaited(next(fromAutoComplete: true));
|
||||
return;
|
||||
}
|
||||
if (!_isStoppingByUser) {
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
_positionMs = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
break;
|
||||
case AudioMixingStateType.audioMixingStateFailed:
|
||||
_stopPositionTimer();
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
SCTts.show("Music playback failed");
|
||||
notifyListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMicRouteChanged(bool isOnMic) {
|
||||
unawaited(syncPlaybackRouting());
|
||||
}
|
||||
|
||||
void _restoreCurrentAfterPlaylistChange() {
|
||||
final currentId = _current?.id;
|
||||
if (currentId == null) {
|
||||
return;
|
||||
}
|
||||
final index = _playlist.indexWhere((item) => item.id == currentId);
|
||||
if (index >= 0) {
|
||||
_current = _playlist[index];
|
||||
return;
|
||||
}
|
||||
_current = null;
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
_positionMs = 0;
|
||||
_durationMs = 0;
|
||||
_stopPositionTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_positionTimer?.cancel();
|
||||
_rtcProvider?.setRoomMusicMixingStateListener(null);
|
||||
_rtcProvider?.setRoomMusicMicRouteChangedListener(null);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
62
lib/services/music/room_music_repository.dart
Normal file
@ -0,0 +1,62 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
|
||||
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
||||
|
||||
class RoomMusicRepository {
|
||||
List<SCMusicMode> loadSongs() {
|
||||
final raw = DataPersistence.getUserRoomMusic();
|
||||
if (raw.trim().isEmpty) {
|
||||
return <SCMusicMode>[];
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return <SCMusicMode>[];
|
||||
}
|
||||
final songs =
|
||||
decoded
|
||||
.map((item) => SCMusicMode.fromJson(item))
|
||||
.where((item) => item.hasPlayablePath)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final sort = a.sortIndex.compareTo(b.sortIndex);
|
||||
if (sort != 0) {
|
||||
return sort;
|
||||
}
|
||||
return a.addedAt.compareTo(b.addedAt);
|
||||
});
|
||||
return _normalizeSort(songs);
|
||||
} catch (_) {
|
||||
return <SCMusicMode>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> saveSongs(List<SCMusicMode> songs) {
|
||||
final normalized = _normalizeSort(_dedupeSongs(songs));
|
||||
return DataPersistence.setUserRoomMusic(
|
||||
jsonEncode(normalized.map((item) => item.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
List<SCMusicMode> _dedupeSongs(List<SCMusicMode> songs) {
|
||||
final seen = <String>{};
|
||||
final result = <SCMusicMode>[];
|
||||
for (final song in songs) {
|
||||
final key = song.id.isNotEmpty ? song.id : song.localPath;
|
||||
if (key.isEmpty || !seen.add(key)) {
|
||||
continue;
|
||||
}
|
||||
result.add(song);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
List<SCMusicMode> _normalizeSort(List<SCMusicMode> songs) {
|
||||
return List<SCMusicMode>.generate(
|
||||
songs.length,
|
||||
(index) => songs[index].copyWith(sortIndex: index),
|
||||
growable: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
29
lib/shared/data_sources/models/sc_music_folder_mode.dart
Normal file
@ -0,0 +1,29 @@
|
||||
import 'package:yumi/shared/data_sources/models/sc_music_mode.dart';
|
||||
|
||||
class SCMusicFolderMode {
|
||||
const SCMusicFolderMode({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.songs,
|
||||
this.addedCount = 0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String path;
|
||||
final List<SCMusicMode> songs;
|
||||
final int addedCount;
|
||||
|
||||
int get musicCount => songs.length;
|
||||
|
||||
SCMusicFolderMode copyWith({List<SCMusicMode>? songs, int? addedCount}) {
|
||||
return SCMusicFolderMode(
|
||||
id: id,
|
||||
name: name,
|
||||
path: path,
|
||||
songs: songs ?? this.songs,
|
||||
addedCount: addedCount ?? this.addedCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,37 +1,149 @@
|
||||
class SCMusicMode {
|
||||
SCMusicMode({String? title, String? artist,int? duration,String? localPath}) {
|
||||
_title = title;
|
||||
_artist = artist;
|
||||
_duration = duration;
|
||||
_localPath = localPath;
|
||||
}
|
||||
|
||||
String? _title;
|
||||
String? _artist;
|
||||
int? _duration;
|
||||
String? _localPath;
|
||||
|
||||
String? get title => _title;
|
||||
|
||||
String? get artist => _artist;
|
||||
|
||||
int? get duration => _duration;
|
||||
|
||||
String? get localPath => _localPath;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['title'] = _title;
|
||||
map['artist'] = _artist;
|
||||
map['duration'] = _duration;
|
||||
map['localPath'] = _localPath;
|
||||
return map;
|
||||
}
|
||||
|
||||
SCMusicMode.fromJson(dynamic json) {
|
||||
_title = json['title'];
|
||||
_artist = json['artist'];
|
||||
_duration = json['duration'];
|
||||
_localPath = json['localPath'];
|
||||
}
|
||||
}
|
||||
import 'dart:convert';
|
||||
|
||||
class SCMusicMode {
|
||||
SCMusicMode({
|
||||
String? id,
|
||||
String? title,
|
||||
String? artist,
|
||||
int? duration,
|
||||
int? durationMs,
|
||||
String? localPath,
|
||||
String? contentUri,
|
||||
String? folderPath,
|
||||
String? fileName,
|
||||
int? size,
|
||||
int? sortIndex,
|
||||
int? addedAt,
|
||||
}) {
|
||||
_id = id;
|
||||
_title = title;
|
||||
_artist = artist;
|
||||
_durationMs = durationMs ?? duration;
|
||||
_localPath = localPath;
|
||||
_contentUri = contentUri;
|
||||
_folderPath = folderPath;
|
||||
_fileName = fileName;
|
||||
_size = size;
|
||||
_sortIndex = sortIndex;
|
||||
_addedAt = addedAt;
|
||||
}
|
||||
|
||||
String? _id;
|
||||
String? _title;
|
||||
String? _artist;
|
||||
int? _durationMs;
|
||||
String? _localPath;
|
||||
String? _contentUri;
|
||||
String? _folderPath;
|
||||
String? _fileName;
|
||||
int? _size;
|
||||
int? _sortIndex;
|
||||
int? _addedAt;
|
||||
|
||||
String get id {
|
||||
final rawId = (_id ?? "").trim();
|
||||
if (rawId.isNotEmpty) {
|
||||
return rawId;
|
||||
}
|
||||
final source = playPath.isNotEmpty ? playPath : title;
|
||||
return base64Url.encode(utf8.encode(source)).replaceAll('=', '');
|
||||
}
|
||||
|
||||
String get title => (_title ?? _fileName ?? "").trim();
|
||||
|
||||
String get artist => (_artist ?? "").trim();
|
||||
|
||||
int get durationMs => _durationMs ?? 0;
|
||||
|
||||
int get duration => durationMs;
|
||||
|
||||
String get localPath => (_localPath ?? "").trim();
|
||||
|
||||
String get contentUri => (_contentUri ?? "").trim();
|
||||
|
||||
String get folderPath => (_folderPath ?? "").trim();
|
||||
|
||||
String get fileName => (_fileName ?? title).trim();
|
||||
|
||||
int get size => _size ?? 0;
|
||||
|
||||
int get sortIndex => _sortIndex ?? 0;
|
||||
|
||||
int get addedAt => _addedAt ?? 0;
|
||||
|
||||
String get playPath => contentUri.isNotEmpty ? contentUri : localPath;
|
||||
|
||||
bool get hasPlayablePath => playPath.isNotEmpty;
|
||||
|
||||
SCMusicMode copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? artist,
|
||||
int? durationMs,
|
||||
String? localPath,
|
||||
String? contentUri,
|
||||
String? folderPath,
|
||||
String? fileName,
|
||||
int? size,
|
||||
int? sortIndex,
|
||||
int? addedAt,
|
||||
}) {
|
||||
return SCMusicMode(
|
||||
id: id ?? _id,
|
||||
title: title ?? _title,
|
||||
artist: artist ?? _artist,
|
||||
durationMs: durationMs ?? _durationMs,
|
||||
localPath: localPath ?? _localPath,
|
||||
contentUri: contentUri ?? _contentUri,
|
||||
folderPath: folderPath ?? _folderPath,
|
||||
fileName: fileName ?? _fileName,
|
||||
size: size ?? _size,
|
||||
sortIndex: sortIndex ?? _sortIndex,
|
||||
addedAt: addedAt ?? _addedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
map['id'] = _id;
|
||||
map['title'] = _title;
|
||||
map['artist'] = _artist;
|
||||
map['duration'] = _durationMs;
|
||||
map['durationMs'] = _durationMs;
|
||||
map['localPath'] = _localPath;
|
||||
map['contentUri'] = _contentUri;
|
||||
map['folderPath'] = _folderPath;
|
||||
map['fileName'] = _fileName;
|
||||
map['size'] = _size;
|
||||
map['sortIndex'] = _sortIndex;
|
||||
map['addedAt'] = _addedAt;
|
||||
return map;
|
||||
}
|
||||
|
||||
SCMusicMode.fromJson(dynamic json) {
|
||||
if (json is! Map) {
|
||||
return;
|
||||
}
|
||||
_id = json['id']?.toString();
|
||||
_title = json['title']?.toString();
|
||||
_artist = json['artist']?.toString();
|
||||
_durationMs = _asInt(json['durationMs'] ?? json['duration']);
|
||||
_localPath = json['localPath']?.toString();
|
||||
_contentUri = json['contentUri']?.toString();
|
||||
_folderPath = json['folderPath']?.toString();
|
||||
_fileName = json['fileName']?.toString();
|
||||
_size = _asInt(json['size']);
|
||||
_sortIndex = _asInt(json['sortIndex']);
|
||||
_addedAt = _asInt(json['addedAt']);
|
||||
}
|
||||
|
||||
static int? _asInt(dynamic value) {
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
return int.tryParse(value?.toString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,42 @@
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class SCPermissionUtils{
|
||||
static Future<bool> checkMicrophonePermission() async {
|
||||
// 检查当前权限状态
|
||||
var status = await Permission.microphone.status;
|
||||
if (status.isDenied) {
|
||||
// 请求权限(弹出系统弹窗)
|
||||
status = await Permission.microphone.request();
|
||||
}
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> checkPhotosPermission() async {
|
||||
// 检查当前权限状态
|
||||
var status = await Permission.photos.status;
|
||||
if (status.isDenied) {
|
||||
// 请求权限(弹出系统弹窗)
|
||||
status = await Permission.photos.request();
|
||||
}
|
||||
return status.isGranted;
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class SCPermissionUtils {
|
||||
static Future<bool> checkMicrophonePermission() async {
|
||||
// 检查当前权限状态
|
||||
var status = await Permission.microphone.status;
|
||||
if (status.isDenied) {
|
||||
// 请求权限(弹出系统弹窗)
|
||||
status = await Permission.microphone.request();
|
||||
}
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> checkPhotosPermission() async {
|
||||
// 检查当前权限状态
|
||||
var status = await Permission.photos.status;
|
||||
if (status.isDenied) {
|
||||
// 请求权限(弹出系统弹窗)
|
||||
status = await Permission.photos.request();
|
||||
}
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> checkAudioPermission() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return true;
|
||||
}
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final permission =
|
||||
androidInfo.version.sdkInt >= 33
|
||||
? Permission.audio
|
||||
: Permission.storage;
|
||||
var status = await permission.status;
|
||||
if (status.isDenied || status.isRestricted || status.isLimited) {
|
||||
status = await permission.request();
|
||||
}
|
||||
return status.isGranted;
|
||||
}
|
||||
}
|
||||
|
||||
87
lib/ui_kit/widgets/room/music/room_music_floating_entry.dart
Normal file
@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:yumi/modules/room/voice_room_route.dart';
|
||||
import 'package:yumi/services/music/room_music_manager.dart';
|
||||
|
||||
class RoomMusicFloatingEntry extends StatefulWidget {
|
||||
const RoomMusicFloatingEntry({super.key});
|
||||
|
||||
@override
|
||||
State<RoomMusicFloatingEntry> createState() => _RoomMusicFloatingEntryState();
|
||||
}
|
||||
|
||||
class _RoomMusicFloatingEntryState extends State<RoomMusicFloatingEntry>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<RoomMusicManager, _FloatingMusicSnapshot>(
|
||||
selector:
|
||||
(_, manager) => _FloatingMusicSnapshot(
|
||||
visible: manager.current != null,
|
||||
playing: manager.isPlaying,
|
||||
),
|
||||
builder: (context, snapshot, child) {
|
||||
if (!snapshot.visible) {
|
||||
_controller.stop();
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (snapshot.playing && !_controller.isAnimating) {
|
||||
_controller.repeat();
|
||||
} else if (!snapshot.playing && _controller.isAnimating) {
|
||||
_controller.stop();
|
||||
}
|
||||
return PositionedDirectional(
|
||||
end: 8.w,
|
||||
top: 292.w,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => VoiceRoomRoute.openRoomMusic(context),
|
||||
child: RotationTransition(
|
||||
turns: _controller,
|
||||
child: Image.asset(
|
||||
"sc_images/room/sc_music_material_room_side.png",
|
||||
width: 58.w,
|
||||
height: 58.w,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FloatingMusicSnapshot {
|
||||
const _FloatingMusicSnapshot({required this.visible, required this.playing});
|
||||
|
||||
final bool visible;
|
||||
final bool playing;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is _FloatingMusicSnapshot &&
|
||||
other.visible == visible &&
|
||||
other.playing == playing;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(visible, playing);
|
||||
}
|
||||
258
lib/ui_kit/widgets/room/music/room_music_player_bar.dart
Normal file
@ -0,0 +1,258 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:yumi/modules/room/music/room_music_texts.dart';
|
||||
import 'package:yumi/services/music/room_music_manager.dart';
|
||||
|
||||
class RoomMusicPlayerBar extends StatelessWidget {
|
||||
const RoomMusicPlayerBar({super.key});
|
||||
|
||||
static const String _playAsset = "sc_images/room/sc_music_material_play.png";
|
||||
static const String _pauseAsset =
|
||||
"sc_images/room/sc_music_material_pause.png";
|
||||
static const String _previousAsset =
|
||||
"sc_images/room/sc_icon_room_music_previous.png";
|
||||
static const String _nextAsset = "sc_images/room/sc_icon_room_music_next.png";
|
||||
static const String _volumeAsset =
|
||||
"sc_images/room/sc_music_material_volume.png";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<RoomMusicManager>(
|
||||
builder: (context, manager, child) {
|
||||
final current = manager.current;
|
||||
if (current == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final duration = manager.durationMs <= 0 ? 1 : manager.durationMs;
|
||||
final position = manager.positionMs.clamp(0, duration).toInt();
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 12.w, 16.w, 12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xF20B3029),
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
blurRadius: 18.w,
|
||||
offset: Offset(0, -6.w),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
current.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.w),
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
fontSize: 11.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => manager.stop(clearCurrent: true),
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
fontSize: 10.sp,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3.w,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6.w,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
activeTrackColor: const Color(0xFF33E6A2),
|
||||
inactiveTrackColor: Colors.white.withValues(
|
||||
alpha: 0.16,
|
||||
),
|
||||
thumbColor: Colors.white,
|
||||
),
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: duration.toDouble(),
|
||||
value: position.toDouble(),
|
||||
onChanged:
|
||||
(value) => manager.seek(value.round().toInt()),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
fontSize: 10.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4.w),
|
||||
Row(
|
||||
children: [
|
||||
_IconButton(
|
||||
asset: _modeAsset(manager.playMode),
|
||||
size: 34.w,
|
||||
onTap: () {
|
||||
final message = manager.switchPlayMode();
|
||||
ScaffoldMessenger.maybeOf(
|
||||
context,
|
||||
)?.hideCurrentSnackBar();
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_localizedModeText(context, message)),
|
||||
duration: const Duration(milliseconds: 900),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
_IconButton(
|
||||
asset: _previousAsset,
|
||||
size: 34.w,
|
||||
onTap: () {
|
||||
manager.previous();
|
||||
},
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
_IconButton(
|
||||
asset: manager.isPlaying ? _pauseAsset : _playAsset,
|
||||
size: 48.w,
|
||||
onTap: () {
|
||||
manager.isPlaying ? manager.pause() : manager.resume();
|
||||
},
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
_IconButton(
|
||||
asset: _nextAsset,
|
||||
size: 34.w,
|
||||
onTap: () {
|
||||
manager.next();
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Image.asset(_volumeAsset, width: 22.w, height: 22.w),
|
||||
SizedBox(
|
||||
width: 82.w,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2.w,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: 5.w,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
activeTrackColor: const Color(0xFF33E6A2),
|
||||
inactiveTrackColor: Colors.white.withValues(
|
||||
alpha: 0.16,
|
||||
),
|
||||
thumbColor: Colors.white,
|
||||
),
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 100,
|
||||
value: manager.volume.toDouble(),
|
||||
onChanged: (value) {
|
||||
manager.setVolume(value.round());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDuration(int milliseconds) {
|
||||
final totalSeconds = (milliseconds / 1000).floor();
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return "$minutes:${seconds.toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
static String _modeAsset(RoomMusicPlayMode mode) {
|
||||
switch (mode) {
|
||||
case RoomMusicPlayMode.list:
|
||||
return "sc_images/room/sc_music_material_sequence.png";
|
||||
case RoomMusicPlayMode.shuffle:
|
||||
return "sc_images/room/sc_music_material_shuffle.png";
|
||||
case RoomMusicPlayMode.single:
|
||||
return "sc_images/room/sc_music_material_single_loop.png";
|
||||
}
|
||||
}
|
||||
|
||||
static String _localizedModeText(BuildContext context, String message) {
|
||||
switch (message) {
|
||||
case "随机播放":
|
||||
return RoomMusicTexts.t(context, "roomMusicShuffleMode", message);
|
||||
case "单曲循环播放":
|
||||
return RoomMusicTexts.t(context, "roomMusicSingleMode", message);
|
||||
case "列表顺序播放":
|
||||
default:
|
||||
return RoomMusicTexts.t(context, "roomMusicListMode", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _IconButton extends StatelessWidget {
|
||||
const _IconButton({
|
||||
required this.asset,
|
||||
required this.size,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String asset;
|
||||
final double size;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Center(child: Image.asset(asset, width: size, height: size)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -37,9 +37,9 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
|
||||
static const int _menuSound = 4;
|
||||
static const int _menuReport = 5;
|
||||
static const int _menuBackground = 6;
|
||||
static const int _menuMusic = 7;
|
||||
static bool get _showBackgroundFeature => true;
|
||||
|
||||
List<RoomMenu> items1 = [];
|
||||
List<RoomMenu> items2 = [];
|
||||
|
||||
@override
|
||||
@ -49,14 +49,6 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// items1.clear();
|
||||
// items1.add(
|
||||
// RoomMenu(
|
||||
// 0,
|
||||
// SCAppLocalizations.of(context)!.music,
|
||||
// "sc_icon_room_music_tag.png",
|
||||
// ),
|
||||
// );
|
||||
// items1.add(
|
||||
// RoomMenu(
|
||||
// 1,
|
||||
@ -129,6 +121,21 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
|
||||
"sc_icon_room_background.png",
|
||||
),
|
||||
);
|
||||
items2.add(
|
||||
RoomMenu(
|
||||
_menuMusic,
|
||||
SCAppLocalizations.of(context)!.music,
|
||||
"sc_music_material_room_menu.png",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
items2.add(
|
||||
RoomMenu(
|
||||
_menuMusic,
|
||||
SCAppLocalizations.of(context)!.music,
|
||||
"sc_music_material_room_menu.png",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!Provider.of<RtcProvider>(context, listen: false).isFz()) {
|
||||
items2.add(
|
||||
@ -206,7 +213,13 @@ class _RoomMenuDialogState extends State<RoomMenuDialog> {
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
if (item.id == _menuMicManagement) {
|
||||
if (item.id == _menuMusic) {
|
||||
SmartDialog.dismiss(tag: "showRoomMenuDialog");
|
||||
VoiceRoomRoute.openRoomMusic(
|
||||
navigatorKey.currentState!.context,
|
||||
rootNavigator: true,
|
||||
);
|
||||
} else if (item.id == _menuMicManagement) {
|
||||
SmartDialog.dismiss(tag: "showRoomMenuDialog");
|
||||
SmartDialog.show(
|
||||
tag: "showRoomMicSwitch",
|
||||
|
||||
48
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"
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
sc_images/room/sc_music_material_delete.png
Normal file
|
After Width: | Height: | Size: 578 B |
BIN
sc_images/room/sc_music_material_edit.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
sc_images/room/sc_music_material_folder.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
sc_images/room/sc_music_material_more.png
Normal file
|
After Width: | Height: | Size: 446 B |
BIN
sc_images/room/sc_music_material_pause.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
sc_images/room/sc_music_material_play.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
sc_images/room/sc_music_material_room_menu.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
sc_images/room/sc_music_material_room_side.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
sc_images/room/sc_music_material_sequence.png
Normal file
|
After Width: | Height: | Size: 678 B |
BIN
sc_images/room/sc_music_material_shuffle.png
Normal file
|
After Width: | Height: | Size: 586 B |
BIN
sc_images/room/sc_music_material_single_loop.png
Normal file
|
After Width: | Height: | Size: 729 B |
BIN
sc_images/room/sc_music_material_volume.png
Normal file
|
After Width: | Height: | Size: 691 B |
15
需求进度.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 和声网都完成后才进入完整房间”的体验存在差异。
|
||||
|
||||