16 changed files with 733 additions and 15 deletions
@ -0,0 +1,140 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'dart:math' as math; |
|||
|
|||
/// 环形进度条播放按钮组件 |
|||
class CircularProgressButton extends StatelessWidget { |
|||
final bool isPlaying; |
|||
final double progress; // 0.0 - 1.0 |
|||
final VoidCallback onPlayPause; |
|||
final Function(double)? onSeek; // 点击进度条跳转回调 |
|||
final double size; |
|||
final Color primaryColor; |
|||
final Color backgroundColor; |
|||
final double strokeWidth; |
|||
|
|||
const CircularProgressButton({ |
|||
Key? key, |
|||
required this.isPlaying, |
|||
required this.progress, |
|||
required this.onPlayPause, |
|||
this.onSeek, |
|||
this.size = 36.0, |
|||
required this.primaryColor, |
|||
this.backgroundColor = Colors.grey, |
|||
this.strokeWidth = 2.0, |
|||
}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return GestureDetector( |
|||
onTapDown: (details) { |
|||
if (onSeek != null) { |
|||
// 计算点击位置对应的进度 |
|||
final center = Offset(size / 2, size / 2); |
|||
final tapPosition = details.localPosition; |
|||
final dx = tapPosition.dx - center.dx; |
|||
final dy = tapPosition.dy - center.dy; |
|||
|
|||
// 计算角度 (从顶部开始,顺时针) |
|||
double angle = math.atan2(dy, dx) + math.pi / 2; |
|||
if (angle < 0) angle += 2 * math.pi; |
|||
|
|||
// 转换为进度值 (0.0 - 1.0) |
|||
final seekProgress = angle / (2 * math.pi); |
|||
|
|||
// 检查点击是否在圆环区域内 |
|||
final distance = math.sqrt(dx * dx + dy * dy); |
|||
final innerRadius = (size / 2) - strokeWidth - 4; |
|||
final outerRadius = (size / 2) + 4; |
|||
|
|||
if (distance >= innerRadius && distance <= outerRadius) { |
|||
onSeek!(seekProgress); |
|||
} else if (distance < innerRadius) { |
|||
// 点击中心区域,播放/暂停 |
|||
onPlayPause(); |
|||
} |
|||
} else { |
|||
onPlayPause(); |
|||
} |
|||
}, |
|||
child: SizedBox( |
|||
width: size, |
|||
height: size, |
|||
child: Stack( |
|||
alignment: Alignment.center, |
|||
children: [ |
|||
// 背景圆环 |
|||
CustomPaint( |
|||
size: Size(size, size), |
|||
painter: _CircularProgressPainter( |
|||
progress: 1.0, |
|||
color: backgroundColor.withValues(alpha: 0.3), |
|||
strokeWidth: strokeWidth, |
|||
), |
|||
), |
|||
// 进度圆环 |
|||
CustomPaint( |
|||
size: Size(size, size), |
|||
painter: _CircularProgressPainter( |
|||
progress: progress, |
|||
color: primaryColor, |
|||
strokeWidth: strokeWidth, |
|||
), |
|||
), |
|||
// 播放/暂停按钮 |
|||
Icon( |
|||
isPlaying ? Icons.pause : Icons.play_arrow, |
|||
color: primaryColor, |
|||
size: size * 0.5, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 环形进度条绘制器 |
|||
class _CircularProgressPainter extends CustomPainter { |
|||
final double progress; |
|||
final Color color; |
|||
final double strokeWidth; |
|||
|
|||
_CircularProgressPainter({ |
|||
required this.progress, |
|||
required this.color, |
|||
required this.strokeWidth, |
|||
}); |
|||
|
|||
@override |
|||
void paint(Canvas canvas, Size size) { |
|||
final center = Offset(size.width / 2, size.height / 2); |
|||
final radius = (size.width / 2) - strokeWidth / 2; |
|||
|
|||
final paint = Paint() |
|||
..color = color |
|||
..strokeWidth = strokeWidth |
|||
..style = PaintingStyle.stroke |
|||
..strokeCap = StrokeCap.round; |
|||
|
|||
// 绘制圆弧,从顶部开始 (-π/2),顺时针绘制 |
|||
const startAngle = -math.pi / 2; |
|||
final sweepAngle = 2 * math.pi * progress; |
|||
|
|||
canvas.drawArc( |
|||
Rect.fromCircle(center: center, radius: radius), |
|||
startAngle, |
|||
sweepAngle, |
|||
false, |
|||
paint, |
|||
); |
|||
} |
|||
|
|||
@override |
|||
bool shouldRepaint(covariant CustomPainter oldDelegate) { |
|||
return oldDelegate is! _CircularProgressPainter || |
|||
oldDelegate.progress != progress || |
|||
oldDelegate.color != color || |
|||
oldDelegate.strokeWidth != strokeWidth; |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/music_playlist_controller.dart'; |
|||
|
|||
class MusicPlaylistBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<MusicPlaylistController>( |
|||
() => MusicPlaylistController(), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import 'package:get/get.dart'; |
|||
|
|||
class MusicPlaylistController extends GetxController { |
|||
// 歌单数据 |
|||
final playlist = <Map<String, dynamic>>[].obs; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
// 从路由参数获取歌单数据 |
|||
final List<Map<String, dynamic>> playlistData = Get.arguments ?? []; |
|||
playlist.assignAll(playlistData); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -0,0 +1,203 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../../agent/controllers/agent_controller.dart'; |
|||
import '../../agent/views/circular_progress_button.dart'; |
|||
|
|||
class MusicPlaylistView extends StatelessWidget { |
|||
const MusicPlaylistView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final controller = Get.find<AgentController>(); |
|||
final List<Map<String, dynamic>> playlist = Get.arguments ?? []; |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
appBar: AppBar( |
|||
title: const Text('音乐播放列表'), |
|||
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
foregroundColor: isDarkMode ? Colors.white : Colors.black, |
|||
elevation: 0, |
|||
), |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
body: playlist.isEmpty |
|||
? Center( |
|||
child: Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Icon( |
|||
Icons.music_off, |
|||
size: 64, |
|||
color: Colors.grey[400], |
|||
), |
|||
const SizedBox(height: 16), |
|||
Text( |
|||
'暂无音乐', |
|||
style: TextStyle( |
|||
fontSize: 16, |
|||
color: Colors.grey[600], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
) |
|||
: ListView.builder( |
|||
padding: const EdgeInsets.all(16), |
|||
itemCount: playlist.length, |
|||
itemBuilder: (context, index) { |
|||
final item = playlist[index]; |
|||
final id = item["id"]?.toString() ?? ""; |
|||
final name = item["name"] ?? "未知歌曲"; |
|||
final url = item["url"] ?? ""; |
|||
final singer = item["singer"] ?? item["sgener"] ?? "未知歌手"; |
|||
final image = item["image"] ?? ""; |
|||
|
|||
return _buildMusicItem( |
|||
context, |
|||
controller, |
|||
id, |
|||
name, |
|||
url, |
|||
singer, |
|||
image, |
|||
isDarkMode, |
|||
); |
|||
}, |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildMusicItem( |
|||
BuildContext context, |
|||
AgentController controller, |
|||
String id, |
|||
String name, |
|||
String url, |
|||
String singer, |
|||
String image, |
|||
bool isDarkMode, |
|||
) { |
|||
final song = { |
|||
'id': id, |
|||
'title': name, |
|||
'url': url, |
|||
'artist': singer, |
|||
'coverUrl': image, |
|||
}; |
|||
|
|||
return Container( |
|||
margin: const EdgeInsets.only(bottom: 12), |
|||
padding: const EdgeInsets.all(12), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[800] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03), |
|||
blurRadius: 3, |
|||
spreadRadius: 1, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
// 封面图片 |
|||
Container( |
|||
width: 50, |
|||
height: 50, |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(8), |
|||
color: Colors.grey[300], |
|||
), |
|||
child: image.isNotEmpty |
|||
? ClipRRect( |
|||
borderRadius: BorderRadius.circular(8), |
|||
child: Image.network( |
|||
image, |
|||
fit: BoxFit.cover, |
|||
errorBuilder: (context, error, stackTrace) { |
|||
return Icon( |
|||
Icons.music_note, |
|||
color: Colors.grey[600], |
|||
); |
|||
}, |
|||
), |
|||
) |
|||
: Icon( |
|||
Icons.music_note, |
|||
color: Colors.grey[600], |
|||
), |
|||
), |
|||
const SizedBox(width: 12), |
|||
// 歌曲信息 |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
name, |
|||
style: TextStyle( |
|||
fontSize: 16, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
maxLines: 1, |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
const SizedBox(height: 4), |
|||
Text( |
|||
singer, |
|||
style: TextStyle( |
|||
fontSize: 14, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
maxLines: 1, |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
const SizedBox(width: 12), |
|||
// 播放按钮 |
|||
Obx(() { |
|||
final isThisPlaying = |
|||
controller.isPlaying.value && controller.currentUrl.value == url; |
|||
|
|||
// 只有当前播放的歌曲才显示进度 |
|||
final currentProgress = isThisPlaying ? controller.progress.value : 0.0; |
|||
|
|||
return CircularProgressButton( |
|||
isPlaying: isThisPlaying, |
|||
progress: currentProgress, |
|||
primaryColor: Theme.of(context).primaryColor, |
|||
backgroundColor: Colors.grey, |
|||
size: 40.0, |
|||
strokeWidth: 2.5, |
|||
onPlayPause: () { |
|||
if (controller.isPlaying.value) { |
|||
if (controller.currentUrl.value == url) { |
|||
controller.musicPause(); |
|||
} else { |
|||
controller.musicPlay(song); |
|||
} |
|||
} else { |
|||
if (controller.currentUrl.value == url) { |
|||
controller.musicResume(); |
|||
} else { |
|||
controller.musicPlay(song); |
|||
} |
|||
} |
|||
}, |
|||
onSeek: isThisPlaying ? (seekProgress) { |
|||
// 只有当前播放的歌曲才允许跳转 |
|||
final targetPosition = (controller.duration.value * seekProgress).round(); |
|||
controller.seekToPosition(targetPosition); |
|||
} : null, |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,132 @@ |
|||
import 'package:flutter_test/flutter_test.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:deep_voice/modules/agent/controllers/agent_controller.dart'; |
|||
import 'package:deep_voice/data/services/music_manager.dart'; |
|||
|
|||
void main() { |
|||
group('音乐功能测试', () { |
|||
late AgentController controller; |
|||
|
|||
setUp(() { |
|||
// 初始化GetX |
|||
Get.testMode = true; |
|||
|
|||
// 模拟MusicManager |
|||
Get.put<MusicManager>(MockMusicManager()); |
|||
|
|||
// 创建控制器 |
|||
controller = AgentController(); |
|||
}); |
|||
|
|||
tearDown(() { |
|||
Get.reset(); |
|||
}); |
|||
|
|||
test('播放进度状态初始化', () { |
|||
expect(controller.currentPosition.value, 0); |
|||
expect(controller.duration.value, 0); |
|||
expect(controller.progress.value, 0.0); |
|||
expect(controller.isPlaying.value, false); |
|||
expect(controller.currentUrl.value, ""); |
|||
}); |
|||
|
|||
test('播放音乐时状态更新', () { |
|||
final song = { |
|||
'id': 'test_id', |
|||
'title': 'Test Song', |
|||
'url': 'https://example.com/song.mp3', |
|||
'artist': 'Test Artist', |
|||
'coverUrl': 'https://example.com/cover.jpg', |
|||
}; |
|||
|
|||
controller.musicPlay(song); |
|||
|
|||
expect(controller.isPlaying.value, true); |
|||
expect(controller.currentUrl.value, song['url']); |
|||
}); |
|||
|
|||
test('暂停音乐时状态更新', () { |
|||
// 先播放 |
|||
final song = { |
|||
'id': 'test_id', |
|||
'title': 'Test Song', |
|||
'url': 'https://example.com/song.mp3', |
|||
'artist': 'Test Artist', |
|||
'coverUrl': 'https://example.com/cover.jpg', |
|||
}; |
|||
controller.musicPlay(song); |
|||
|
|||
// 然后暂停 |
|||
controller.musicPause(); |
|||
|
|||
expect(controller.isPlaying.value, false); |
|||
}); |
|||
|
|||
test('恢复播放时状态更新', () { |
|||
// 先播放然后暂停 |
|||
final song = { |
|||
'id': 'test_id', |
|||
'title': 'Test Song', |
|||
'url': 'https://example.com/song.mp3', |
|||
'artist': 'Test Artist', |
|||
'coverUrl': 'https://example.com/cover.jpg', |
|||
}; |
|||
controller.musicPlay(song); |
|||
controller.musicPause(); |
|||
|
|||
// 恢复播放 |
|||
controller.musicResume(); |
|||
|
|||
expect(controller.isPlaying.value, true); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
// 模拟MusicManager用于测试 |
|||
class MockMusicManager extends GetxService implements MusicManager { |
|||
@override |
|||
Future<void> play(Map<String, String> song) async { |
|||
// 模拟播放 |
|||
} |
|||
|
|||
@override |
|||
Future<void> pause() async { |
|||
// 模拟暂停 |
|||
} |
|||
|
|||
@override |
|||
Future<void> resume() async { |
|||
// 模拟恢复 |
|||
} |
|||
|
|||
@override |
|||
Future<Map<String, dynamic>> getStatus() async { |
|||
return { |
|||
'isPlaying': false, |
|||
'url': '', |
|||
}; |
|||
} |
|||
|
|||
@override |
|||
Future<Map<String, dynamic>> getProgress() async { |
|||
return { |
|||
'currentPosition': 0, |
|||
'duration': 0, |
|||
'progress': 0.0, |
|||
}; |
|||
} |
|||
|
|||
@override |
|||
Future<void> seekTo(int positionMs) async { |
|||
// 模拟跳转 |
|||
} |
|||
|
|||
@override |
|||
void onInit() {} |
|||
|
|||
@override |
|||
void onClose() {} |
|||
|
|||
@override |
|||
Future<void> requestNotificationPermission() async {} |
|||
} |
|||
Loading…
Reference in new issue