Browse Source

feat(音乐): 新增音乐播放列表功能及进度控制

- 添加音乐播放列表路由和页面
- 实现播放进度获取和跳转功能
- 新增环形进度条播放按钮组件
- 完善音乐播放状态管理
- 添加音乐功能测试用例
weicu
lxm 1 year ago
parent
commit
0235ab2873
  1. 10
      lib/data/services/music_manager.dart
  2. 54
      lib/modules/agent/controllers/agent_controller.dart
  3. 140
      lib/modules/agent/views/circular_progress_button.dart
  4. 39
      lib/modules/agent/views/music_card.dart
  5. 11
      lib/modules/music/bindings/music_playlist_binding.dart
  6. 19
      lib/modules/music/controllers/music_playlist_controller.dart
  7. 203
      lib/modules/music/views/music_playlist_view.dart
  8. 9
      lib/routes/app_pages.dart
  9. 3
      lib/routes/app_routes.dart
  10. 53
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  11. 13
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt
  12. 3
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt
  13. 30
      local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift
  14. 14
      local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift
  15. 15
      local_plugins/music_service/lib/music_service.dart
  16. 132
      test/music_features_test.dart

10
lib/data/services/music_manager.dart

@ -70,4 +70,14 @@ class MusicManager extends GetxService {
Future<Map<String, dynamic>> getStatus() async {
return await _musicService.getStatus();
}
//获取播放进度
Future<Map<String, dynamic>> getProgress() async {
return await _musicService.getProgress();
}
//跳转到指定位置
Future<void> seekTo(int positionMs) async {
await _musicService.seekTo(positionMs);
}
}

54
lib/modules/agent/controllers/agent_controller.dart

@ -36,6 +36,11 @@ class AgentController extends GetxController {
var isPlaying = false.obs;
var currentUrl = "".obs;
// 播放进度相关状态
var currentPosition = 0.obs; // 当前播放位置(毫秒)
var duration = 0.obs; // 总时长(毫秒)
var progress = 0.0.obs; // 播放进度(0.0-1.0)
// 各种状态标志
final isInitialized = false.obs;
final isListening = false.obs; //是否正在语音识别
@ -63,6 +68,9 @@ class AgentController extends GetxController {
// 导航流事件订阅
StreamSubscription<AmapServiceEvent>? _amapeventSubscription;
// 播放进度更新定时器
Timer? _progressTimer;
// 用于跟踪AI回复状态
bool _isNewAssistantResponse = true;
String _currentResponseId = '';
@ -90,6 +98,8 @@ class AgentController extends GetxController {
textController.dispose();
scrollController.dispose();
_eventSubscription?.cancel();
_amapeventSubscription?.cancel();
_progressTimer?.cancel();
super.onClose();
}
@ -809,17 +819,61 @@ class AgentController extends GetxController {
_musiceManager.play(song);
isPlaying.value = true;
currentUrl.value = song['url'] ?? '';
_startProgressTimer();
}
// 播放音乐
void musicPause() {
_musiceManager.pause();
isPlaying.value = false;
_stopProgressTimer();
}
void musicResume() {
_musiceManager.resume();
isPlaying.value = true;
_startProgressTimer();
}
// 启动进度更新定时器
void _startProgressTimer() {
_stopProgressTimer(); // 先停止现有定时器
_progressTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (isPlaying.value) {
updateMusicProgress();
} else {
timer.cancel();
}
});
}
// 停止进度更新定时器
void _stopProgressTimer() {
_progressTimer?.cancel();
_progressTimer = null;
}
// 获取播放进度
Future<void> updateMusicProgress() async {
try {
var progressInfo = await _musiceManager.getProgress();
currentPosition.value = progressInfo['currentPosition'] ?? 0;
duration.value = progressInfo['duration'] ?? 0;
progress.value = progressInfo['progress'] ?? 0.0;
} catch (e) {
Logger.e('AgentController', '获取播放进度失败: $e');
}
}
// 跳转到指定位置
Future<void> seekToPosition(int positionMs) async {
try {
await _musiceManager.seekTo(positionMs);
// 立即更新进度状态
await updateMusicProgress();
} catch (e) {
Logger.e('AgentController', '跳转播放位置失败: $e');
}
}
// 修改自动停止处理,遵循TTS设置

140
lib/modules/agent/views/circular_progress_button.dart

@ -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;
}
}

