diff --git a/lib/data/services/music_manager.dart b/lib/data/services/music_manager.dart index 8bfea2f47..4df938f87 100644 --- a/lib/data/services/music_manager.dart +++ b/lib/data/services/music_manager.dart @@ -70,4 +70,14 @@ class MusicManager extends GetxService { Future> getStatus() async { return await _musicService.getStatus(); } + + //获取播放进度 + Future> getProgress() async { + return await _musicService.getProgress(); + } + + //跳转到指定位置 + Future seekTo(int positionMs) async { + await _musicService.seekTo(positionMs); + } } diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 698a44d99..017bd4a56 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/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? _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 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 seekToPosition(int positionMs) async { + try { + await _musiceManager.seekTo(positionMs); + // 立即更新进度状态 + await updateMusicProgress(); + } catch (e) { + Logger.e('AgentController', '跳转播放位置失败: $e'); + } } // 修改自动停止处理,遵循TTS设置 diff --git a/lib/modules/agent/views/circular_progress_button.dart b/lib/modules/agent/views/circular_progress_button.dart new file mode 100644 index 000000000..510bf95a4 --- /dev/null +++ b/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; + } +} diff --git a/lib/modules/agent/views/music_card.dart b/lib/modules/agent/views/music_card.dart index d76270cad..250f517c7 100644 --- a/lib/modules/agent/views/music_card.dart +++ b/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( diff --git a/lib/modules/music/bindings/music_playlist_binding.dart b/lib/modules/music/bindings/music_playlist_binding.dart new file mode 100644 index 000000000..85f385d42 --- /dev/null +++ b/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(), + ); + } +} diff --git a/lib/modules/music/controllers/music_playlist_controller.dart b/lib/modules/music/controllers/music_playlist_controller.dart new file mode 100644 index 000000000..ac0c8d2ee --- /dev/null +++ b/lib/modules/music/controllers/music_playlist_controller.dart @@ -0,0 +1,19 @@ +import 'package:get/get.dart'; + +class MusicPlaylistController extends GetxController { + // 歌单数据 + final playlist = >[].obs; + + @override + void onInit() { + super.onInit(); + // 从路由参数获取歌单数据 + final List> playlistData = Get.arguments ?? []; + playlist.assignAll(playlistData); + } + + @override + void onClose() { + super.onClose(); + } +} diff --git a/lib/modules/music/views/music_playlist_view.dart b/lib/modules/music/views/music_playlist_view.dart new file mode 100644 index 000000000..c5ca02817 --- /dev/null +++ b/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(); + final List> 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, + ); + }), + ], + ), + ); + } +} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index fd468e296..4631245f0 100644 --- a/lib/routes/app_pages.dart +++ b/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), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 066c8af22..3e2e1be89 100644 --- a/lib/routes/app_routes.dart +++ b/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'; } diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt index dfe71e4c2..f7df25578 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt +++ b/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 { + 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 diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt index 9756b25f8..79f5937bc 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt +++ b/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("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() } } diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt index 4ea229c4e..14cbd35f4 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt +++ b/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? = null, volume: Float? = null) { + fun startServiceWithCommand(context: Context, command: String, song: Map? = 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) diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift index 92c4e9a21..81aa3d2c3 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift +++ b/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() diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift index 5a9a1180e..61e1a844e 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift +++ b/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) } diff --git a/local_plugins/music_service/lib/music_service.dart b/local_plugins/music_service/lib/music_service.dart index 02e3756ec..6d8817fc2 100644 --- a/local_plugins/music_service/lib/music_service.dart +++ b/local_plugins/music_service/lib/music_service.dart @@ -59,4 +59,19 @@ class MusicService { 'url': result?['url'] ?? "", }; } + + /// 获取播放进度信息 + Future> getProgress() async { + final result = await _methodChannel.invokeMethod('getProgress'); + return { + 'currentPosition': result?['currentPosition'] ?? 0, // 当前播放位置(毫秒) + 'duration': result?['duration'] ?? 0, // 总时长(毫秒) + 'progress': result?['progress'] ?? 0.0, // 播放进度(0.0-1.0) + }; + } + + /// 跳转到指定位置 + Future seekTo(int positionMs) async { + await _methodChannel.invokeMethod('seekTo', {'position': positionMs}); + } } diff --git a/test/music_features_test.dart b/test/music_features_test.dart new file mode 100644 index 000000000..b8e144965 --- /dev/null +++ b/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(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 play(Map song) async { + // 模拟播放 + } + + @override + Future pause() async { + // 模拟暂停 + } + + @override + Future resume() async { + // 模拟恢复 + } + + @override + Future> getStatus() async { + return { + 'isPlaying': false, + 'url': '', + }; + } + + @override + Future> getProgress() async { + return { + 'currentPosition': 0, + 'duration': 0, + 'progress': 0.0, + }; + } + + @override + Future seekTo(int positionMs) async { + // 模拟跳转 + } + + @override + void onInit() {} + + @override + void onClose() {} + + @override + Future requestNotificationPermission() async {} +}