yumi-flutter/lib/modules/room/music/room_music_page.dart
2026-06-15 16:21:05 +08:00

636 lines
20 KiB
Dart

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 Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: Scaffold(
backgroundColor: const Color(0xFF071F1B),
body: SafeArea(
child: Stack(
children: [
Column(
children: [
_buildHeader(context),
Expanded(
child: Consumer<RoomMusicManager>(
builder: (context, manager, child) {
final songs = _filteredSongs(manager.playlist);
if (manager.playlist.isEmpty) {
return _buildEmptyState(context);
}
return Column(
children: [
_buildSearchField(),
_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: [
_PlainIconButton(
onPressed: () => Navigator.pop(context),
child: Icon(
Icons.arrow_back_ios_new,
color: Colors.white,
size: 24.w,
),
),
Expanded(
child: Text(
RoomMusicTexts.t(context, "music", "音乐"),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w800,
),
),
),
_PlainIconButton(
onPressed: _startAddFlow,
child: Image.asset(
"sc_images/room/sc_icon_room_music_add.png",
width: 24.w,
height: 24.w,
),
),
],
),
);
}
Widget _buildSearchField() {
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 4.w, 16.w, 14.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(17.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: Row(
children: [
Expanded(
child: Text(
template.replaceAll("{1}", count.toString()),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.90),
fontSize: 14.sp,
),
),
),
_PlainIconButton(
onPressed:
_searchController.text.trim().isEmpty
? () {
setState(() {
_isEditing = !_isEditing;
});
}
: null,
child: Image.asset(
"sc_images/room/sc_music_material_edit.png",
width: 24.w,
height: 24.w,
color:
_isEditing
? const Color(0xFF33E6A2)
: Colors.white.withValues(
alpha: _searchController.text.trim().isEmpty ? 1 : 0.36,
),
),
),
],
),
);
}
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),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _startAddFlow,
child: Container(
width: 164.w,
height: 42.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF33E6A2),
borderRadius: BorderRadius.circular(22.w),
),
child: Text(
RoomMusicTexts.t(
context,
"roomMusicScanFromPhone",
"从手机扫描添加",
),
style: TextStyle(
color: const Color(0xFF06251F),
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 Padding(
padding: EdgeInsets.only(bottom: 8.w),
child: _SwipeDeleteTile(
key: ValueKey("music-${item.id}"),
onDelete: () {
_confirmDelete(item);
},
child: _SongTile(
item: item,
selected: manager.current?.id == item.id,
playing: manager.current?.id == item.id && manager.isPlaying,
onTap: () => manager.play(item),
),
),
);
},
);
}
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),
buildDefaultDragHandles: false,
itemCount: songs.length,
proxyDecorator: (child, index, animation) {
return Material(
color: Colors.transparent,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
child: child,
);
},
onReorder: (oldIndex, newIndex) {
manager.reorder(oldIndex, newIndex);
},
itemBuilder: (context, index) {
final item = songs[index];
return Padding(
key: ValueKey("reorder-${item.id}"),
padding: EdgeInsets.only(bottom: 8.w),
child: _SongTile(
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>(_musicRoute(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({
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 GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFB2FBCC), Color(0x00B2FBCC)],
stops: [0, 1],
),
borderRadius: BorderRadius.circular(8.w),
),
child: Container(
height: 54.w,
margin: const EdgeInsets.all(1),
padding: EdgeInsetsDirectional.fromSTEB(12.w, 0, 12.w, 0),
decoration: BoxDecoration(
color: const Color(0xFF08251E),
borderRadius: BorderRadius.circular(7.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:
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) ...[
SizedBox(width: 10.w),
trailing!,
] else
Image.asset(
playing
? "sc_images/room/sc_music_material_pause.png"
: "sc_images/room/sc_music_material_play.png",
width: 26.w,
height: 26.w,
),
],
),
),
),
);
}
String _formatDuration(int milliseconds) {
final seconds = (milliseconds / 1000).floor();
final minutes = seconds ~/ 60;
final rest = seconds % 60;
return "$minutes:${rest.toString().padLeft(2, '0')}";
}
}
class _SwipeDeleteTile extends StatefulWidget {
const _SwipeDeleteTile({
super.key,
required this.child,
required this.onDelete,
});
final Widget child;
final VoidCallback onDelete;
@override
State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState();
}
class _SwipeDeleteTileState extends State<_SwipeDeleteTile> {
double _dragOffset = 0;
@override
Widget build(BuildContext context) {
final actionWidth = 60.w;
return LayoutBuilder(
builder: (context, constraints) {
return ClipRect(
child: SizedBox(
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) {
setState(() {
_dragOffset = (_dragOffset + details.delta.dx).clamp(
-actionWidth,
0,
);
});
},
onHorizontalDragEnd: (_) {
setState(() {
_dragOffset =
_dragOffset.abs() > actionWidth * 0.38 ? -actionWidth : 0;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(_dragOffset, 0, 0),
child: SizedBox(
width: constraints.maxWidth + actionWidth,
child: Row(
children: [
SizedBox(
width: constraints.maxWidth,
child: widget.child,
),
SizedBox(
width: actionWidth,
height: 56.w,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onDelete,
child: Container(
margin: EdgeInsetsDirectional.only(start: 4.w),
decoration: BoxDecoration(
color: const Color(0xFFE64A4A),
borderRadius: BorderRadius.circular(8.w),
),
alignment: Alignment.center,
child: Image.asset(
"sc_images/room/sc_music_material_delete.png",
width: 22.w,
height: 22.w,
),
),
),
),
],
),
),
),
),
),
);
},
);
}
}
Route<T> _musicRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: true,
barrierColor: const Color(0xFF071F1B),
transitionDuration: const Duration(milliseconds: 180),
reverseTransitionDuration: const Duration(milliseconds: 160),
pageBuilder:
(context, animation, secondaryAnimation) =>
ColoredBox(color: const Color(0xFF071F1B), child: page),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final tween = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).chain(CurveTween(curve: Curves.easeOutCubic));
return SlideTransition(position: animation.drive(tween), child: child);
},
);
}
class _PlainIconButton extends StatelessWidget {
const _PlainIconButton({required this.child, required this.onPressed});
final Widget child;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(width: 48.w, height: 48.w, child: Center(child: child)),
);
}
}