39
lib/modules/agent/views/music_card.dart

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/agent_controller.dart';
import 'circular_progress_button.dart';
/// 构建音乐项目
Widget buildMusicItem(BuildContext context, String id, String name, String url,
@ -23,7 +24,7 @@ Widget buildMusicItem(BuildContext context, String id, String name, String url,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.03),
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03),
blurRadius: 3,
spreadRadius: 1,
offset: const Offset(0, 1),
@ -83,8 +84,17 @@ Widget buildMusicItem(BuildContext context, String id, String name, String url,
final isThisPlaying =
controller.isPlaying.value && controller.currentUrl.value == url;
return IconButton(
onPressed: () {
// 只有当前播放的歌曲才显示进度
final currentProgress = isThisPlaying ? controller.progress.value : 0.0;
return CircularProgressButton(
isPlaying: isThisPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 36.0,
strokeWidth: 2.0,
onPlayPause: () {
if (controller.isPlaying.value) {
if (controller.currentUrl.value == url) {
controller.musicPause();
@ -99,13 +109,11 @@ Widget buildMusicItem(BuildContext context, String id, String name, String url,
}
}
},
icon: Icon(
isThisPlaying ? Icons.pause_circle : Icons.play_circle,
color: Theme.of(context).primaryColor,
),
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
iconSize: 36,
onSeek: isThisPlaying ? (seekProgress) {
// 只有当前播放的歌曲才允许跳转
final targetPosition = (controller.duration.value * seekProgress).round();
controller.seekToPosition(targetPosition);
} : null,
);
}),
],
@ -124,7 +132,7 @@ Widget buildMusicContent(
Text(
'以下是一些推荐的音乐:',
style: TextStyle(
color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87,
color: isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
@ -154,7 +162,7 @@ Widget buildMusicContent(
Icon(
Icons.music_note,
size: 16,
color: Theme.of(context).primaryColor.withOpacity(0.7),
color: Theme.of(context).primaryColor.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
@ -197,10 +205,13 @@ Widget buildMusicContent(
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: () {},
onPressed: () {
// 跳转到完整歌单页面,传递完整的歌单数据
Get.toNamed('/music/playlist', arguments: playlist);
},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(

11
lib/modules/music/bindings/music_playlist_binding.dart

@ -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(),
);
}
}

19
lib/modules/music/controllers/music_playlist_controller.dart

@ -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();
}
}

203
lib/modules/music/views/music_playlist_view.dart

@ -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,
);
}),
],
),
);
}
}

9
lib/routes/app_pages.dart

@ -42,6 +42,8 @@ import '../modules/ota_upgrade/views/ota_upgrade_view.dart';
import '../modules/ota_upgrade/bindings/ota_upgrade_binding.dart';
import '../modules/realtime/bindings/realtime_binding.dart';
import '../modules/realtime/views/realtime_view.dart';
import '../modules/music/views/music_playlist_view.dart';
import '../modules/music/bindings/music_playlist_binding.dart';
abstract class AppPages {
static final pages = [
@ -206,5 +208,12 @@ abstract class AppPages {
page: () => const RealtimeView(),
binding: RealtimeBinding(),
),
GetPage(
name: Routes.musicPlaylist,
page: () => const MusicPlaylistView(),
binding: MusicPlaylistBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
];
}

3
lib/routes/app_routes.dart

@ -46,6 +46,9 @@ abstract class Routes {
static const agent = '/agent'; // 在线客服
static const navigation = '/navigation'; // 实时导航
// 音乐相关
static const musicPlaylist = '/music/playlist'; // 音乐播放列表
// 实时语音对话
static const realtime = '/realtime';
}

53
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt

@ -43,6 +43,39 @@ class MusicService : MediaBrowserServiceCompat() {
fun isServiceRunning() = isRunning.get()
fun isPlaying() = currentStatus
fun getCurrentUrl(): String? = currentUrlInternal
// 获取播放进度信息
fun getProgress(): Map<String, Any> {
return if (instance != null && instance!!.mediaPlayer != null) {
val player = instance!!.mediaPlayer!!
try {
val currentPosition = if (player.isPlaying || currentStatus) player.currentPosition else 0
val duration = player.duration
val progress = if (duration > 0) currentPosition.toDouble() / duration.toDouble() else 0.0
mapOf(
"currentPosition" to currentPosition,
"duration" to duration,
"progress" to progress
)
} catch (e: Exception) {
Log.e(TAG, "获取播放进度失败: ${e.message}")
mapOf(
"currentPosition" to 0,
"duration" to 0,
"progress" to 0.0
)
}
} else {
mapOf(
"currentPosition" to 0,
"duration" to 0,
"progress" to 0.0
)
}
}
private var instance: MusicService? = null
}
private var isReceiverRegistered = false
private lateinit var mediaSession: MediaSessionCompat
@ -51,6 +84,7 @@ class MusicService : MediaBrowserServiceCompat() {
override fun onCreate() {
super.onCreate()
instance = this
isRunning.set(true)
setupMediaSession()
startForeground(NOTIFICATION_ID, createNotification("等待播放","DeepSound",""))
@ -116,6 +150,12 @@ class MusicService : MediaBrowserServiceCompat() {
"stop" -> stopSelf()
"next" -> if (currentIndex + 1 < playlist.size) play(playlist[++currentIndex])
"previous" -> if (currentIndex - 1 >= 0) play(playlist[--currentIndex])
"seekTo" -> {
val position = intent.getIntExtra("position", -1)
if (position >= 0) {
seekTo(position)
}
}
}
return START_STICKY
}
@ -401,6 +441,19 @@ class MusicService : MediaBrowserServiceCompat() {
else stopSelf()
}
private fun seekTo(positionMs: Int) {
mediaPlayer?.let { player ->
try {
if (player.isPlaying || currentStatus) {
player.seekTo(positionMs)
Log.i(TAG, "跳转到位置: ${positionMs}ms")
}
} catch (e: Exception) {
Log.e(TAG, "跳转失败: ${e.message}")
}
}
}
private fun updateNotification(title: String,artist: String,coverUrl: String?) {
val notification = createNotification(title, artist, coverUrl)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

13
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt

@ -70,6 +70,19 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler {
)
result.success(status)
}
"getProgress" -> {
val progressInfo = MusicService.getProgress()
result.success(progressInfo)
}
"seekTo" -> {
val position = call.argument<Int>("position")
if (position != null) {
MusicServiceStarter.startServiceWithCommand(context, "seekTo", position = position)
result.success(null)
} else {
result.error("INVALID_ARGUMENT", "Missing 'position' parameter", null)
}
}
else -> result.notImplemented()
}
}

