28 changed files with 617 additions and 1005 deletions
|
After Width: | Height: | Size: 19 KiB |
@ -1,9 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/qqmusic_controller.dart'; |
|
||||
|
|
||||
class QQMusicBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
Get.lazyPut<QQMusicController>(() => QQMusicController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,326 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:qq_music/qq_music.dart'; |
|
||||
import 'package:voitrans/data/services/qq_music.dart'; |
|
||||
import '../models/song_model.dart'; |
|
||||
|
|
||||
class QQMusicController extends GetxController { |
|
||||
final QqMusicService _qqMusic = Get.find<QqMusicService>(); |
|
||||
|
|
||||
// 登录状态 |
|
||||
final isLoggedIn = false.obs; |
|
||||
final userInfo = Rxn<QQMusicLoginResult>(); |
|
||||
|
|
||||
// 搜索相关 |
|
||||
final searchResults = <SongModel>[].obs; |
|
||||
final isSearching = false.obs; |
|
||||
final searchKeyword = ''.obs; |
|
||||
|
|
||||
// 播放相关 |
|
||||
final currentSong = Rxn<SongModel>(); |
|
||||
final isPlaying = false.obs; |
|
||||
final currentPosition = 0.obs; |
|
||||
final totalDuration = 0.obs; |
|
||||
final playlist = <SongModel>[].obs; |
|
||||
final currentIndex = 0.obs; |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
_checkLoginStatus(); |
|
||||
_startPlaybackStateMonitor(); |
|
||||
} |
|
||||
|
|
||||
/// 检查登录状态 |
|
||||
void _checkLoginStatus() { |
|
||||
// 这里可以检查本地存储的登录信息 |
|
||||
// 暂时设为未登录状态 |
|
||||
isLoggedIn.value = false; |
|
||||
} |
|
||||
|
|
||||
/// QQ音乐登录 |
|
||||
Future<void> login() async { |
|
||||
try { |
|
||||
print('开始QQ音乐登录...'); |
|
||||
|
|
||||
Get.dialog( |
|
||||
const Center( |
|
||||
child: CircularProgressIndicator(), |
|
||||
), |
|
||||
barrierDismissible: false, |
|
||||
); |
|
||||
|
|
||||
// 添加超时处理 |
|
||||
final result = await _qqMusic.login().timeout( |
|
||||
const Duration(seconds: 30), |
|
||||
onTimeout: () { |
|
||||
print('QQ音乐登录超时'); |
|
||||
return {'success': false, 'error': '登录超时,请重试'}; |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
print('QQ音乐登录结果: $result'); |
|
||||
|
|
||||
Get.back(); // 关闭加载对话框 |
|
||||
|
|
||||
if (result['success'] == true) { |
|
||||
userInfo.value = QQMusicLoginResult.fromMap(result); |
|
||||
isLoggedIn.value = true; |
|
||||
print('QQ音乐登录成功: ${result['openId']}'); |
|
||||
Get.snackbar('登录成功', '欢迎使用QQ音乐'); |
|
||||
} else { |
|
||||
print('QQ音乐登录失败: ${result['error']}'); |
|
||||
Get.snackbar('登录失败', result['error'] ?? '未知错误'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('QQ音乐登录异常: $e'); |
|
||||
Get.back(); |
|
||||
Get.snackbar('登录失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 退出登录 |
|
||||
void logout() { |
|
||||
userInfo.value = null; |
|
||||
isLoggedIn.value = false; |
|
||||
currentSong.value = null; |
|
||||
isPlaying.value = false; |
|
||||
playlist.clear(); |
|
||||
Get.snackbar('已退出', '已退出QQ音乐登录'); |
|
||||
} |
|
||||
|
|
||||
/// 搜索歌曲 |
|
||||
Future<void> searchSongs(String keyword) async { |
|
||||
if (keyword.trim().isEmpty) return; |
|
||||
|
|
||||
print('开始搜索歌曲: $keyword'); |
|
||||
searchKeyword.value = keyword; |
|
||||
isSearching.value = true; |
|
||||
|
|
||||
try { |
|
||||
// 检查登录状态 |
|
||||
if (!isLoggedIn.value) { |
|
||||
print('用户未登录,无法搜索'); |
|
||||
Get.snackbar('搜索失败', '请先登录QQ音乐'); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
print('调用搜索接口...'); |
|
||||
final result = await _qqMusic.searchSongs(keyword: keyword).timeout( |
|
||||
const Duration(seconds: 15), |
|
||||
onTimeout: () { |
|
||||
print('搜索超时'); |
|
||||
return {'success': false, 'error': '搜索超时,请重试'}; |
|
||||
}, |
|
||||
); |
|
||||
|
|
||||
print('搜索结果: $result'); |
|
||||
|
|
||||
if (result['success'] == true && result['songs'] != null) { |
|
||||
final List<dynamic> songsData = result['songs']; |
|
||||
print('找到 ${songsData.length} 首歌曲'); |
|
||||
|
|
||||
final songs = songsData.map((songData) { |
|
||||
return SongModel( |
|
||||
mid: songData['mid'] ?? '', |
|
||||
name: songData['name'] ?? '', |
|
||||
artist: songData['singer'] ?? '', |
|
||||
album: songData['album'] ?? '', |
|
||||
albumPic: songData['pic'], |
|
||||
duration: songData['duration'] ?? 0, |
|
||||
); |
|
||||
}).toList(); |
|
||||
|
|
||||
searchResults.assignAll(songs); |
|
||||
print('搜索成功,更新UI'); |
|
||||
} else { |
|
||||
print('搜索失败: ${result['error']}'); |
|
||||
Get.snackbar('搜索失败', result['error'] ?? '搜索结果为空'); |
|
||||
searchResults.clear(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('搜索异常: $e'); |
|
||||
Get.snackbar('搜索失败', e.toString()); |
|
||||
searchResults.clear(); |
|
||||
} finally { |
|
||||
print('搜索完成,重置状态'); |
|
||||
isSearching.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 播放歌曲 |
|
||||
Future<void> playSong(SongModel song, {List<SongModel>? songList}) async { |
|
||||
try { |
|
||||
currentSong.value = song; |
|
||||
|
|
||||
if (songList != null) { |
|
||||
playlist.assignAll(songList); |
|
||||
currentIndex.value = songList.indexOf(song); |
|
||||
} else { |
|
||||
playlist.assignAll([song]); |
|
||||
currentIndex.value = 0; |
|
||||
} |
|
||||
|
|
||||
// 获取歌曲MID列表 |
|
||||
final songMids = playlist.map((s) => s.mid).toList(); |
|
||||
final playIndex = currentIndex.value; |
|
||||
|
|
||||
// 调用QQ音乐播放接口 |
|
||||
final result = await _qqMusic.playSongs( |
|
||||
songMids: songMids, |
|
||||
index: playIndex, |
|
||||
); |
|
||||
|
|
||||
if (result['success'] == true) { |
|
||||
isPlaying.value = true; |
|
||||
totalDuration.value = song.duration; |
|
||||
currentPosition.value = 0; |
|
||||
Get.snackbar('开始播放', song.name); |
|
||||
} else { |
|
||||
Get.snackbar('播放失败', result['error'] ?? '未知错误'); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Get.snackbar('播放失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 暂停/继续播放 |
|
||||
Future<void> togglePlayPause() async { |
|
||||
try { |
|
||||
if (isPlaying.value) { |
|
||||
final result = await _qqMusic.pauseMusic(); |
|
||||
if (result['success'] == true) { |
|
||||
isPlaying.value = false; |
|
||||
} |
|
||||
} else { |
|
||||
final result = await _qqMusic.resumeMusic(); |
|
||||
if (result['success'] == true) { |
|
||||
isPlaying.value = true; |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Get.snackbar('操作失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 上一首 |
|
||||
Future<void> previousSong() async { |
|
||||
try { |
|
||||
final result = await _qqMusic.skipToPrevious(); |
|
||||
if (result['success'] == true) { |
|
||||
if (playlist.isNotEmpty) { |
|
||||
int newIndex = currentIndex.value - 1; |
|
||||
if (newIndex < 0) newIndex = playlist.length - 1; |
|
||||
currentIndex.value = newIndex; |
|
||||
currentSong.value = playlist[newIndex]; |
|
||||
} |
|
||||
await _updateCurrentSong(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Get.snackbar('切换失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 下一首 |
|
||||
Future<void> nextSong() async { |
|
||||
try { |
|
||||
final result = await _qqMusic.skipToNext(); |
|
||||
if (result['success'] == true) { |
|
||||
if (playlist.isNotEmpty) { |
|
||||
int newIndex = currentIndex.value + 1; |
|
||||
if (newIndex >= playlist.length) newIndex = 0; |
|
||||
currentIndex.value = newIndex; |
|
||||
currentSong.value = playlist[newIndex]; |
|
||||
} |
|
||||
await _updateCurrentSong(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Get.snackbar('切换失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止播放 |
|
||||
Future<void> stopMusic() async { |
|
||||
try { |
|
||||
final result = await _qqMusic.stopMusic(); |
|
||||
if (result['success'] == true) { |
|
||||
isPlaying.value = false; |
|
||||
currentPosition.value = 0; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Get.snackbar('停止失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 更新当前播放歌曲信息 |
|
||||
Future<void> _updateCurrentSong() async { |
|
||||
try { |
|
||||
final result = await _qqMusic.getCurrentSong(); |
|
||||
if (result['success'] == true && result['song'] != null) { |
|
||||
final songData = result['song']; |
|
||||
final song = SongModel( |
|
||||
mid: songData['mid'] ?? '', |
|
||||
name: songData['name'] ?? '', |
|
||||
artist: songData['singer'] ?? '', |
|
||||
album: songData['album'] ?? '', |
|
||||
albumPic: songData['pic'], |
|
||||
duration: songData['duration'] ?? 0, |
|
||||
); |
|
||||
currentSong.value = song; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
print('更新当前歌曲信息失败: $e'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 启动播放状态监控 |
|
||||
void _startPlaybackStateMonitor() { |
|
||||
// 每秒检查一次播放状态和时间 |
|
||||
Stream.periodic(const Duration(seconds: 1)).listen((_) async { |
|
||||
if (isLoggedIn.value) { |
|
||||
await _updatePlaybackState(); |
|
||||
await _updatePlayTime(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 更新播放状态 |
|
||||
Future<void> _updatePlaybackState() async { |
|
||||
try { |
|
||||
final result = await _qqMusic.getPlaybackState(); |
|
||||
if (result['success'] == true && result['state'] != null) { |
|
||||
final state = result['state']; |
|
||||
// QQ音乐播放状态:0-停止,1-播放,2-暂停 |
|
||||
isPlaying.value = state == 1; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
// 静默处理错误,避免频繁弹窗 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 更新播放时间 |
|
||||
Future<void> _updatePlayTime() async { |
|
||||
try { |
|
||||
final result = await _qqMusic.getPlayTime(); |
|
||||
if (result['success'] == true) { |
|
||||
currentPosition.value = result['currentTime'] ?? 0; |
|
||||
totalDuration.value = result['totalTime'] ?? 0; |
|
||||
} |
|
||||
} catch (e) { |
|
||||
// 静默处理错误,避免频繁弹窗 |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 跳转到指定位置(暂时保留,QQ音乐可能不支持) |
|
||||
void seekTo(int position) { |
|
||||
currentPosition.value = position; |
|
||||
// QQ音乐SDK可能不支持跳转功能 |
|
||||
Get.snackbar('提示', '当前版本不支持跳转功能'); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// 清理资源 |
|
||||
super.onClose(); |
|
||||
} |
|
||||
} |
|
||||
@ -1,49 +0,0 @@ |
|||||
class SongModel { |
|
||||
final String mid; |
|
||||
final String name; |
|
||||
final String artist; |
|
||||
final String album; |
|
||||
final String? albumPic; |
|
||||
final int duration; |
|
||||
final bool isFavorite; |
|
||||
|
|
||||
SongModel({ |
|
||||
required this.mid, |
|
||||
required this.name, |
|
||||
required this.artist, |
|
||||
required this.album, |
|
||||
this.albumPic, |
|
||||
required this.duration, |
|
||||
this.isFavorite = false, |
|
||||
}); |
|
||||
|
|
||||
factory SongModel.fromJson(Map<String, dynamic> json) { |
|
||||
return SongModel( |
|
||||
mid: json['mid'] ?? '', |
|
||||
name: json['name'] ?? '', |
|
||||
artist: json['artist'] ?? '', |
|
||||
album: json['album'] ?? '', |
|
||||
albumPic: json['albumPic'], |
|
||||
duration: json['duration'] ?? 0, |
|
||||
isFavorite: json['isFavorite'] ?? false, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Map<String, dynamic> toJson() { |
|
||||
return { |
|
||||
'mid': mid, |
|
||||
'name': name, |
|
||||
'artist': artist, |
|
||||
'album': album, |
|
||||
'albumPic': albumPic, |
|
||||
'duration': duration, |
|
||||
'isFavorite': isFavorite, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
String get durationText { |
|
||||
final minutes = duration ~/ 60; |
|
||||
final seconds = duration % 60; |
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; |
|
||||
} |
|
||||
} |
|
||||
@ -1,242 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/qqmusic_controller.dart'; |
|
||||
import '../models/song_model.dart'; |
|
||||
|
|
||||
class QQMusicView extends GetView<QQMusicController> { |
|
||||
const QQMusicView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Scaffold( |
|
||||
appBar: AppBar( |
|
||||
title: const Text('QQ音乐'), |
|
||||
actions: [ |
|
||||
Obx(() => controller.isLoggedIn.value |
|
||||
? IconButton( |
|
||||
icon: const Icon(Icons.logout), |
|
||||
onPressed: controller.logout, |
|
||||
) |
|
||||
: IconButton( |
|
||||
icon: const Icon(Icons.login), |
|
||||
onPressed: controller.login, |
|
||||
)), |
|
||||
], |
|
||||
), |
|
||||
body: Obx(() { |
|
||||
if (!controller.isLoggedIn.value) { |
|
||||
return _buildLoginView(); |
|
||||
} |
|
||||
return _buildMainView(); |
|
||||
}), |
|
||||
bottomNavigationBar: Obx(() { |
|
||||
if (controller.currentSong.value != null) { |
|
||||
return _buildMiniPlayer(); |
|
||||
} |
|
||||
return const SizedBox.shrink(); |
|
||||
}), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildLoginView() { |
|
||||
return Center( |
|
||||
child: Column( |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: [ |
|
||||
const Icon( |
|
||||
Icons.music_note, |
|
||||
size: 100, |
|
||||
color: Colors.blue, |
|
||||
), |
|
||||
const SizedBox(height: 20), |
|
||||
const Text( |
|
||||
'请先登录QQ音乐', |
|
||||
style: TextStyle(fontSize: 18), |
|
||||
), |
|
||||
const SizedBox(height: 20), |
|
||||
ElevatedButton( |
|
||||
onPressed: controller.login, |
|
||||
child: const Text('登录QQ音乐'), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildMainView() { |
|
||||
return Column( |
|
||||
children: [ |
|
||||
_buildSearchBar(), |
|
||||
Expanded( |
|
||||
child: Obx(() { |
|
||||
if (controller.isSearching.value) { |
|
||||
return const Center(child: CircularProgressIndicator()); |
|
||||
} |
|
||||
if (controller.searchResults.isEmpty) { |
|
||||
return const Center( |
|
||||
child: Text('搜索歌曲开始您的音乐之旅'), |
|
||||
); |
|
||||
} |
|
||||
return _buildSearchResults(); |
|
||||
}), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildSearchBar() { |
|
||||
return Container( |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
child: TextField( |
|
||||
decoration: const InputDecoration( |
|
||||
hintText: '搜索歌曲、歌手、专辑', |
|
||||
prefixIcon: Icon(Icons.search), |
|
||||
border: OutlineInputBorder(), |
|
||||
), |
|
||||
onSubmitted: controller.searchSongs, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildSearchResults() { |
|
||||
return ListView.builder( |
|
||||
itemCount: controller.searchResults.length, |
|
||||
itemBuilder: (context, index) { |
|
||||
final song = controller.searchResults[index]; |
|
||||
return _buildSongItem(song); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildSongItem(SongModel song) { |
|
||||
return ListTile( |
|
||||
leading: Container( |
|
||||
width: 50, |
|
||||
height: 50, |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.grey[300], |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
), |
|
||||
child: song.albumPic != null |
|
||||
? ClipRRect( |
|
||||
borderRadius: BorderRadius.circular(8), |
|
||||
child: Image.network( |
|
||||
song.albumPic!, |
|
||||
fit: BoxFit.cover, |
|
||||
errorBuilder: (context, error, stackTrace) { |
|
||||
return const Icon(Icons.music_note); |
|
||||
}, |
|
||||
), |
|
||||
) |
|
||||
: const Icon(Icons.music_note), |
|
||||
), |
|
||||
title: Text( |
|
||||
song.name, |
|
||||
maxLines: 1, |
|
||||
overflow: TextOverflow.ellipsis, |
|
||||
), |
|
||||
subtitle: Text( |
|
||||
'${song.artist} • ${song.album}', |
|
||||
maxLines: 1, |
|
||||
overflow: TextOverflow.ellipsis, |
|
||||
), |
|
||||
trailing: Row( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
children: [ |
|
||||
Text(song.durationText), |
|
||||
const SizedBox(width: 8), |
|
||||
IconButton( |
|
||||
icon: const Icon(Icons.play_arrow), |
|
||||
onPressed: () => controller.playSong( |
|
||||
song, |
|
||||
songList: controller.searchResults, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildMiniPlayer() { |
|
||||
return Obx(() { |
|
||||
final song = controller.currentSong.value!; |
|
||||
return Container( |
|
||||
height: 80, |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.1), |
|
||||
blurRadius: 4, |
|
||||
offset: const Offset(0, -2), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
// 进度条 |
|
||||
Obx(() => LinearProgressIndicator( |
|
||||
value: controller.totalDuration.value > 0 |
|
||||
? controller.currentPosition.value / |
|
||||
controller.totalDuration.value |
|
||||
: 0, |
|
||||
backgroundColor: Colors.grey[300], |
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue), |
|
||||
)), |
|
||||
Expanded( |
|
||||
child: Padding( |
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
// 歌曲信息 |
|
||||
Expanded( |
|
||||
child: Column( |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
song.name, |
|
||||
maxLines: 1, |
|
||||
overflow: TextOverflow.ellipsis, |
|
||||
style: const TextStyle(fontWeight: FontWeight.bold), |
|
||||
), |
|
||||
Text( |
|
||||
song.artist, |
|
||||
maxLines: 1, |
|
||||
overflow: TextOverflow.ellipsis, |
|
||||
style: TextStyle(color: Colors.grey[600]), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
// 控制按钮 |
|
||||
Row( |
|
||||
children: [ |
|
||||
IconButton( |
|
||||
icon: const Icon(Icons.skip_previous), |
|
||||
onPressed: controller.previousSong, |
|
||||
), |
|
||||
Obx(() => IconButton( |
|
||||
icon: Icon( |
|
||||
controller.isPlaying.value |
|
||||
? Icons.pause |
|
||||
: Icons.play_arrow, |
|
||||
), |
|
||||
onPressed: controller.togglePlayPause, |
|
||||
)), |
|
||||
IconButton( |
|
||||
icon: const Icon(Icons.skip_next), |
|
||||
onPressed: controller.nextSong, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,307 @@ |
|||||
|
package com.yunqiinnovation.chat_api |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.content.Intent |
||||
|
import android.util.Log |
||||
|
import com.yunqiinnovation.music_service.MusicSingleton |
||||
|
import org.json.JSONObject |
||||
|
import kotlin.coroutines.resume |
||||
|
import kotlin.coroutines.suspendCoroutine |
||||
|
|
||||
|
/** |
||||
|
* QQ音乐系统功能处理器 |
||||
|
* 负责注册QQ音乐相关的MCP函数 |
||||
|
*/ |
||||
|
class MusicSystemFunctionHandler(private val context: Context? = null) { |
||||
|
companion object { |
||||
|
const val TAG = "MusicSystem" |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 注册所有QQ音乐相关函数到MCPClient |
||||
|
*/ |
||||
|
fun registerAllFunctions(client: MCPClient) { |
||||
|
try { |
||||
|
// Only register QQ Music search function |
||||
|
client.registerLocalFunction( |
||||
|
"music_searchandplay_songs", |
||||
|
"在音乐播放服务中搜索并播放歌曲,支持歌曲、专辑、歌手等搜索类型", |
||||
|
mapOf( |
||||
|
"type" to "object", |
||||
|
"properties" to mapOf( |
||||
|
"keyword" to mapOf( |
||||
|
"type" to "string", |
||||
|
"description" to "搜索关键词,歌曲名、专辑名或歌手名" |
||||
|
), |
||||
|
"searchType" to mapOf( |
||||
|
"type" to "integer", |
||||
|
"description" to "搜索类型:0-歌名搜索,1-歌手搜索,2-歌曲类型搜索", |
||||
|
"default" to 0 |
||||
|
) |
||||
|
), |
||||
|
"required" to listOf("keyword") |
||||
|
), |
||||
|
MusicSearchHandler(context) |
||||
|
) |
||||
|
|
||||
|
|
||||
|
// 注册媒体播放功能 |
||||
|
client.registerLocalFunction( |
||||
|
name = "media_play", |
||||
|
description = "播放媒体", |
||||
|
parameters = mapOf( |
||||
|
"type" to "object", |
||||
|
"properties" to emptyMap<String, Any>(), |
||||
|
"required" to emptyList<String>() |
||||
|
), |
||||
|
handler = MediaPlayHandler(context) |
||||
|
) |
||||
|
|
||||
|
// 注册媒体暂停功能 |
||||
|
client.registerLocalFunction( |
||||
|
name = "media_pause", |
||||
|
description = "暂停媒体播放", |
||||
|
parameters = mapOf( |
||||
|
"type" to "object", |
||||
|
"properties" to emptyMap<String, Any>(), |
||||
|
"required" to emptyList<String>() |
||||
|
), |
||||
|
handler = MediaPauseHandler(context) |
||||
|
) |
||||
|
// 注册媒体暂停功能 |
||||
|
client.registerLocalFunction( |
||||
|
name = "media_close", |
||||
|
description = "关闭媒体播放", |
||||
|
parameters = mapOf( |
||||
|
"type" to "object", |
||||
|
"properties" to emptyMap<String, Any>(), |
||||
|
"required" to emptyList<String>() |
||||
|
), |
||||
|
handler = MediaCloseHandler(context) |
||||
|
) |
||||
|
// 注册媒体上一首功能 |
||||
|
client.registerLocalFunction( |
||||
|
name = "media_previous", |
||||
|
description = "播放上一首", |
||||
|
parameters = mapOf( |
||||
|
"type" to "object", |
||||
|
"properties" to emptyMap<String, Any>(), |
||||
|
"required" to emptyList<String>() |
||||
|
), |
||||
|
handler = MediaPreviousHandler(context) |
||||
|
) |
||||
|
|
||||
|
// 注册媒体下一首功能 |
||||
|
client.registerLocalFunction( |
||||
|
name = "media_next", |
||||
|
description = "播放下一首", |
||||
|
parameters = mapOf( |
||||
|
"type" to "object", |
||||
|
"properties" to emptyMap<String, Any>(), |
||||
|
"required" to emptyList<String>() |
||||
|
), |
||||
|
handler = MediaNextHandler(context) |
||||
|
) |
||||
|
Log.d(TAG, "音乐所有功能注册完成") |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "注册音乐功能失败: ${e.message}", e) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private class MusicSearchHandler(private val context: Context?) : FunctionHandler { |
||||
|
override suspend fun handle(arguments: Map<String, Any>): String { |
||||
|
return try { |
||||
|
val keyword = arguments["keyword"] as? String |
||||
|
?: return "{\"success\": false, \"message\": \"缺少搜索关键词\"}" |
||||
|
val searchType = arguments["searchType"] as? Int ?: 0 // 默认搜索歌曲 |
||||
|
val musicSingleton = MusicSingleton.getInstance() |
||||
|
|
||||
|
// 使用 suspendCoroutine 将回调转换为协程 |
||||
|
suspendCoroutine { continuation -> |
||||
|
musicSingleton.searchSongs(keyword) { searchResult -> |
||||
|
val result = if (searchResult.isSuccess) { |
||||
|
val songs = searchResult.getOrNull() ?: emptyList() |
||||
|
|
||||
|
// 根据搜索类型处理结果 |
||||
|
val processedSongs = when (searchType) { |
||||
|
0 -> { // 歌曲搜索,返回所有结果 |
||||
|
songs |
||||
|
} |
||||
|
|
||||
|
else -> { // 专辑、歌手等其他类型,返回所有结果 |
||||
|
songs |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
val songsJson = processedSongs.joinToString(",") { song -> |
||||
|
val songMap = song as Map<String, Any?> |
||||
|
// 添加调试日志 |
||||
|
Log.d("QQMusicSearchHandler", "原始数据: $songMap") |
||||
|
// 修正字段名映射 |
||||
|
val id = songMap["id"] ?: 0 |
||||
|
val name = songMap["name"] ?: "unknown" |
||||
|
val singer = songMap["singer"] ?: "unknown" |
||||
|
val url = songMap["url"] ?: "" |
||||
|
val image = songMap["image"] ?: "" |
||||
|
val ts = songMap["ts"] ?: 0 |
||||
|
"{\"id\": $id, \"name\": \"$name\", \"singer\": \"$singer\", \"url\": \"$url\", \"ts\":$ts,\"image\": \"$image\"}" |
||||
|
} |
||||
|
"{\"success\": true, \"broadcast\": false, \"message\": \"搜索成功,正在播放\", \"data\": [$songsJson]}" |
||||
|
} else { |
||||
|
val error = searchResult.exceptionOrNull() |
||||
|
"{\"success\": false, \"message\": \"搜索失败: ${error?.message}\"}" |
||||
|
} |
||||
|
// 恢复协程执行 |
||||
|
continuation.resume(result) |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e("QQMusicSearchHandler", "QQ音乐搜索失败", e) |
||||
|
"{\"success\": false, \"message\": \"搜索异常: ${e.message}\"}" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/** |
||||
|
* 媒体播放处理器 |
||||
|
*/ |
||||
|
private class MediaPlayHandler(private val context: Context?) : FunctionHandler { |
||||
|
override suspend fun handle(arguments: Map<String, Any>): String { |
||||
|
if (context == null) { |
||||
|
return JSONObject().apply { |
||||
|
put("error", JSONObject().apply { |
||||
|
put("code", "context_error") |
||||
|
put("message", "上下文未初始化") |
||||
|
}) |
||||
|
}.toString() |
||||
|
} |
||||
|
|
||||
|
// 发送媒体播放广播 |
||||
|
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PLAY").apply { |
||||
|
setPackage(context.packageName) |
||||
|
} |
||||
|
context.sendBroadcast(intent) |
||||
|
|
||||
|
return JSONObject().apply { |
||||
|
put("result", "已开始播放媒体") |
||||
|
put("notreply", true) |
||||
|
put("broadcast", false) |
||||
|
}.toString() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 媒体暂停处理器 |
||||
|
*/ |
||||
|
private class MediaPauseHandler(private val context: Context?) : FunctionHandler { |
||||
|
override suspend fun handle(arguments: Map<String, Any>): String { |
||||
|
if (context == null) { |
||||
|
return JSONObject().apply { |
||||
|
put("error", JSONObject().apply { |
||||
|
put("code", "context_error") |
||||
|
put("message", "上下文未初始化") |
||||
|
}) |
||||
|
}.toString() |
||||
|
} |
||||
|
|
||||
|
Log.d("MediaPauseHandler", "📤 正在发送广播 context = $context") |
||||
|
// 发送媒体暂停广播 |
||||
|
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PAUSE").apply { |
||||
|
setPackage(context.packageName) |
||||
|
} |
||||
|
context.sendBroadcast(intent) |
||||
|
|
||||
|
return JSONObject().apply { |
||||
|
put("result", "已暂停媒体播放") |
||||
|
put("notreply", true) |
||||
|
put("broadcast", false) |
||||
|
}.toString() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 媒体关闭处理器 |
||||
|
*/ |
||||
|
private class MediaCloseHandler(private val context: Context?) : FunctionHandler { |
||||
|
override suspend fun handle(arguments: Map<String, Any>): String { |
||||
|
if (context == null) { |
||||
|
return JSONObject().apply { |
||||
|
put("error", JSONObject().apply { |
||||
|
put("code", "context_error") |
||||
|
put("message", "上下文未初始化") |
||||
|
}) |
||||
|
}.toString() |
||||
|
} |
||||
|
|
||||
|
Log.d("MediaCloseHandler", "📤 正在发送广播 context = $context") |
||||
|
// 发送媒体关闭广播 |
||||
|
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_CLOSE").apply { |
||||
|
setPackage(context.packageName) |
||||
|
} |
||||
|
context.sendBroadcast(intent) |
||||
|
|
||||
|
return JSONObject().apply { |
||||
|
put("result", "已关闭媒体播放") |
||||
|
put("notreply", true) |
||||
|
put("broadcast", false) |
||||
|
}.toString() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 媒体上一首处理器 |
||||
|
*/ |
||||
|
private class MediaPreviousHandler(private val context: Context?) : FunctionHandler { |
||||
|
override suspend fun handle(arguments: Map<String, Any>): String { |
||||
|
if (context == null) { |
||||
|
return JSONObject().apply { |
||||
|
put("error", JSONObject().apply { |
||||
|
put("code", "context_error") |
||||
|
put("message", "上下文未初始化") |
||||
|
}) |
||||
|
}.toString() |
||||
|
} |
||||
|
|
||||
|
// 发送切换上一首广播 |
||||
|
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PREVIOUS").apply { |
||||
|
setPackage(context.packageName) |
||||
|
} |
||||
|
context.sendBroadcast(intent) |
||||
|
|
||||
|
return JSONObject().apply { |
||||
|
put("result", "已切换到上一首") |
||||
|
put("notreply", true) |
||||
|
put("broadcast", false) |
||||
|
}.toString() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 媒体下一首处理器 |
||||
|
*/ |
||||
|
private class MediaNextHandler(private val context: Context?) : FunctionHandler { |
||||
|
override suspend fun handle(arguments: Map<String, Any>): String { |
||||
|
if (context == null) { |
||||
|
return JSONObject().apply { |
||||
|
put("error", JSONObject().apply { |
||||
|
put("code", "context_error") |
||||
|
put("message", "上下文未初始化") |
||||
|
}) |
||||
|
}.toString() |
||||
|
} |
||||
|
|
||||
|
// 发送切换下一首广播 |
||||
|
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_NEXT").apply { |
||||
|
setPackage(context.packageName) |
||||
|
} |
||||
|
context.sendBroadcast(intent) |
||||
|
|
||||
|
return JSONObject().apply { |
||||
|
put("result", "已切换到下一首") |
||||
|
put("notreply", true) |
||||
|
put("broadcast", false) |
||||
|
}.toString() |
||||
|
} |
||||
|
} |
||||
@ -1,55 +0,0 @@ |
|||||
package com.yunqiinnovation.music_service |
|
||||
|
|
||||
import android.content.Context |
|
||||
import android.content.Intent |
|
||||
import android.os.Build |
|
||||
import android.util.Log |
|
||||
import androidx.core.content.ContextCompat |
|
||||
import java.util.jar.Manifest |
|
||||
|
|
||||
//@Parcelize |
|
||||
//data class Song( |
|
||||
// val url: String, |
|
||||
// val title: String, |
|
||||
// val artist: String, |
|
||||
// val coverUrl: String? |
|
||||
//) |
|
||||
|
|
||||
object MusicServiceStarter { |
|
||||
private val TAG = "MusicServiceStarter" |
|
||||
//播放单曲 |
|
||||
fun startServiceWithCommand(context: Context, command: String, song: Map<String,Any>? = 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) |
|
||||
song?.let { |
|
||||
putExtra("song", HashMap(it)) // 只有非 null 时才 putExtra |
|
||||
} |
|
||||
volume?.let { putExtra("volume", it) } |
|
||||
position?.let { putExtra("position", it) } |
|
||||
} |
|
||||
val success = try { |
|
||||
ContextCompat.startForegroundService(context, intent) |
|
||||
true |
|
||||
} catch (e: Exception) { |
|
||||
Log.e("MusicServiceStarter", "liwei------------------- Failed to start service", e) |
|
||||
false |
|
||||
} |
|
||||
Log.d(" MusicServiceStarter", "Service start success: $success") |
|
||||
} |
|
||||
|
|
||||
//启动播放列表 |
|
||||
fun startServiceWithPlaylist(context: Context,command: String, songs: List<Map<String,Any>>,volume: Float? = null) { |
|
||||
Log.i(TAG, "liwei------------------- startServiceWithPlaylist: $command $songs") |
|
||||
val serializableList = ArrayList<HashMap<String, Any>>().apply { |
|
||||
songs.forEach { add(HashMap(it)) } |
|
||||
} |
|
||||
val intent = Intent(context, MusicService::class.java).apply { |
|
||||
putExtra("command", command) |
|
||||
putExtra("songs", serializableList) // ✅ Serializable |
|
||||
volume?.let { putExtra("volume", it) } |
|
||||
} |
|
||||
ContextCompat.startForegroundService(context, intent) |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,202 @@ |
|||||
|
package com.yunqiinnovation.music_service |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.content.Intent |
||||
|
import android.util.Log |
||||
|
import androidx.core.content.ContextCompat |
||||
|
import kotlinx.coroutines.launch |
||||
|
import kotlinx.coroutines.CoroutineScope |
||||
|
import kotlinx.coroutines.Dispatchers |
||||
|
import kotlinx.coroutines.SupervisorJob |
||||
|
import okhttp3.Call |
||||
|
import okhttp3.Callback |
||||
|
import okhttp3.MediaType.Companion.toMediaType |
||||
|
import okhttp3.OkHttpClient |
||||
|
import okhttp3.Request |
||||
|
import okhttp3.RequestBody.Companion.toRequestBody |
||||
|
import okhttp3.Response |
||||
|
import org.json.JSONArray |
||||
|
import org.json.JSONObject |
||||
|
import java.io.IOException |
||||
|
import java.util.concurrent.TimeUnit |
||||
|
|
||||
|
|
||||
|
class MusicSingleton private constructor() { |
||||
|
companion object { |
||||
|
private val TAG = "MusicServiceStarter" |
||||
|
@Volatile |
||||
|
private var INSTANCE: MusicSingleton? = null |
||||
|
|
||||
|
fun getInstance(): MusicSingleton { |
||||
|
return INSTANCE ?: synchronized(this) { |
||||
|
INSTANCE ?: MusicSingleton().also { INSTANCE = it } |
||||
|
} |
||||
|
} |
||||
|
//播放单曲 |
||||
|
fun startServiceWithCommand( |
||||
|
context: Context, |
||||
|
command: String, |
||||
|
song: Map<String, Any>? = 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) |
||||
|
song?.let { |
||||
|
putExtra("song", HashMap(it)) // 只有非 null 时才 putExtra |
||||
|
} |
||||
|
volume?.let { putExtra("volume", it) } |
||||
|
position?.let { putExtra("position", it) } |
||||
|
} |
||||
|
val success = try { |
||||
|
ContextCompat.startForegroundService(context, intent) |
||||
|
true |
||||
|
} catch (e: Exception) { |
||||
|
Log.e("MusicServiceStarter", "liwei------------------- Failed to start service", e) |
||||
|
false |
||||
|
} |
||||
|
Log.d(" MusicServiceStarter", "Service start success: $success") |
||||
|
} |
||||
|
|
||||
|
//启动播放列表 |
||||
|
fun startServiceWithPlaylist( |
||||
|
context: Context, |
||||
|
command: String, |
||||
|
songs: List<Map<String, Any>>, |
||||
|
volume: Float? = null |
||||
|
) { |
||||
|
Log.i(TAG, "liwei------------------- startServiceWithPlaylist: $command $songs") |
||||
|
val serializableList = ArrayList<HashMap<String, Any>>().apply { |
||||
|
songs.forEach { add(HashMap(it)) } |
||||
|
} |
||||
|
val intent = Intent(context, MusicService::class.java).apply { |
||||
|
putExtra("command", command) |
||||
|
putExtra("songs", serializableList) // ✅ Serializable |
||||
|
volume?.let { putExtra("volume", it) } |
||||
|
} |
||||
|
ContextCompat.startForegroundService(context, intent) |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
private var url = "" |
||||
|
private var token = "" |
||||
|
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) |
||||
|
/** |
||||
|
* 初始化单例,设置Activity上下文 |
||||
|
*/ |
||||
|
fun initialize(context: Context,serviceUrl:String,usertoken:String) { |
||||
|
url = serviceUrl; |
||||
|
token = usertoken; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 搜索歌曲(带自动登录检查和重试机制) |
||||
|
*/ |
||||
|
fun searchSongs( |
||||
|
keyword: String, |
||||
|
searchType: Int = 0, |
||||
|
callback: (Result<List<Map<String, Any?>>>) -> Unit |
||||
|
) { |
||||
|
// 使用协程异步处理,避免阻塞主线程 |
||||
|
coroutineScope.launch { |
||||
|
try { |
||||
|
// 配置带超时的 OkHttpClient |
||||
|
val client = OkHttpClient.Builder() |
||||
|
.connectTimeout(10, TimeUnit.SECONDS) |
||||
|
.writeTimeout(10, TimeUnit.SECONDS) |
||||
|
.readTimeout(30, TimeUnit.SECONDS) |
||||
|
.build() |
||||
|
|
||||
|
// 构建搜索请求的 JSON 数据 |
||||
|
val jsonObject = JSONObject().apply { |
||||
|
put("keywords", keyword) |
||||
|
put("searchtype", searchType) |
||||
|
put("limit", 20) // 默认返回20首歌曲 |
||||
|
put("offset", 0) // 默认返回20首歌曲 |
||||
|
} |
||||
|
|
||||
|
val json = jsonObject.toString() |
||||
|
val mediaType = "application/json".toMediaType() |
||||
|
val body = json.toRequestBody(mediaType) |
||||
|
|
||||
|
// 构建请求 |
||||
|
val request = Request.Builder() |
||||
|
.url("$url/api/home/music_search") |
||||
|
.addHeader("Authorization", token) |
||||
|
.addHeader("Content-Type", "application/json") |
||||
|
.post(body) |
||||
|
.build() |
||||
|
|
||||
|
Log.d(TAG, "发送搜索请求: $json") |
||||
|
|
||||
|
// 使用异步调用 |
||||
|
client.newCall(request).enqueue(object : Callback { |
||||
|
override fun onFailure(call: Call, e: IOException) { |
||||
|
Log.e(TAG, "搜索歌曲请求失败: ${e.message}", e) |
||||
|
callback(Result.failure(e)) |
||||
|
} |
||||
|
|
||||
|
override fun onResponse(call: Call, response: Response) { |
||||
|
response.use { |
||||
|
try { |
||||
|
if (response.isSuccessful) { |
||||
|
val responseBody = response.body?.string() |
||||
|
Log.d(TAG, "搜索响应: $responseBody") |
||||
|
|
||||
|
if (responseBody != null) { |
||||
|
val jsonResponse = JSONObject(responseBody) |
||||
|
val songs = parseSongsFromResponse(jsonResponse) |
||||
|
callback(Result.success(songs)) |
||||
|
} else { |
||||
|
callback(Result.failure(Exception("响应体为空"))) |
||||
|
} |
||||
|
} else { |
||||
|
val errorMsg = "搜索失败,HTTP状态码: ${response.code}" |
||||
|
Log.w(TAG, errorMsg) |
||||
|
callback(Result.failure(Exception(errorMsg))) |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "解析搜索响应时发生异常: ${e.message}", e) |
||||
|
callback(Result.failure(e)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "搜索歌曲时发生异常: ${e.message}", e) |
||||
|
callback(Result.failure(e)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析服务器响应中的歌曲列表 |
||||
|
*/ |
||||
|
private fun parseSongsFromResponse(jsonResponse: JSONObject): List<Map<String, Any?>> { |
||||
|
val songs = mutableListOf<Map<String, Any?>>() |
||||
|
try { |
||||
|
val data = jsonResponse.optJSONObject("data") |
||||
|
val songArray = data?.optJSONArray("musics") |
||||
|
|
||||
|
songArray?.let { array -> |
||||
|
for (i in 0 until array.length()) { |
||||
|
val songObj = array.getJSONObject(i) |
||||
|
songs.add(mapOf( |
||||
|
"id" to songObj.optLong("id"), |
||||
|
"name" to songObj.optString("name"), |
||||
|
"singer" to songObj.optString("singer"), |
||||
|
"url" to songObj.optString("url"), |
||||
|
"image" to songObj.optString("image"), |
||||
|
"ts" to songObj.optLong("ts"), |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
Log.e(TAG, "Failed to parse songs from response", e) |
||||
|
} |
||||
|
return songs |
||||
|
} |
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue