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