3
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt

@ -18,7 +18,7 @@ import java.util.jar.Manifest
object MusicServiceStarter {
private val TAG = "MusicServiceStarter"
//播放单曲
fun startServiceWithCommand(context: Context, command: String, song: Map<String,String>? = null, volume: Float? = null) {
fun startServiceWithCommand(context: Context, command: String, song: Map<String,String>? = null, volume: Float? = null, position: Int? = null) {
Log.i(TAG, "liwei------------------- startServiceWithCommand: $command $song")
val intent = Intent(context, MusicService::class.java).apply {
putExtra("command", command)
@ -26,6 +26,7 @@ object MusicServiceStarter {
putExtra("song", HashMap(it)) // 只有非 null 时才 putExtra
}
volume?.let { putExtra("volume", it) }
position?.let { putExtra("position", it) }
}
val success = try {
ContextCompat.startForegroundService(context, intent)

30
local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift

@ -75,6 +75,36 @@ public class MusicService: NSObject {
func setVolume(volume: Float) {
player?.volume = volume
}
func getProgress() -> [String: Any] {
guard let player = player, let currentItem = player.currentItem else {
return [
"currentPosition": 0,
"duration": 0,
"progress": 0.0
]
}
let currentTime = CMTimeGetSeconds(player.currentTime())
let duration = CMTimeGetSeconds(currentItem.duration)
// 处理无效值
let currentPosition = currentTime.isNaN || currentTime.isInfinite ? 0 : Int(currentTime * 1000)
let totalDuration = duration.isNaN || duration.isInfinite ? 0 : Int(duration * 1000)
let progress = totalDuration > 0 ? currentTime / duration : 0.0
return [
"currentPosition": currentPosition,
"duration": totalDuration,
"progress": progress.isNaN || progress.isInfinite ? 0.0 : progress
]
}
func seekTo(positionMs: Int) {
guard let player = player else { return }
let time = CMTime(seconds: Double(positionMs) / 1000.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
player.seek(to: time)
}
private func setupRemoteCommandCenter() {
let commandCenter = MPRemoteCommandCenter.shared()

14
local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift

@ -65,6 +65,20 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
"url": currentUrl != nil ? currentUrl! : NSNull()
]
result(status)
case "getProgress":
let progressInfo = MusicService.shared.getProgress()
result(progressInfo)
case "seekTo":
if let args = call.arguments as? [String: Any],
let position = args["position"] as? Int {
MusicService.shared.seekTo(positionMs: position)
result(nil)
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing or invalid 'position'", details: nil))
}
default:
result(FlutterMethodNotImplemented)
}

15
local_plugins/music_service/lib/music_service.dart

@ -59,4 +59,19 @@ class MusicService {
'url': result?['url'] ?? "",
};
}
/// 获取播放进度信息
Future<Map<String, dynamic>> getProgress() async {
final result = await _methodChannel.invokeMethod<Map>('getProgress');
return {
'currentPosition': result?['currentPosition'] ?? 0, // 当前播放位置(毫秒)
'duration': result?['duration'] ?? 0, // 总时长(毫秒)
'progress': result?['progress'] ?? 0.0, // 播放进度(0.0-1.0)
};
}
/// 跳转到指定位置
Future<void> seekTo(int positionMs) async {
await _methodChannel.invokeMethod('seekTo', {'position': positionMs});
}
}

132
test/music_features_test.dart

@ -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…
Cancel
Save