@ -0,0 +1,157 @@ |
|||
import 'package:get/get.dart'; |
|||
import 'package:qq_music/qq_music.dart'; |
|||
|
|||
/// QQ音乐服务适配器 |
|||
class QqMusocService extends GetxService { |
|||
static QqMusocService get to => Get.find<QqMusocService>(); |
|||
|
|||
var isInitialized = false; |
|||
// 音乐服务实例 |
|||
final QqMusic _qqMusic = QqMusic(); |
|||
|
|||
/// 初始化 |
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
} |
|||
|
|||
/// 登录QQ音乐 |
|||
Future<Map<String, dynamic>> login() async { |
|||
try { |
|||
// 直接调用QQ音乐插件的登录方法 |
|||
final result = await _qqMusic.login(); |
|||
|
|||
// 检查登录结果 |
|||
if (result['success'] == true) { |
|||
isInitialized = true; |
|||
return result; |
|||
} else { |
|||
return {'success': false, 'error': result['error'] ?? '登录失败'}; |
|||
} |
|||
} catch (e) { |
|||
return {'success': false, 'error': '登录异常: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 搜索歌曲 |
|||
Future<Map<String, dynamic>> searchSongs({ |
|||
required String keyword, |
|||
int searchType = 0, // SEARCH_TYPE_SONG |
|||
}) async { |
|||
try { |
|||
final result = await _qqMusic.searchSongs( |
|||
keyword: keyword, |
|||
searchType: searchType, |
|||
); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '搜索失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 播放歌曲列表 |
|||
Future<Map<String, dynamic>> playSongs({ |
|||
required List<String> songMids, |
|||
int index = 0, |
|||
}) async { |
|||
try { |
|||
final result = await _qqMusic.playSongs( |
|||
songMids: songMids, |
|||
index: index, |
|||
); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '播放失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 播放音乐 |
|||
Future<Map<String, dynamic>> playMusic() async { |
|||
try { |
|||
final result = await _qqMusic.playMusic(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '播放失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 暂停音乐 |
|||
Future<Map<String, dynamic>> pauseMusic() async { |
|||
try { |
|||
final result = await _qqMusic.pauseMusic(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '暂停失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 继续播放 |
|||
Future<Map<String, dynamic>> resumeMusic() async { |
|||
try { |
|||
final result = await _qqMusic.resumeMusic(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '继续播放失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 停止音乐 |
|||
Future<Map<String, dynamic>> stopMusic() async { |
|||
try { |
|||
final result = await _qqMusic.stopMusic(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '停止失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 下一首 |
|||
Future<Map<String, dynamic>> skipToNext() async { |
|||
try { |
|||
final result = await _qqMusic.skipToNext(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '下一首失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 上一首 |
|||
Future<Map<String, dynamic>> skipToPrevious() async { |
|||
try { |
|||
final result = await _qqMusic.skipToPrevious(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '上一首失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 获取播放状态 |
|||
Future<Map<String, dynamic>> getPlaybackState() async { |
|||
try { |
|||
final result = await _qqMusic.getPlaybackState(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '获取播放状态失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 获取当前播放歌曲 |
|||
Future<Map<String, dynamic>> getCurrentSong() async { |
|||
try { |
|||
final result = await _qqMusic.getCurrentSong(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '获取当前歌曲失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
|
|||
/// 获取播放时间 |
|||
Future<Map<String, dynamic>> getPlayTime() async { |
|||
try { |
|||
final result = await _qqMusic.getPlayTime(); |
|||
return result; |
|||
} catch (e) { |
|||
return {'success': false, 'error': '获取播放时间失败: ${e.toString()}'}; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/qqmusic_controller.dart'; |
|||
|
|||
class QQMusicBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<QQMusicController>(() => QQMusicController()); |
|||
} |
|||
} |
|||
@ -0,0 +1,326 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:qq_music/qq_music.dart'; |
|||
import 'package:voitrans/data/services/qq_musoc.dart'; |
|||
import '../models/song_model.dart'; |
|||
|
|||
class QQMusicController extends GetxController { |
|||
final QqMusocService _qqMusic = Get.find<QqMusocService>(); |
|||
|
|||
// 登录状态 |
|||
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(); |
|||
} |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
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')}'; |
|||
} |
|||
} |
|||
@ -0,0 +1,242 @@ |
|||
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,33 @@ |
|||
# Miscellaneous |
|||
*.class |
|||
*.log |
|||
*.pyc |
|||
*.swp |
|||
.DS_Store |
|||
.atom/ |
|||
.build/ |
|||
.buildlog/ |
|||
.history |
|||
.svn/ |
|||
.swiftpm/ |
|||
migrate_working_dir/ |
|||
|
|||
# IntelliJ related |
|||
*.iml |
|||
*.ipr |
|||
*.iws |
|||
.idea/ |
|||
|
|||
# The .vscode folder contains launch configuration and tasks you configure in |
|||
# VS Code which you may wish to be included in version control, so this line |
|||
# is commented out by default. |
|||
#.vscode/ |
|||
|
|||
# Flutter/Dart/Pub related |
|||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. |
|||
/pubspec.lock |
|||
**/doc/api/ |
|||
.dart_tool/ |
|||
.flutter-plugins |
|||
.flutter-plugins-dependencies |
|||
build/ |
|||
@ -0,0 +1,33 @@ |
|||
# This file tracks properties of this Flutter project. |
|||
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
|||
# |
|||
# This file should be version controlled and should not be manually edited. |
|||
|
|||
version: |
|||
revision: "5c1433509f997e9059d230942bdf83f1b88a179e" |
|||
channel: "stable" |
|||
|
|||
project_type: plugin |
|||
|
|||
# Tracks metadata for the flutter migrate command |
|||
migration: |
|||
platforms: |
|||
- platform: root |
|||
create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
|||
base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
|||
- platform: android |
|||
create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
|||
base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
|||
- platform: ios |
|||
create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
|||
base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
|||
|
|||
# User provided section |
|||
|
|||
# List of Local paths (relative to this file) that should be |
|||
# ignored by the migrate tool. |
|||
# |
|||
# Files that are not part of the templates will be ignored by default. |
|||
unmanaged_files: |
|||
- 'lib/main.dart' |
|||
- 'ios/Runner.xcodeproj/project.pbxproj' |
|||
@ -0,0 +1,3 @@ |
|||
## 0.0.1 |
|||
|
|||
* TODO: Describe initial release. |
|||
@ -0,0 +1 @@ |
|||
TODO: Add your license here. |
|||
@ -0,0 +1,15 @@ |
|||
# qq_music |
|||
|
|||
A new Flutter project. |
|||
|
|||
## Getting Started |
|||
|
|||
This project is a starting point for a Flutter |
|||
[plug-in package](https://flutter.dev/to/develop-plugins), |
|||
a specialized package that includes platform-specific implementation code for |
|||
Android and/or iOS. |
|||
|
|||
For help getting started with Flutter development, view the |
|||
[online documentation](https://docs.flutter.dev), which offers tutorials, |
|||
samples, guidance on mobile development, and a full API reference. |
|||
|
|||
@ -0,0 +1,4 @@ |
|||
include: package:flutter_lints/flutter.yaml |
|||
|
|||
# Additional information about this file can be found at |
|||
# https://dart.dev/guides/language/analysis-options |
|||
@ -0,0 +1,9 @@ |
|||
*.iml |
|||
.gradle |
|||
/local.properties |
|||
/.idea/workspace.xml |
|||
/.idea/libraries |
|||
.DS_Store |
|||
/build |
|||
/captures |
|||
.cxx |
|||
@ -0,0 +1,79 @@ |
|||
group = "com.yunqiinnovation.qq_music" |
|||
version = "1.0-SNAPSHOT" |
|||
|
|||
buildscript { |
|||
ext.kotlin_version = "2.1.0" |
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
} |
|||
|
|||
dependencies { |
|||
classpath("com.android.tools.build:gradle:8.7.3") |
|||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") |
|||
} |
|||
} |
|||
|
|||
allprojects { |
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
// 添加本地AAR仓库 |
|||
flatDir { |
|||
dirs 'libs' |
|||
} |
|||
} |
|||
} |
|||
|
|||
apply plugin: "com.android.library" |
|||
apply plugin: "kotlin-android" |
|||
|
|||
android { |
|||
namespace = "com.yunqiinnovation.qq_music" |
|||
|
|||
compileSdk = 35 |
|||
|
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_11 |
|||
targetCompatibility = JavaVersion.VERSION_11 |
|||
} |
|||
|
|||
kotlinOptions { |
|||
jvmTarget = JavaVersion.VERSION_11 |
|||
} |
|||
|
|||
// 合并后的 sourceSets 配置 |
|||
sourceSets { |
|||
main { |
|||
java.srcDirs += "src/main/kotlin" |
|||
aidl.srcDirs = ['src/main/aidl'] |
|||
} |
|||
test { |
|||
java.srcDirs += "src/test/kotlin" |
|||
} |
|||
} |
|||
|
|||
defaultConfig { |
|||
minSdk = 21 |
|||
} |
|||
|
|||
buildFeatures { |
|||
aidl true |
|||
} |
|||
|
|||
dependencies { |
|||
testImplementation("org.jetbrains.kotlin:kotlin-test") |
|||
testImplementation("org.mockito:mockito-core:5.0.0") |
|||
|
|||
// 添加AndroidX AppCompat依赖 |
|||
implementation 'androidx.appcompat:appcompat:1.6.1' |
|||
|
|||
// 添加 Gson 依赖 |
|||
implementation 'com.google.code.gson:gson:2.10.1' |
|||
|
|||
// 添加 ConstraintLayout 依赖 |
|||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' |
|||
|
|||
api(name: 'qqmusic-innovation-aidl-api-sdk-1.0.26', ext: 'aar') |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
rootProject.name = 'qq_music' |
|||
@ -0,0 +1,9 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.qq_music"> |
|||
|
|||
<uses-permission android:name="android.permission.INTERNET" /> |
|||
|
|||
<application> |
|||
<!-- 移除了 LoginExampleActivity 的声明,因为该类已被删除 --> |
|||
</application> |
|||
</manifest> |
|||
@ -0,0 +1,15 @@ |
|||
// IPlayService.aidl |
|||
package com.yunqiinnovation.qq_music; |
|||
|
|||
// Declare any non-default types here with import statements |
|||
|
|||
import com.yunqiinnovation.qq_music.IPrint; |
|||
|
|||
interface IPlayService { |
|||
void bindService(); |
|||
void startPcmMode(); |
|||
void stopPcmMode(); |
|||
void resumeOrPause(); |
|||
void playNext(); |
|||
void setPrintMessageCallback(IPrint callback); |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
// IPrintMessage.aidl |
|||
package com.yunqiinnovation.qq_music; |
|||
|
|||
// Declare any non-default types here with import statements |
|||
|
|||
interface IPrint { |
|||
void print(String msg); |
|||
} |
|||
@ -0,0 +1,361 @@ |
|||
package com.tencent.qqmusic.api.demo |
|||
|
|||
import android.os.Bundle |
|||
import androidx.annotation.Nullable |
|||
import android.util.Log |
|||
import com.google.gson.Gson |
|||
import com.tencent.qqmusic.third.api.contract.* |
|||
import com.tencent.qqmusic.third.api.contract.ErrorCodes.ERROR_API_NOT_INITIALIZED |
|||
|
|||
|
|||
// |
|||
// Created by tylertan on 2020-03-19. |
|||
// Copyright (c) 2020 Tencent. All rights reserved. |
|||
// |
|||
|
|||
/** |
|||
* 对[Methods]中的接口进行基础封装 |
|||
* |
|||
*/ |
|||
object ApiSample { |
|||
|
|||
const val TAG = "ApiSample" |
|||
|
|||
/** |
|||
* 获取接口返回错误码,对应[ErrorCodes]中的错误码列表 |
|||
* |
|||
* @return 返回[ErrorCodes.ERROR_OK]代表成功,反之为失败 |
|||
*/ |
|||
private fun apiCode(bundle: Bundle?): Int? { |
|||
return bundle?.getInt(Keys.API_RETURN_KEY_CODE) |
|||
} |
|||
|
|||
/** |
|||
* 获取接口返回错误详细信息 |
|||
* |
|||
*/ |
|||
private fun apiError(bundle: Bundle?): String? { |
|||
return bundle?.getString(Keys.API_RETURN_KEY_ERROR) |
|||
} |
|||
|
|||
fun hi(api: IQQMusicApi?) { |
|||
val bundle = Bundle() |
|||
bundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) |
|||
bundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, CommonCmd.AIDL_PLATFORM_TYPE_PHONE) |
|||
val result = api?.execute("hi", bundle) |
|||
Log.i(TAG, "sayHi ret:" + result!!.getInt(Keys.API_RETURN_KEY_CODE)) |
|||
} |
|||
|
|||
fun playSongMid(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("midList", ids) |
|||
api?.executeAsync("playSongMid", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun playSongMidAtIndex(api: IQQMusicApi?, songList: List<Data.Song>, index: Int, block: (() -> Unit)) { |
|||
val midList = ArrayList<String>() |
|||
for (i in songList.indices) { |
|||
midList.add(songList[i].mid) |
|||
} |
|||
|
|||
val params = Bundle() |
|||
params.putStringArrayList("midList", midList) |
|||
params.putInt("index", index) |
|||
Log.i(TAG, "[playSongMidAtIndex] 播放歌曲列表... $index,${songList.size}") |
|||
api?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun playSongLocalPath(api: IQQMusicApi?, pathList: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("pathList", pathList) |
|||
api?.executeAsync("playSongLocalPath", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun playMusic(api: IQQMusicApi?): Int { |
|||
val ret = api?.execute("playMusic", null) |
|||
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
} |
|||
|
|||
fun stopMusic(api: IQQMusicApi?): Int { |
|||
val ret = api?.execute("stopMusic", null) |
|||
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
} |
|||
|
|||
fun pauseMusic(api: IQQMusicApi?): Int { |
|||
val ret = api?.execute("pauseMusic", null) |
|||
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
} |
|||
|
|||
fun resumeMusic(api: IQQMusicApi?): Int { |
|||
val ret = api?.execute("resumeMusic", null) |
|||
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
} |
|||
|
|||
fun skipToNext(api: IQQMusicApi?): Int { |
|||
val ret = api?.execute("skipToNext", null) |
|||
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
} |
|||
|
|||
fun skipToPrevious(api: IQQMusicApi?): Int { |
|||
val ret = api?.execute("skipToPrevious", null) |
|||
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
} |
|||
|
|||
fun getPlaybackState(api: IQQMusicApi?): Int { |
|||
val ret = api?.execute("getPlaybackState", null) |
|||
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
} |
|||
|
|||
@Nullable |
|||
fun getCurrentSong(api: IQQMusicApi?, gson: Gson): Data.Song? { |
|||
val ret = api?.execute("getCurrentSong", null) |
|||
val curSongJson = ret?.getString(Keys.API_RETURN_KEY_DATA) |
|||
return gson.fromJson(curSongJson, Data.Song::class.java) |
|||
} |
|||
|
|||
fun addToFavourite(api: IQQMusicApi?, midList: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("midList", midList) |
|||
api?.executeAsync("addToFavourite", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun removeFromFavourite(api: IQQMusicApi?, midList: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("midList", midList) |
|||
api?.executeAsync("removeFromFavourite", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun addLocalPathToFavourite(api: IQQMusicApi?, localPathList: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("localPathList", localPathList) |
|||
api?.executeAsync("addLocalPathToFavourite", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun removeLocalPathFromFavourite(api: IQQMusicApi?, localPathList: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("localPathList", localPathList) |
|||
api?.executeAsync("removeLocalPathFromFavourite", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun isFavouriteMid(api: IQQMusicApi?, midList: ArrayList<String>, block: ((ret: BooleanArray?) -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("midList", midList) |
|||
api?.executeAsync("isFavouriteMid", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) |
|||
block(ret) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun isFavouriteLocalPath(api: IQQMusicApi?, localPathList: ArrayList<String>, block: ((ret: BooleanArray?) -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("localPathList", localPathList) |
|||
api?.executeAsync("isFavouriteLocalPath", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) |
|||
block(ret) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun registerEventListener(api: IQQMusicApi?, events: ArrayList<String>, block: (() -> Unit)) { |
|||
api?.registerEventListener(events, object : IQQMusicApiEventListener.Stub() { |
|||
override fun onEvent(event: String, extra: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun unregisterEventListener(api: IQQMusicApi?, events: ArrayList<String>, block: (() -> Unit)) { |
|||
api?.unregisterEventListener(events, object : IQQMusicApiEventListener.Stub() { |
|||
override fun onEvent(event: String, extra: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun playFromChorus(api: IQQMusicApi?, fromChorus: Boolean) { |
|||
val params = Bundle() |
|||
params.putBoolean("fromChorus", fromChorus) |
|||
api?.execute("playFromChorus", params) |
|||
} |
|||
|
|||
@Deprecated("Use playSongMid instead.") |
|||
fun playSongId(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("songIdList", ids) |
|||
api?.executeAsync("playSongId", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
@Deprecated("Use playSongMidAtIndex instead.") |
|||
fun playSongIdAtIndex(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("songIdList", ids) |
|||
api?.executeAsync("playSongIdAtIndex", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun getTotalTime(api: IQQMusicApi?): Long { |
|||
val result = api?.execute("getTotalTime", null) |
|||
return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 |
|||
} |
|||
|
|||
fun getCurrTime(api: IQQMusicApi?): Long { |
|||
val result = api?.execute("getCurrTime", null) |
|||
return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 |
|||
} |
|||
|
|||
fun getPlayList(api: IQQMusicApi?, ids: ArrayList<String>, block: ((json: String?) -> Unit)) { |
|||
val params = Bundle() |
|||
params.putStringArrayList("songIdList", ids) |
|||
api?.executeAsync("getPlayList", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
val json = result.getString(Keys.API_RETURN_KEY_DATA) |
|||
block(json) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun getFolderList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
|||
val params = Bundle().apply { |
|||
putString("folderId", folderId) |
|||
putInt("folderType", folderType) |
|||
putInt("page", page) |
|||
} |
|||
|
|||
api?.executeAsync("getFolderList", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(p0: Bundle?) { |
|||
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
|||
block(json) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun getSongList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
|||
val params = Bundle().apply { |
|||
putString("folderId", folderId) |
|||
putInt("folderType", folderType) |
|||
putInt("page", page) |
|||
} |
|||
|
|||
api?.executeAsync("getSongList", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(p0: Bundle?) { |
|||
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
|||
block(json) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun getUserFolderList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
|||
val params = Bundle().apply { |
|||
putString("openId", openId) |
|||
putString("openToken", openToken) |
|||
putString("folderId", folderId) |
|||
putInt("folderType", folderType) |
|||
putInt("page", page) |
|||
} |
|||
|
|||
api?.executeAsync("getUserFolderList", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(p0: Bundle?) { |
|||
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
|||
block(json) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun getUserSongList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
|||
val params = Bundle().apply { |
|||
putString("openId", openId) |
|||
putString("openToken", openToken) |
|||
putString("folderId", folderId) |
|||
putInt("folderType", folderType) |
|||
putInt("page", page) |
|||
} |
|||
|
|||
api?.executeAsync("getUserSongList", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(p0: Bundle?) { |
|||
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
|||
block(json) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun search(api: IQQMusicApi?, block: () -> Unit) { |
|||
val params = Bundle().apply { |
|||
putString("keyword", "周杰伦") |
|||
putInt("searchType", 0) |
|||
putBoolean("firstPage", true) |
|||
} |
|||
|
|||
api?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(p0: Bundle?) { |
|||
block() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun voiceShortcut(api: IQQMusicApi?, intent: String, block: (code: Int?) -> Unit) { |
|||
val bundle = Bundle() |
|||
bundle.apply { |
|||
putString("intent", intent) |
|||
} |
|||
api?.executeAsync("voiceShortcut", bundle, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(p0: Bundle?) { |
|||
val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED |
|||
block(code) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
fun voicePlay(api: IQQMusicApi?, query: String, slotList: ArrayList<String>, block: (code: Int, json: String?) -> Unit) { |
|||
val bundle = Bundle() |
|||
bundle.apply { |
|||
putString("query", query) |
|||
putStringArrayList("slotList", slotList) |
|||
} |
|||
api?.executeAsync("voicePlay", bundle, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(p0: Bundle?) { |
|||
val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED |
|||
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
|||
block(code, json) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
package com.yunqiinnovation.qq_music; |
|||
|
|||
import com.tencent.qqmusic.third.api.contract.CommonCmd; |
|||
|
|||
public class Config { |
|||
|
|||
/** |
|||
* 需向QQ音乐发邮件申请,具体参考README |
|||
*/ |
|||
public static final String OPENID_APPID = "2000000921"; |
|||
|
|||
/** |
|||
* 配置接入方自己生成的RSA私钥,由于是Demo,所以直接写在代码中了,真实环境中需要注意私钥泄漏的风险。 |
|||
*/ |
|||
public static final String OPENID_APP_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" + |
|||
"MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALWuTayxg0296yfm\n" + |
|||
"h8WhunybY1BEFt9fB4qb3TGvB0vkd+oKDoBdiTevtgwSL47h4O0ISNTQqqmVOvk1\n" + |
|||
"EQMGcLESBEdHquPX2uOplwmmVWx1un9NYB4JKaHmUkCXOIoiCgmEY052zFY2T+a0\n" + |
|||
"RfFiaUTc0wRMynquE8Qp18i23IUBAgMBAAECgYBf/s1D5jd+qE7MMl6i6iVjGaeM\n" + |
|||
"Da4YMFoHvvtM+TJdw0Yo/09SPBn4tnr2PdWrJWZ3V91DtU23dQZIDuYCNzDrOkVr\n" + |
|||
"93W5Pkqprdbde6qRhWm1VTZVN7h85DStGBzovSxQ6DQNHZFzIriJkfGDjPopmIil\n" + |
|||
"awQvMPlS3lLYkzHwAQJBAOWBlshEMHXeqXekjJExwX7YSy2oabWXtllCFbcPprAY\n" + |
|||
"MiM8cdNsEhnhrsmdBN4crqTQKJq/ATaYFhzASuIiZ2ECQQDKp17+A3u6q7RZ3Vmb\n" + |
|||
"khvAATQpVK5loCOzuyPU7/UGU49AO1DIaPwDvaUqL6PuF9eFrcRHA5AFbBjOgYNn\n" + |
|||
"piGhAkEAr5gMo0D4ytYHVxDAs74by071hgNajaN70qLrNlp1MDny8l6mwBnpZ3/y\n" + |
|||
"WTMXrFDJljPlPTByXzDRikHdiVLFQQJATMOTpxK4tqA+A+BmBhkVa+jI0nMiUJqV\n" + |
|||
"i4py60EuBoR2efU+B3xFIkGfI9jB9vmPCTMma2P9FSC6UNccaJG0oQJBAJYb6Ch9\n" + |
|||
"yowGPpY1CShaR+TfauUreJBd4PSpu8mFlYUssp2AXYcJM51RMzrZKXvTTOvwkStc\n" + |
|||
"NRCBcPVAYcVUnXo=\n" + |
|||
"-----END PRIVATE KEY-----"; |
|||
|
|||
/** |
|||
* 获取纯Base64格式的私钥(去除PEM头尾标记) |
|||
* @return 纯Base64格式的私钥字符串 |
|||
*/ |
|||
public static String getBase64PrivateKey() { |
|||
return OPENID_APP_PRIVATE_KEY |
|||
.replace("-----BEGIN PRIVATE KEY-----", "") |
|||
.replace("-----END PRIVATE KEY-----", "") |
|||
.replaceAll("\\s+", ""); // 移除所有空白字符(包括换行符)
|
|||
} |
|||
|
|||
/** |
|||
* 配置平台类型,可选值如下: |
|||
* {@link CommonCmd#AIDL_PLATFORM_TYPE_PHONE } |
|||
* {@link CommonCmd#AIDL_PLATFORM_TYPE_TV} |
|||
* {@link CommonCmd#AIDL_PLATFORM_TYPE_CAR} |
|||
* {@link CommonCmd#AIDL_PLATFORM_TYPE_PAD} |
|||
*/ |
|||
// 修改这一行:使用CommonCmd中的常量而不是字符串
|
|||
public static String BIND_PLATFORM = CommonCmd.AIDL_PLATFORM_TYPE_PHONE; |
|||
|
|||
} |
|||
@ -0,0 +1,239 @@ |
|||
package com.yunqiinnovation.qq_music |
|||
|
|||
import android.app.Activity |
|||
import android.os.Bundle |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.util.Log |
|||
import com.yunqiinnovation.qq_music.openid.OpenIDHelper |
|||
import com.yunqiinnovation.qq_music.util.QPlayBindHelper |
|||
import com.yunqiinnovation.qq_music.util.QQMusicApiWrapper |
|||
import com.tencent.qqmusic.third.api.contract.* |
|||
import org.json.JSONObject |
|||
import org.json.JSONTokener |
|||
|
|||
/** |
|||
* QQ音乐登录管理器 |
|||
* 负责处理QQ音乐的登录授权流程 |
|||
*/ |
|||
class QQMusicLoginManager(private val activity: Activity) { |
|||
|
|||
companion object { |
|||
private const val TAG = "QQMusicLoginManager" |
|||
private const val BASE_SCHEME = "qqmusicapidemo://" |
|||
private const val URI_LOGIN = BASE_SCHEME + "login" |
|||
|
|||
// 定义错误码常量,替代 LoginExampleActivity 中的常量 |
|||
const val LOGIN_FAILED = 1 |
|||
} |
|||
|
|||
private lateinit var qPlayBindHelper: QPlayBindHelper |
|||
private var qqMusicApi: IQQMusicApi? = null |
|||
private lateinit var qqMusicApiWrapper: QQMusicApiWrapper |
|||
private val handler = Handler(Looper.getMainLooper()) |
|||
|
|||
/** |
|||
* 登录结果回调接口 |
|||
*/ |
|||
interface LoginCallback { |
|||
fun onSuccess(openId: String, openToken: String, expireTime: String) |
|||
fun onError(errorCode: String, errorMessage: String) |
|||
} |
|||
|
|||
/** |
|||
* 开始登录流程 |
|||
*/ |
|||
fun startLogin(callback: LoginCallback) { |
|||
Log.d(TAG, "开始QQ音乐登录流程") |
|||
|
|||
// 检查配置 |
|||
if (Config.OPENID_APPID.isEmpty() || Config.OPENID_APP_PRIVATE_KEY.isEmpty()) { |
|||
callback.onError("CONFIG_ERROR", "请先配置APPID和私钥") |
|||
return |
|||
} |
|||
|
|||
// 初始化绑定助手 |
|||
qPlayBindHelper = QPlayBindHelper(activity) |
|||
|
|||
// 开始绑定QQ音乐服务 |
|||
bindQQMusicService(callback) |
|||
} |
|||
|
|||
/** |
|||
* 绑定QQ音乐服务 |
|||
*/ |
|||
private fun bindQQMusicService(callback: LoginCallback) { |
|||
Log.d(TAG, "开始绑定QQ音乐服务") |
|||
|
|||
qPlayBindHelper.ensureQQMusicBindByStartProcess { success -> |
|||
if (success && qPlayBindHelper.isBindQQMusic()) { |
|||
Log.d(TAG, "QQ音乐服务绑定成功") |
|||
qqMusicApi = qPlayBindHelper.getQQMusicApi()!! |
|||
qqMusicApiWrapper = QQMusicApiWrapper(qqMusicApi!!) |
|||
|
|||
// 检查是否已经授权 |
|||
checkAuthStatus(callback) |
|||
|
|||
} else { |
|||
Log.e(TAG, "QQ音乐服务绑定失败") |
|||
callback.onError("BIND_FAILED", "绑定QQ音乐服务失败,请确保已安装QQ音乐") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查授权状态 |
|||
*/ |
|||
private fun checkAuthStatus(callback: LoginCallback) { |
|||
Log.d(TAG, "检查授权状态") |
|||
|
|||
val bundle = Bundle().apply { |
|||
putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) |
|||
putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM) |
|||
} |
|||
|
|||
try { |
|||
val result = qqMusicApi?.execute("hi", bundle) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) |
|||
|
|||
when (code) { |
|||
ErrorCodes.ERROR_OK -> { |
|||
// 已授权,直接请求登录信息 |
|||
Log.d(TAG, "已授权,直接获取登录信息") |
|||
requestAuth(callback) |
|||
} |
|||
ErrorCodes.ERROR_API_NO_PERMISSION -> { |
|||
// 需要授权 |
|||
Log.d(TAG, "需要授权,启动授权流程") |
|||
startAuthProcess(callback) |
|||
} |
|||
else -> { |
|||
Log.e(TAG, "检查授权状态失败: $code") |
|||
callback.onError("AUTH_CHECK_FAILED", "检查授权状态失败") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "检查授权状态异常", e) |
|||
callback.onError("AUTH_CHECK_ERROR", "检查授权状态异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 启动授权流程 |
|||
*/ |
|||
private fun startAuthProcess(callback: LoginCallback) { |
|||
Log.d(TAG, "启动QQ音乐登录页面") |
|||
|
|||
// 启动QQ音乐登录 |
|||
CommonCmd.loginQQMusic(activity, URI_LOGIN) |
|||
|
|||
// 等待用户登录完成后进行身份验证 |
|||
handler.postDelayed({ |
|||
verifyIdentity(callback) |
|||
}, 3000) // 给用户足够的登录时间 |
|||
} |
|||
|
|||
/** |
|||
* 验证身份 |
|||
*/ |
|||
private fun verifyIdentity(callback: LoginCallback) { |
|||
Log.d(TAG, "开始身份验证") |
|||
|
|||
val time = System.currentTimeMillis() |
|||
val nonce = time.toString() |
|||
val encryptString = OpenIDHelper.getEncryptString(nonce) |
|||
|
|||
CommonCmd.verifyCallerIdentity( |
|||
activity, |
|||
Config.OPENID_APPID, |
|||
activity.packageName, |
|||
encryptString, |
|||
BASE_SCHEME |
|||
) |
|||
|
|||
// 延迟后请求授权 |
|||
handler.postDelayed({ |
|||
requestAuth(callback) |
|||
}, 2000) |
|||
} |
|||
|
|||
/** |
|||
* 请求授权并获取登录信息 |
|||
*/ |
|||
private fun requestAuth(callback: LoginCallback) { |
|||
Log.d(TAG, "开始请求授权") |
|||
|
|||
qqMusicApiWrapper.requestAuthNew( |
|||
success = { encrypt -> |
|||
Log.d(TAG, "授权成功,开始解密登录信息") |
|||
parseLoginResult(encrypt, callback) |
|||
}, |
|||
failed = { errorCode, errorMsg -> |
|||
Log.e(TAG, "授权失败: code=$errorCode, msg=$errorMsg") |
|||
|
|||
val errorMessage = when (errorCode) { |
|||
LOGIN_FAILED -> "登录失败: $errorMsg" |
|||
else -> "授权失败: $errorMsg" |
|||
} |
|||
|
|||
callback.onError("AUTH_FAILED", errorMessage) |
|||
} |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 解析登录结果 |
|||
*/ |
|||
private fun parseLoginResult(encrypt: String, callback: LoginCallback) { |
|||
try { |
|||
val qmDecryptString = OpenIDHelper.decryptQQMEncryptString(encrypt) |
|||
if (qmDecryptString != null) { |
|||
val appParser = JSONTokener(qmDecryptString) |
|||
val appDecryptJson = appParser.nextValue() as JSONObject |
|||
val sign = appDecryptJson.getString(Keys.API_RETURN_KEY_SIGN) |
|||
val returnNonce = appDecryptJson.getString(Keys.API_RETURN_KEY_NONCE) |
|||
|
|||
// 检查签名 |
|||
if (OpenIDHelper.checkQMSign(sign, returnNonce)) { |
|||
val openId = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_ID) |
|||
val openToken = appDecryptJson.getString(Keys.API_RETURN_KEY_OPEN_TOKEN) |
|||
val expireTime = appDecryptJson.getString(Keys.API_PARAM_KEY_SDK_EXPIRETIME) |
|||
|
|||
Log.d(TAG, "登录成功: openId=$openId") |
|||
|
|||
handler.post { |
|||
callback.onSuccess(openId, openToken, expireTime) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "签名验证失败") |
|||
handler.post { |
|||
callback.onError("SIGN_ERROR", "签名验证失败") |
|||
} |
|||
} |
|||
} else { |
|||
Log.e(TAG, "解密失败") |
|||
handler.post { |
|||
callback.onError("DECRYPT_ERROR", "解密失败") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析登录结果失败", e) |
|||
handler.post { |
|||
callback.onError("PARSE_ERROR", "解析登录结果失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清理资源 |
|||
*/ |
|||
fun cleanup() { |
|||
try { |
|||
if (::qPlayBindHelper.isInitialized) { |
|||
qPlayBindHelper.unBindQQMusic() |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "清理资源失败", e) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,495 @@ |
|||
package com.yunqiinnovation.qq_music |
|||
|
|||
import android.app.Activity |
|||
import android.content.ComponentName |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.ServiceConnection |
|||
import android.os.Bundle |
|||
import android.os.IBinder |
|||
import android.util.Log |
|||
import com.google.gson.Gson |
|||
import com.google.gson.JsonParser |
|||
import com.tencent.qqmusic.third.api.contract.* |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
|
|||
class QQMusicManager(private val activity: Activity) { |
|||
companion object { |
|||
const val TAG = "QQMusicManager" |
|||
} |
|||
|
|||
private var qqmusicApi: IQQMusicApi? = null |
|||
private var isBindQQMusicService = false |
|||
private val gson = Gson() |
|||
|
|||
// 回调接口 |
|||
interface SearchCallback { |
|||
fun onSuccess(songs: List<Map<String, Any?>>) |
|||
fun onError(errorCode: Int, errorMessage: String) |
|||
} |
|||
|
|||
interface PlayCallback { |
|||
fun onSuccess() |
|||
fun onError(errorCode: Int, errorMessage: String) |
|||
} |
|||
|
|||
interface InfoCallback { |
|||
fun onSuccess(data: Map<String, Any?>) |
|||
fun onError(errorCode: Int, errorMessage: String) |
|||
} |
|||
|
|||
private val serviceConnection = object : ServiceConnection { |
|||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { |
|||
Log.d(TAG, "QQ音乐服务连接成功") |
|||
qqmusicApi = IQQMusicApi.Stub.asInterface(service) |
|||
isBindQQMusicService = true |
|||
|
|||
// 初始化SDK |
|||
CommonCmd.init(Config.BIND_PLATFORM) |
|||
|
|||
// 发送hi消息 |
|||
sayHi() |
|||
} |
|||
|
|||
override fun onServiceDisconnected(name: ComponentName?) { |
|||
Log.d(TAG, "QQ音乐服务连接断开") |
|||
qqmusicApi = null |
|||
isBindQQMusicService = false |
|||
} |
|||
} |
|||
|
|||
init { |
|||
bindQQMusicApiService() |
|||
} |
|||
|
|||
private fun sayHi() { |
|||
if (!isBindQQMusicService || qqmusicApi == null) return |
|||
|
|||
val bundle = Bundle() |
|||
bundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) |
|||
bundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM) |
|||
|
|||
val result = qqmusicApi?.execute("hi", bundle) |
|||
Log.d(TAG, "sayHi ret:" + result?.getInt(Keys.API_RETURN_KEY_CODE)) |
|||
} |
|||
|
|||
private fun bindQQMusicApiService() { |
|||
Log.d(TAG, "开始绑定QQ音乐服务,平台类型: ${Config.BIND_PLATFORM}") |
|||
|
|||
val intent = when (Config.BIND_PLATFORM) { |
|||
CommonCmd.AIDL_PLATFORM_TYPE_PHONE -> { |
|||
Intent("com.tencent.qqmusic.third.api.QQMusicApiService").apply { |
|||
`package` = "com.tencent.qqmusic" |
|||
} |
|||
} |
|||
CommonCmd.AIDL_PLATFORM_TYPE_CAR -> { |
|||
Intent("com.tencent.qqmusiccar.third.api.QQMusicApiService").apply { |
|||
`package` = "com.tencent.qqmusiccar" |
|||
} |
|||
} |
|||
CommonCmd.AIDL_PLATFORM_TYPE_TV -> { |
|||
Intent("com.tencent.qqmusictv.third.api.QQMusicApiService").apply { |
|||
`package` = "com.tencent.qqmusictv" |
|||
} |
|||
} |
|||
else -> { |
|||
Log.e(TAG, "不支持的平台类型: ${Config.BIND_PLATFORM}") |
|||
return |
|||
} |
|||
} |
|||
|
|||
Log.d(TAG, "绑定Intent: action=${intent.action}, package=${intent.`package`}") |
|||
|
|||
try { |
|||
val bindResult = activity.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) |
|||
Log.d(TAG, "bindService结果: $bindResult") |
|||
|
|||
if (!bindResult) { |
|||
Log.e(TAG, "绑定QQ音乐服务失败,尝试启动QQ音乐进程") |
|||
// 尝试启动QQ音乐进程 |
|||
CommonCmd.startQQMusicProcess(activity, activity.packageName) |
|||
|
|||
// 延迟重试绑定 |
|||
Handler(Looper.getMainLooper()).postDelayed({ |
|||
Log.d(TAG, "重试绑定QQ音乐服务") |
|||
activity.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) |
|||
}, 2000) |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "绑定QQ音乐服务异常", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 搜索歌曲 |
|||
* @param keyword 搜索关键词 |
|||
* @param searchType 搜索类型,参考Data.SearchType |
|||
* @param callback 回调 |
|||
*/ |
|||
fun searchSongs( |
|||
keyword: String, |
|||
searchType: Int = Data.SearchType.SEARCH_TYPE_SONG, |
|||
callback: SearchCallback |
|||
) { |
|||
Log.d(TAG, "searchSongs: keyword=$keyword, searchType=$searchType") |
|||
|
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
Log.e(TAG, "QQ音乐服务未连接") |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
// 先测试服务连接状态 |
|||
try { |
|||
val testBundle = Bundle() |
|||
testBundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) |
|||
testBundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM) |
|||
|
|||
val testResult = qqmusicApi?.execute("hi", testBundle) |
|||
val testCode = testResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
Log.d(TAG, "服务连接测试结果: $testCode") |
|||
|
|||
if (testCode != ErrorCodes.ERROR_OK) { |
|||
Log.e(TAG, "QQ音乐服务连接异常: $testCode") |
|||
val testErrorMessage = when (testCode) { |
|||
ErrorCodes.ERROR_API_NO_PERMISSION -> "没有权限访问QQ音乐服务" |
|||
ErrorCodes.ERROR_API_NOT_INITIALIZED -> "QQ音乐API未初始化" |
|||
else -> "QQ音乐服务连接异常 (错误码: $testCode)" |
|||
} |
|||
callback.onError(testCode, testErrorMessage) |
|||
return |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "测试服务连接失败", e) |
|||
callback.onError(-1, "测试服务连接失败: ${e.message}") |
|||
return |
|||
} |
|||
|
|||
val params = Bundle().apply { |
|||
putInt("searchType", searchType) |
|||
putString("keyword", keyword) |
|||
putBoolean("firstPage", true) |
|||
} |
|||
|
|||
Log.d(TAG, "调用QQ音乐搜索API,参数: searchType=$searchType, keyword=$keyword") |
|||
|
|||
try { |
|||
// 直接调用executeAsync,不接收返回值 |
|||
qqmusicApi?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle?) { |
|||
Log.d(TAG, "搜索API返回结果") |
|||
|
|||
if (result == null) { |
|||
Log.e(TAG, "搜索返回结果为null") |
|||
callback.onError(-1, "搜索返回结果为null") |
|||
return |
|||
} |
|||
|
|||
val code = result.getInt(Keys.API_RETURN_KEY_CODE, -1) |
|||
Log.d(TAG, "搜索返回码: $code") |
|||
|
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
try { |
|||
val dataJson = result.getString(Keys.API_RETURN_KEY_DATA) |
|||
Log.d(TAG, "搜索数据: $dataJson") |
|||
|
|||
if (dataJson != null && dataJson.isNotEmpty()) { |
|||
val array = JsonParser().parse(dataJson).asJsonArray |
|||
val songs = mutableListOf<Map<String, Any?>>() |
|||
|
|||
for (elem in array) { |
|||
val song = gson.fromJson(elem, Data.Song::class.java) |
|||
songs.add(mapOf( |
|||
"mid" to song.mid, |
|||
"id" to song.id, |
|||
"name" to song.title, |
|||
"singer" to song.singer?.title, |
|||
"album" to song.album?.title, |
|||
"duration" to song.duration, |
|||
"pic" to song.album?.coverUri |
|||
)) |
|||
} |
|||
Log.d(TAG, "解析完成,共 ${songs.size} 首歌曲") |
|||
callback.onSuccess(songs) |
|||
} else { |
|||
Log.e(TAG, "搜索结果数据为空") |
|||
callback.onError(code, "搜索结果数据为空") |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析搜索结果异常", e) |
|||
callback.onError(-1, "解析搜索结果失败: ${e.message}") |
|||
} |
|||
} else { |
|||
val error = result.getString(Keys.API_RETURN_KEY_ERROR) ?: "搜索失败" |
|||
Log.e(TAG, "搜索失败: code=$code, error=$error") |
|||
|
|||
// 根据实际可用的错误码提供具体的错误信息 |
|||
val errorMessage = when (code) { |
|||
ErrorCodes.ERROR_API_NO_PERMISSION -> "没有搜索权限,请检查应用授权" |
|||
ErrorCodes.ERROR_API_NOT_INITIALIZED -> "QQ音乐API未初始化,请重新登录" |
|||
else -> "搜索失败: $error (错误码: $code)" |
|||
} |
|||
callback.onError(code, errorMessage) |
|||
} |
|||
} |
|||
}) |
|||
|
|||
Log.d(TAG, "executeAsync调用完成,等待回调") |
|||
|
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "调用搜索API异常", e) |
|||
callback.onError(-1, "调用搜索API异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 播放歌曲列表 |
|||
* @param songMids 歌曲MID列表 |
|||
* @param index 开始播放的索引 |
|||
* @param callback 回调 |
|||
*/ |
|||
fun playSongs( |
|||
songMids: List<String>, |
|||
index: Int = 0, |
|||
callback: PlayCallback |
|||
) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val params = Bundle().apply { |
|||
putStringArrayList("midList", ArrayList(songMids)) |
|||
putInt("index", index) |
|||
} |
|||
|
|||
// 直接调用executeAsync,不接收返回值 |
|||
qqmusicApi?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle?) { |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
callback.onSuccess() |
|||
} else { |
|||
val error = result?.getString(Keys.API_RETURN_KEY_ERROR) ?: "播放失败" |
|||
callback.onError(code, error) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* 播放音乐 |
|||
*/ |
|||
fun playMusic(callback: PlayCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("playMusic", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
callback.onSuccess() |
|||
} else { |
|||
callback.onError(code, "播放失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 暂停音乐 |
|||
*/ |
|||
fun pauseMusic(callback: PlayCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("pauseMusic", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
callback.onSuccess() |
|||
} else { |
|||
callback.onError(code, "暂停失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 恢复播放 |
|||
*/ |
|||
fun resumeMusic(callback: PlayCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("resumeMusic", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
callback.onSuccess() |
|||
} else { |
|||
callback.onError(code, "恢复播放失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止音乐 |
|||
*/ |
|||
fun stopMusic(callback: PlayCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("stopMusic", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
callback.onSuccess() |
|||
} else { |
|||
callback.onError(code, "停止失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 下一首 |
|||
*/ |
|||
fun skipToNext(callback: PlayCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("skipToNext", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
callback.onSuccess() |
|||
} else { |
|||
callback.onError(code, "切换下一首失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 上一首 |
|||
*/ |
|||
fun skipToPrevious(callback: PlayCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("skipToPrevious", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
callback.onSuccess() |
|||
} else { |
|||
callback.onError(code, "切换上一首失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取播放状态 |
|||
*/ |
|||
fun getPlaybackState(callback: InfoCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("getPlaybackState", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
val state = result?.getInt(Keys.API_RETURN_KEY_DATA) ?: 0 |
|||
callback.onSuccess(mapOf("state" to state)) |
|||
} else { |
|||
callback.onError(code, "获取播放状态失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取当前播放歌曲 |
|||
*/ |
|||
fun getCurrentSong(callback: InfoCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
val result = qqmusicApi?.execute("getCurrentSong", null) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
try { |
|||
val curSongJson = result?.getString(Keys.API_RETURN_KEY_DATA) |
|||
if (curSongJson != null) { |
|||
val song = gson.fromJson(curSongJson, Data.Song::class.java) |
|||
val songMap = mapOf( |
|||
"mid" to song.mid, |
|||
"id" to song.id, |
|||
"name" to song.title, |
|||
"singer" to song.singer?.title, |
|||
"album" to song.album?.title, |
|||
"duration" to song.duration, |
|||
"pic" to song.album?.coverUri |
|||
) |
|||
callback.onSuccess(songMap) |
|||
} else { |
|||
callback.onError(-1, "当前没有播放歌曲") |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析当前歌曲信息异常", e) |
|||
callback.onError(-1, "解析歌曲信息失败: ${e.message}") |
|||
} |
|||
} else { |
|||
callback.onError(code, "获取当前歌曲失败") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取播放时长 |
|||
*/ |
|||
fun getPlayTime(callback: InfoCallback) { |
|||
if (!isBindQQMusicService || qqmusicApi == null) { |
|||
callback.onError(-1, "QQ音乐服务未连接") |
|||
return |
|||
} |
|||
|
|||
try { |
|||
// 获取当前播放时间 |
|||
val currResult = qqmusicApi?.execute("getCurrTime", null) |
|||
val currCode = currResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
|
|||
// 获取总时长 |
|||
val totalResult = qqmusicApi?.execute("getTotalTime", null) |
|||
val totalCode = totalResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
|
|||
if (currCode == ErrorCodes.ERROR_OK && totalCode == ErrorCodes.ERROR_OK) { |
|||
val currentTime = (currResult?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 |
|||
val totalTime = (totalResult?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 |
|||
|
|||
callback.onSuccess(mapOf( |
|||
"currentTime" to currentTime, |
|||
"totalTime" to totalTime |
|||
)) |
|||
} else { |
|||
callback.onError(-1, "获取播放时长失败") |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "获取播放时长异常", e) |
|||
callback.onError(-1, "获取播放时长异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清理资源 |
|||
*/ |
|||
fun cleanup() { |
|||
try { |
|||
if (isBindQQMusicService) { |
|||
activity.unbindService(serviceConnection) |
|||
isBindQQMusicService = false |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "清理资源异常", e) |
|||
} |
|||
qqmusicApi = null |
|||
} |
|||
} |
|||
@ -0,0 +1,393 @@ |
|||
package com.yunqiinnovation.qq_music |
|||
|
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.util.Log |
|||
import android.app.Activity |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityAware |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
// 添加这行导入 |
|||
import com.tencent.qqmusic.third.api.contract.* |
|||
|
|||
/** QqMusicPlugin */ |
|||
class QqMusicPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { |
|||
private lateinit var channel: MethodChannel |
|||
private var activity: Activity? = null |
|||
private var loginManager: QQMusicLoginManager? = null |
|||
private var musicManager: QQMusicManager? = null |
|||
|
|||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.qq_music") |
|||
channel.setMethodCallHandler(this) |
|||
} |
|||
|
|||
override fun onMethodCall(call: MethodCall, result: Result) { |
|||
when (call.method) { |
|||
"login" -> { |
|||
handleLogin(result) |
|||
} |
|||
"searchSongs" -> { |
|||
handleSearchSongs(call, result) |
|||
} |
|||
"playSongs" -> { |
|||
handlePlaySongs(call, result) |
|||
} |
|||
"playMusic" -> { |
|||
handlePlayMusic(result) |
|||
} |
|||
"pauseMusic" -> { |
|||
handlePauseMusic(result) |
|||
} |
|||
"resumeMusic" -> { |
|||
handleResumeMusic(result) |
|||
} |
|||
"stopMusic" -> { |
|||
handleStopMusic(result) |
|||
} |
|||
"skipToNext" -> { |
|||
handleSkipToNext(result) |
|||
} |
|||
"skipToPrevious" -> { |
|||
handleSkipToPrevious(result) |
|||
} |
|||
"getPlaybackState" -> { |
|||
handleGetPlaybackState(result) |
|||
} |
|||
"getCurrentSong" -> { |
|||
handleGetCurrentSong(result) |
|||
} |
|||
"getPlayTime" -> { |
|||
handleGetPlayTime(result) |
|||
} |
|||
"getPlatformVersion" -> { |
|||
result.success("Android ${android.os.Build.VERSION.RELEASE}") |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun handleLogin(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
// 创建登录管理器 |
|||
loginManager = QQMusicLoginManager(currentActivity) |
|||
|
|||
// 开始登录 |
|||
loginManager?.startLogin(object : QQMusicLoginManager.LoginCallback { |
|||
override fun onSuccess(openId: String, openToken: String, expireTime: String) { |
|||
// 确保在主线程中执行回调 |
|||
Handler(Looper.getMainLooper()).post { |
|||
val loginResult = mapOf( |
|||
"success" to true, |
|||
"openId" to openId, |
|||
"openToken" to openToken, |
|||
"expireTime" to expireTime |
|||
) |
|||
Log.d("QqMusicPlugin", "登录成功回调: $loginResult") |
|||
result.success(loginResult) |
|||
} |
|||
} |
|||
|
|||
override fun onError(errorCode: String, errorMessage: String) { |
|||
// 确保在主线程中执行回调 |
|||
Handler(Looper.getMainLooper()).post { |
|||
Log.e("QqMusicPlugin", "登录失败回调: $errorCode - $errorMessage") |
|||
result.error(errorCode, errorMessage, null) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleSearchSongs(call: MethodCall, result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
val keyword = call.argument<String>("keyword") |
|||
val searchType = call.argument<Int>("searchType") ?: Data.SearchType.SEARCH_TYPE_SONG |
|||
|
|||
if (keyword == null) { |
|||
result.error("INVALID_ARGUMENT", "keyword is required", null) |
|||
return |
|||
} |
|||
|
|||
Log.d("QqMusicPlugin", "开始搜索: keyword=$keyword, searchType=$searchType") |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.searchSongs(keyword, searchType, object : QQMusicManager.SearchCallback { |
|||
override fun onSuccess(songs: List<Map<String, Any?>>) { |
|||
// 确保在主线程中执行回调 |
|||
Handler(Looper.getMainLooper()).post { |
|||
Log.d("QqMusicPlugin", "搜索成功: 找到 ${songs.size} 首歌曲") |
|||
result.success(mapOf( |
|||
"success" to true, |
|||
"songs" to songs |
|||
)) |
|||
} |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
// 确保在主线程中执行回调 |
|||
Handler(Looper.getMainLooper()).post { |
|||
Log.e("QqMusicPlugin", "搜索失败: code=$errorCode, message=$errorMessage") |
|||
result.error("SEARCH_FAILED", errorMessage, null) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handlePlaySongs(call: MethodCall, result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
val songMids = call.argument<List<String>>("songMids") |
|||
val index = call.argument<Int>("index") ?: 0 |
|||
|
|||
if (songMids == null || songMids.isEmpty()) { |
|||
result.error("INVALID_ARGUMENT", "songMids is required", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.playSongs(songMids, index, object : QQMusicManager.PlayCallback { |
|||
override fun onSuccess() { |
|||
result.success(mapOf("success" to true)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("PLAY_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handlePlayMusic(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.playMusic(object : QQMusicManager.PlayCallback { |
|||
override fun onSuccess() { |
|||
result.success(mapOf("success" to true)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("PLAY_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handlePauseMusic(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.pauseMusic(object : QQMusicManager.PlayCallback { |
|||
override fun onSuccess() { |
|||
result.success(mapOf("success" to true)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("PAUSE_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleResumeMusic(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.resumeMusic(object : QQMusicManager.PlayCallback { |
|||
override fun onSuccess() { |
|||
result.success(mapOf("success" to true)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("RESUME_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleStopMusic(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.stopMusic(object : QQMusicManager.PlayCallback { |
|||
override fun onSuccess() { |
|||
result.success(mapOf("success" to true)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("STOP_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleSkipToNext(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.skipToNext(object : QQMusicManager.PlayCallback { |
|||
override fun onSuccess() { |
|||
result.success(mapOf("success" to true)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("SKIP_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleSkipToPrevious(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.skipToPrevious(object : QQMusicManager.PlayCallback { |
|||
override fun onSuccess() { |
|||
result.success(mapOf("success" to true)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("SKIP_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleGetPlaybackState(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.getPlaybackState(object : QQMusicManager.InfoCallback { |
|||
override fun onSuccess(data: Map<String, Any?>) { |
|||
result.success(mapOf( |
|||
"success" to true, |
|||
"state" to data["state"] |
|||
)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("GET_STATE_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleGetCurrentSong(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.getCurrentSong(object : QQMusicManager.InfoCallback { |
|||
override fun onSuccess(data: Map<String, Any?>) { |
|||
result.success(mapOf( |
|||
"success" to true, |
|||
"song" to data |
|||
)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("GET_SONG_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun handleGetPlayTime(result: Result) { |
|||
val currentActivity = activity |
|||
if (currentActivity == null) { |
|||
result.error("NO_ACTIVITY", "Activity not available", null) |
|||
return |
|||
} |
|||
|
|||
ensureMusicManager(currentActivity) |
|||
musicManager?.getPlayTime(object : QQMusicManager.InfoCallback { |
|||
override fun onSuccess(data: Map<String, Any?>) { |
|||
result.success(mapOf( |
|||
"success" to true, |
|||
"totalTime" to data["totalTime"], |
|||
"currentTime" to data["currentTime"] |
|||
)) |
|||
} |
|||
|
|||
override fun onError(errorCode: Int, errorMessage: String) { |
|||
result.error("GET_TIME_FAILED", errorMessage, null) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun ensureMusicManager(activity: Activity) { |
|||
if (musicManager == null) { |
|||
musicManager = QQMusicManager(activity) |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
channel.setMethodCallHandler(null) |
|||
loginManager?.cleanup() |
|||
loginManager = null |
|||
musicManager?.cleanup() |
|||
musicManager = null |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
activity = null |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
activity = null |
|||
loginManager?.cleanup() |
|||
loginManager = null |
|||
musicManager?.cleanup() |
|||
musicManager = null |
|||
} |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
package com.yunqiinnovation.qq_music.openid; |
|||
|
|||
import android.util.Base64; |
|||
|
|||
import com.yunqiinnovation.qq_music.Config; |
|||
import com.tencent.qqmusic.third.api.contract.Keys; |
|||
|
|||
import org.json.JSONObject; |
|||
|
|||
public class OpenIDHelper { |
|||
|
|||
private static final String QQMusicPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrp4sMcJjY9hb2J3sHWlwIEBrJlw2Cimv+rZAQmR8V3EI+0PUK14pL8OcG7CY79li30IHwYGWwUapADKA01nKgNeq7+rSciMYZv6ByVq+ocxKY8az78HwIppwxKWpQ+ziqYavvfE5+iHIzAc8RvGj9lL6xx1zhoPkdaA0agAyuMQIDAQAB"; |
|||
|
|||
|
|||
public static String getEncryptString(String nonce) { |
|||
if (nonce == null || nonce.isEmpty()) { |
|||
return null; |
|||
} |
|||
|
|||
try { |
|||
//1.使用App私钥签名 - 修改这里,使用Base64格式的私钥
|
|||
String signString = RSAUtils.sign(nonce.getBytes(), Config.getBase64PrivateKey()); |
|||
|
|||
JSONObject signJson = new JSONObject(); |
|||
signJson.put(Keys.API_RETURN_KEY_NONCE, nonce); |
|||
signJson.put(Keys.API_RETURN_KEY_SIGN, signString); |
|||
signJson.put(Keys.API_RETURN_KEY_CALLBACK_URL, "qqmusicapidemo://"); |
|||
String sourceString = signJson.toString(); |
|||
|
|||
//2. 使用Q音公钥加密(随机数+签名)
|
|||
byte[] encryptData = RSAUtils.encryptByPublicKey(sourceString.getBytes(), QQMusicPublicKey); |
|||
if (encryptData == null) { |
|||
return null; |
|||
} |
|||
return Base64.encodeToString(encryptData, Base64.DEFAULT); |
|||
} catch (Exception e) { |
|||
e.printStackTrace(); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
public static String decryptQQMEncryptString(String qmEncryptString) { |
|||
try { |
|||
byte[] qmEncryptData = Base64.decode(qmEncryptString, Base64.DEFAULT); |
|||
//7.使用App私钥解密 - 修改这里,使用Base64格式的私钥
|
|||
byte[] decryptData = RSAUtils.decryptByPrivateKey(qmEncryptData, Config.getBase64PrivateKey()); |
|||
if (decryptData != null) { |
|||
return new String(decryptData); |
|||
} |
|||
} catch (Exception e) { |
|||
e.printStackTrace(); |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
public static boolean checkQMSign(String sign, String nonce) { |
|||
try { |
|||
return RSAUtils.verify(nonce.getBytes(), QQMusicPublicKey, sign); |
|||
} catch (Exception e) { |
|||
e.printStackTrace(); |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
@ -0,0 +1,314 @@ |
|||
package com.yunqiinnovation.qq_music.openid; |
|||
|
|||
import android.util.Base64; |
|||
|
|||
import java.io.ByteArrayOutputStream; |
|||
import java.security.Key; |
|||
import java.security.KeyFactory; |
|||
import java.security.KeyPair; |
|||
import java.security.KeyPairGenerator; |
|||
import java.security.PrivateKey; |
|||
import java.security.PublicKey; |
|||
import java.security.Signature; |
|||
import java.security.interfaces.RSAPrivateKey; |
|||
import java.security.interfaces.RSAPublicKey; |
|||
import java.security.spec.PKCS8EncodedKeySpec; |
|||
import java.security.spec.X509EncodedKeySpec; |
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
|
|||
import javax.crypto.Cipher; |
|||
|
|||
public class RSAUtils { |
|||
/** |
|||
* 加密算法RSA |
|||
*/ |
|||
public static final String KEY_ALGORITHM = "RSA"; |
|||
|
|||
/** |
|||
* 签名算法 |
|||
*/ |
|||
public static final String SIGNATURE_ALGORITHM = "SHA256withRSA";//"MD5withRSA";
|
|||
|
|||
/** |
|||
* 获取公钥的key |
|||
*/ |
|||
private static final String PUBLIC_KEY = "RSAPublicKey"; |
|||
|
|||
/** |
|||
* 获取私钥的key |
|||
*/ |
|||
private static final String PRIVATE_KEY = "RSAPrivateKey"; |
|||
|
|||
private static final String CIHPER_KEY = "RSA/ECB/PKCS1Padding";//
|
|||
|
|||
/** |
|||
* RSA最大加密明文大小 |
|||
*/ |
|||
private static final int MAX_ENCRYPT_BLOCK = 117; |
|||
|
|||
/** |
|||
* RSA最大解密密文大小 |
|||
*/ |
|||
private static final int MAX_DECRYPT_BLOCK = 128; |
|||
|
|||
/** |
|||
* 生成密钥对(公钥和私钥) |
|||
* |
|||
* @return |
|||
* @throws Exception |
|||
*/ |
|||
public static Map<String, Object> genKeyPair() throws Exception { |
|||
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM); |
|||
keyPairGen.initialize(1024); |
|||
KeyPair keyPair = keyPairGen.generateKeyPair(); |
|||
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); |
|||
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); |
|||
Map<String, Object> keyMap = new HashMap<>(2); |
|||
keyMap.put(PUBLIC_KEY, publicKey); |
|||
keyMap.put(PRIVATE_KEY, privateKey); |
|||
return keyMap; |
|||
} |
|||
|
|||
/** |
|||
* 用私钥对信息生成数字签名 |
|||
* |
|||
* @param data 已加密数据 |
|||
* @param privateKey 私钥(BASE64编码) |
|||
* @return |
|||
* @throws Exception |
|||
*/ |
|||
public static String sign(byte[] data, String privateKey) throws Exception { |
|||
|
|||
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); |
|||
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); |
|||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
|||
PrivateKey privateK = keyFactory.generatePrivate(pkcs8KeySpec); |
|||
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); |
|||
signature.initSign(privateK); |
|||
signature.update(data); |
|||
return Base64.encodeToString(signature.sign(), Base64.DEFAULT); |
|||
} |
|||
|
|||
/** |
|||
* 校验数字签名 |
|||
* |
|||
* @param data 已加密数据 |
|||
* @param publicKey 公钥(BASE64编码) |
|||
* @param sign 数字签名 |
|||
* @return |
|||
* @throws Exception |
|||
*/ |
|||
public static boolean verify(byte[] data, String publicKey, String sign) |
|||
throws Exception { |
|||
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); |
|||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); |
|||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
|||
PublicKey publicK = keyFactory.generatePublic(keySpec); |
|||
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); |
|||
signature.initVerify(publicK); |
|||
signature.update(data); |
|||
return signature.verify(Base64.decode(sign, Base64.DEFAULT)); |
|||
} |
|||
|
|||
/** |
|||
* 私钥解密 |
|||
* |
|||
* @param encryptedData 已加密数据 |
|||
* @param privateKey 私钥(BASE64编码) |
|||
* @return |
|||
* @throws Exception |
|||
*/ |
|||
public static byte[] decryptByPrivateKey(byte[] encryptedData, String privateKey) |
|||
throws Exception { |
|||
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); |
|||
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); |
|||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
|||
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec); |
|||
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
|||
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
|||
|
|||
cipher.init(Cipher.DECRYPT_MODE, privateK); |
|||
int inputLen = encryptedData.length; |
|||
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
|||
int offSet = 0; |
|||
byte[] cache; |
|||
int i = 0; |
|||
// 对数据分段解密
|
|||
while (inputLen - offSet > 0) { |
|||
if (inputLen - offSet > MAX_DECRYPT_BLOCK) { |
|||
cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK); |
|||
} else { |
|||
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet); |
|||
} |
|||
out.write(cache, 0, cache.length); |
|||
i++; |
|||
offSet = i * MAX_DECRYPT_BLOCK; |
|||
} |
|||
byte[] decryptedData = out.toByteArray(); |
|||
out.close(); |
|||
return decryptedData; |
|||
} |
|||
|
|||
public static String testDecryptByJavaPrivKey(String privkey_from_java, String encryptedString) throws Exception { |
|||
//加解密类
|
|||
Cipher cipher = Cipher.getInstance("RSA");//Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
|||
// G0语言通过java产生的公钥加密得到的加密后的字符串
|
|||
byte[] enBytes = Base64.decode(encryptedString, Base64.DEFAULT); |
|||
//通过密钥字符串得到密钥
|
|||
|
|||
PrivateKey privateKey = testGetPrivateKey(privkey_from_java); |
|||
|
|||
|
|||
//解密
|
|||
cipher.init(Cipher.DECRYPT_MODE, privateKey); |
|||
byte[] deBytes = cipher.doFinal(enBytes); |
|||
return new String(deBytes); |
|||
|
|||
} |
|||
|
|||
public static PrivateKey testGetPrivateKey(String key) throws Exception { |
|||
byte[] keyBytes; |
|||
keyBytes = Base64.decode(key, Base64.DEFAULT); |
|||
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); |
|||
KeyFactory keyFactory = KeyFactory.getInstance("RSA"); |
|||
return keyFactory.generatePrivate(keySpec); |
|||
} |
|||
|
|||
/** |
|||
* 公钥解密 |
|||
* |
|||
* @param encryptedData 已加密数据 |
|||
* @param publicKey 公钥(BASE64编码) |
|||
* @return |
|||
* @throws Exception |
|||
*/ |
|||
public static byte[] decryptByPublicKey(byte[] encryptedData, String publicKey) |
|||
throws Exception { |
|||
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); |
|||
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes); |
|||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
|||
Key publicK = keyFactory.generatePublic(x509KeySpec); |
|||
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
|||
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
|||
cipher.init(Cipher.DECRYPT_MODE, publicK); |
|||
int inputLen = encryptedData.length; |
|||
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
|||
int offSet = 0; |
|||
byte[] cache; |
|||
int i = 0; |
|||
// 对数据分段解密
|
|||
while (inputLen - offSet > 0) { |
|||
if (inputLen - offSet > MAX_DECRYPT_BLOCK) { |
|||
cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK); |
|||
} else { |
|||
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet); |
|||
} |
|||
out.write(cache, 0, cache.length); |
|||
i++; |
|||
offSet = i * MAX_DECRYPT_BLOCK; |
|||
} |
|||
byte[] decryptedData = out.toByteArray(); |
|||
out.close(); |
|||
return decryptedData; |
|||
} |
|||
|
|||
/** |
|||
* 公钥加密 |
|||
* |
|||
* @param data 源数据 |
|||
* @param publicKey 公钥(BASE64编码) |
|||
* @return |
|||
* @throws Exception |
|||
*/ |
|||
public static byte[] encryptByPublicKey(byte[] data, String publicKey) |
|||
throws Exception { |
|||
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); |
|||
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes); |
|||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
|||
Key publicK = keyFactory.generatePublic(x509KeySpec); |
|||
// 对数据加密
|
|||
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
|||
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
|||
cipher.init(Cipher.ENCRYPT_MODE, publicK); |
|||
int inputLen = data.length; |
|||
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
|||
int offSet = 0; |
|||
byte[] cache; |
|||
int i = 0; |
|||
// 对数据分段加密
|
|||
while (inputLen - offSet > 0) { |
|||
if (inputLen - offSet > MAX_ENCRYPT_BLOCK) { |
|||
cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK); |
|||
} else { |
|||
cache = cipher.doFinal(data, offSet, inputLen - offSet); |
|||
} |
|||
out.write(cache, 0, cache.length); |
|||
i++; |
|||
offSet = i * MAX_ENCRYPT_BLOCK; |
|||
} |
|||
byte[] encryptedData = out.toByteArray(); |
|||
out.close(); |
|||
return encryptedData; |
|||
} |
|||
|
|||
/** |
|||
* 私钥加密 |
|||
* |
|||
* @param data 源数据 |
|||
* @param privateKey 私钥(BASE64编码) |
|||
* @return |
|||
* @throws Exception |
|||
*/ |
|||
public static byte[] encryptByPrivateKey(byte[] data, String privateKey) |
|||
throws Exception { |
|||
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); |
|||
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); |
|||
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
|||
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec); |
|||
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
|||
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
|||
cipher.init(Cipher.ENCRYPT_MODE, privateK); |
|||
int inputLen = data.length; |
|||
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
|||
int offSet = 0; |
|||
byte[] cache; |
|||
int i = 0; |
|||
// 对数据分段加密
|
|||
while (inputLen - offSet > 0) { |
|||
if (inputLen - offSet > MAX_ENCRYPT_BLOCK) { |
|||
cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK); |
|||
} else { |
|||
cache = cipher.doFinal(data, offSet, inputLen - offSet); |
|||
} |
|||
out.write(cache, 0, cache.length); |
|||
i++; |
|||
offSet = i * MAX_ENCRYPT_BLOCK; |
|||
} |
|||
byte[] encryptedData = out.toByteArray(); |
|||
out.close(); |
|||
return encryptedData; |
|||
} |
|||
|
|||
/** |
|||
* 获取私钥 |
|||
* |
|||
* @param keyMap 密钥对 |
|||
* @return |
|||
*/ |
|||
public static String getPrivateKey(Map<String, Object> keyMap) { |
|||
Key key = (Key) keyMap.get(PRIVATE_KEY); |
|||
return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); |
|||
} |
|||
|
|||
/** |
|||
* 获取公钥 |
|||
* |
|||
* @param keyMap 密钥对 |
|||
* @return |
|||
*/ |
|||
public static String getPublicKey(Map<String, Object> keyMap) { |
|||
Key key = (Key) keyMap.get(PUBLIC_KEY); |
|||
return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); |
|||
} |
|||
} |
|||
@ -0,0 +1,253 @@ |
|||
package com.yunqiinnovation.qq_music.pcm |
|||
|
|||
import android.media.AudioFormat |
|||
import android.media.AudioManager |
|||
import android.media.AudioRecord |
|||
import android.media.AudioTrack |
|||
import android.media.AudioTrack.* |
|||
import android.os.Build |
|||
import android.os.ParcelFileDescriptor |
|||
import android.os.Process |
|||
import android.util.Log |
|||
import com.yunqiinnovation.qq_music.util.BitConverter |
|||
import java.io.DataInputStream |
|||
import java.io.File |
|||
import java.io.FileInputStream |
|||
import java.io.InputStream |
|||
import java.util.concurrent.ConcurrentLinkedQueue |
|||
|
|||
|
|||
class AudioTrackManager { |
|||
companion object { |
|||
private const val TAG = "AudioTrackManager" |
|||
private const val LOG_LOOP = true |
|||
|
|||
//音频流类型 |
|||
private const val mStreamType = AudioManager.STREAM_MUSIC |
|||
|
|||
//指定采样率 (MediaRecoder 的采样率通常是8000Hz AAC的通常是44100Hz。 设置采样率为44100,目前为常用的采样率,官方文档表示这个值可以兼容所有的设置) |
|||
private const val mSampleRateInHz = 44100 |
|||
|
|||
//指定捕获音频的声道数目。在AudioFormat类中指定用于此的常量 |
|||
private const val mChannelConfig = AudioFormat.CHANNEL_IN_STEREO //STEREO立体声 |
|||
|
|||
//指定音频量化位数 ,在AudioFormaat类中指定了以下各种可能的常量。通常我们选择ENCODING_PCM_16BIT和ENCODING_PCM_8BIT PCM代表的是脉冲编码调制,它实际上是原始音频样本。 |
|||
//因此可以设置每个样本的分辨率为16位或者8位,16位将占用更多的空间和处理能力,表示的音频也更加接近真实。 |
|||
private const val mAudioFormat = AudioFormat.ENCODING_PCM_FLOAT |
|||
|
|||
//STREAM的意思是由用户在应用程序通过write方式把数据一次一次得写到audiotrack中。这个和我们在socket中发送数据一样, |
|||
// 应用层从某个地方获取数据,例如通过编解码得到PCM数据,然后write到audiotrack。 |
|||
private const val mMode = AudioTrack.MODE_STREAM |
|||
} |
|||
|
|||
private var mAudioTrack: AudioTrack? = null |
|||
|
|||
private var mInputStream: InputStream? = null //播放的数据流 |
|||
private var mPlayerThread: Thread? = null |
|||
|
|||
//指定缓冲区大小。调用AudioRecord类的getMinBufferSize方法可以获得。 |
|||
private var mMinBufferSize = 0 |
|||
|
|||
var printMessageCallback: ((String) -> Unit)? = null |
|||
var enterForeground: ((Boolean) -> Unit)? = null |
|||
|
|||
var isFloatPcm = true |
|||
|
|||
fun initData(sampleRateInHz: Int = mSampleRateInHz, channelConfig: Int = mChannelConfig, audioFormat: Int = mAudioFormat) { |
|||
Log.d(TAG, "[initData】$sampleRateInHz, $channelConfig, $audioFormat") |
|||
//根据采样率,采样精度,单双声道来得到frame的大小。 |
|||
mMinBufferSize = AudioTrack.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat) //计算最小缓冲区 |
|||
//注意,按照数字音频的知识,这个算出来的是一秒钟buffer的大小。 |
|||
//创建AudioTrack |
|||
mAudioTrack = AudioTrack(mStreamType, sampleRateInHz, channelConfig, audioFormat, mMinBufferSize, mMode) |
|||
isFloatPcm = audioFormat == AudioFormat.ENCODING_PCM_FLOAT |
|||
play() |
|||
} |
|||
|
|||
/** |
|||
* 销毁线程方法 |
|||
*/ |
|||
private fun destroyThread() { |
|||
try { |
|||
if (null != mPlayerThread && Thread.State.RUNNABLE == mPlayerThread!!.state) { |
|||
try { |
|||
Thread.sleep(500) |
|||
mPlayerThread!!.interrupt() |
|||
} catch (e: Exception) { |
|||
mPlayerThread = null |
|||
} |
|||
} |
|||
mPlayerThread = null |
|||
} catch (e: Exception) { |
|||
e.printStackTrace() |
|||
} finally { |
|||
mPlayerThread = null |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 启动播放线程 |
|||
*/ |
|||
private fun startThread() { |
|||
destroyThread() |
|||
if (mPlayerThread == null) { |
|||
mPlayerThread = Thread(streamPlayerRunnable) |
|||
mPlayerThread!!.start() |
|||
} |
|||
} |
|||
|
|||
private val streamPlayerRunnable = Runnable { |
|||
try { |
|||
//设置线程的优先级 |
|||
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) |
|||
Log.d(TAG, "streamPlayerRunnable") |
|||
|
|||
val tempBuffer = ByteArray(mMinBufferSize * 4) |
|||
var readCount = 0 |
|||
while (true) { |
|||
if (LOG_LOOP) { |
|||
//Log.d(TAG, "while true") |
|||
} |
|||
if (mInputStream!!.available() <= 0) { |
|||
if (LOG_LOOP) { |
|||
//Log.d(TAG, "sleep") |
|||
} |
|||
printMessageCallback?.invoke("Play thread sleep \n PlayState:${mAudioTrack?.playState?.playStateToString()}") |
|||
Thread.sleep(500) |
|||
continue |
|||
} |
|||
|
|||
readCount = mInputStream!!.read(tempBuffer) |
|||
if (LOG_LOOP) { |
|||
Log.d(TAG, "readCount:$readCount") |
|||
} |
|||
if (readCount == AudioTrack.ERROR_INVALID_OPERATION || readCount == AudioTrack.ERROR_BAD_VALUE) { |
|||
continue |
|||
} |
|||
if (readCount != 0 && readCount != -1) { |
|||
//判断AudioTrack未初始化,停止播放的时候释放了,状态就为STATE_UNINITIALIZED |
|||
if (mAudioTrack!!.state == AudioTrack.STATE_UNINITIALIZED) { |
|||
initData() |
|||
} |
|||
|
|||
printMessageCallback?.invoke("AudioTrack write $readCount Bytes\n PlayState:${mAudioTrack?.playState?.playStateToString()}") |
|||
|
|||
if (isFloatPcm) { |
|||
val size = readCount / 4 |
|||
val audioData = FloatArray(size) |
|||
for (i in 0 until size) { |
|||
// 框架地址:https://github.com/smartdevicelink/sdl_java_suite/blob/master/base/src/main/java/com/smartdevicelink/util/BitConverter.java |
|||
val bitInt = BitConverter.intFromByteArray(tempBuffer, i * 4) |
|||
val fl = java.lang.Float.intBitsToFloat(bitInt) |
|||
audioData[i] = fl |
|||
} |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
|||
val ret = mAudioTrack?.write(audioData, 0, audioData.size, WRITE_BLOCKING) |
|||
Log.d(TAG, "ret=$ret") |
|||
} |
|||
} else { |
|||
mAudioTrack!!.write(tempBuffer, 0, readCount) |
|||
} |
|||
|
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
e.printStackTrace() |
|||
} |
|||
} |
|||
|
|||
fun Int.playStateToString(): String { |
|||
return when (this) { |
|||
PLAYSTATE_STOPPED -> { |
|||
"PLAYSTATE_STOPPED" |
|||
} |
|||
PLAYSTATE_PAUSED -> { |
|||
"PLAYSTATE_PAUSED" |
|||
} |
|||
PLAYSTATE_PLAYING -> { |
|||
"PLAYSTATE_PLAYING" |
|||
} |
|||
else -> { |
|||
"unknown" |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 播放文件 |
|||
* |
|||
* @param path |
|||
* @throws Exception |
|||
*/ |
|||
@Throws(Exception::class) |
|||
private fun setPath(path: String) { |
|||
val file = File(path) |
|||
mInputStream = DataInputStream(FileInputStream(file)) |
|||
} |
|||
|
|||
/** |
|||
* 启动播放 |
|||
* |
|||
* @param path |
|||
*/ |
|||
fun startPlay(path: String) { |
|||
try { |
|||
//AudioTrack未初始化 |
|||
setPath(path) |
|||
startThread() |
|||
} catch (e: Exception) { |
|||
Log.d(TAG, "startPlay: Exception$e") |
|||
} |
|||
} |
|||
|
|||
fun startPlay(inputStream: InputStream?) { |
|||
initData() |
|||
try { |
|||
mInputStream = DataInputStream(inputStream) |
|||
startThread() |
|||
} catch (e: Exception) { |
|||
Log.d(TAG, "startPlay: Exception$e") |
|||
} |
|||
} |
|||
|
|||
fun startPlayByFileDescriptor(fileDescriptor: ParcelFileDescriptor) { |
|||
mInputStream = ParcelFileDescriptor.AutoCloseInputStream(fileDescriptor) |
|||
startThread() |
|||
} |
|||
|
|||
/** |
|||
* 停止播放 |
|||
*/ |
|||
fun stopPlay() { |
|||
try { |
|||
destroyThread() //销毁线程 |
|||
if (mAudioTrack != null) { |
|||
if (mAudioTrack!!.state == AudioRecord.STATE_INITIALIZED) { //初始化成功 |
|||
mAudioTrack!!.stop() //停止播放 |
|||
} |
|||
if (mAudioTrack != null) { |
|||
mAudioTrack!!.release() //释放audioTrack资源 |
|||
} |
|||
mAudioTrack = null |
|||
} |
|||
if (mInputStream != null) { |
|||
mInputStream!!.close() //关闭数据输入流 |
|||
} |
|||
} catch (e: Exception) { |
|||
e.printStackTrace() |
|||
} |
|||
enterForeground?.invoke(false) |
|||
} |
|||
|
|||
|
|||
fun pause() { |
|||
mAudioTrack?.pause() |
|||
enterForeground?.invoke(false) |
|||
} |
|||
|
|||
fun play() { |
|||
mAudioTrack?.play() |
|||
enterForeground?.invoke(true) |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
package com.yunqiinnovation.qq_music.pcm |
|||
|
|||
import android.content.* |
|||
import android.os.Bundle |
|||
import android.os.IBinder |
|||
import androidx.appcompat.app.AppCompatActivity |
|||
import android.widget.Button |
|||
import android.widget.TextView |
|||
import android.widget.Toast |
|||
import com.yunqiinnovation.qq_music.* |
|||
import com.yunqiinnovation.qq_music.R |
|||
|
|||
|
|||
class PcmExampleActivityNew : AppCompatActivity() { |
|||
companion object { |
|||
const val TAG = "PcmExampleActivity" |
|||
} |
|||
|
|||
private lateinit var informationTextView: TextView |
|||
private var playService: IPlayService? = null |
|||
|
|||
private val conn = object : ServiceConnection { |
|||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { |
|||
playService = IPlayService.Stub.asInterface(service) |
|||
playService?.setPrintMessageCallback(object :IPrint.Stub(){ |
|||
override fun print(msg: String?) { |
|||
runOnUiThread { |
|||
informationTextView.text = msg |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
override fun onServiceDisconnected(name: ComponentName?) { |
|||
playService?.setPrintMessageCallback(null) |
|||
playService = null |
|||
} |
|||
} |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
setContentView(R.layout.activity_pcm_example) |
|||
|
|||
informationTextView = findViewById(R.id.tv_information) |
|||
|
|||
val intent = Intent(this, PlayerService::class.java) |
|||
startService(intent) |
|||
bindService(intent, conn, BIND_AUTO_CREATE) |
|||
|
|||
findViewById<Button>(R.id.bt_bind_service).setOnClickListener { |
|||
playService?.bindService() |
|||
} |
|||
|
|||
findViewById<Button>(R.id.bt_start_pcm_mode).setOnClickListener { |
|||
playService?.startPcmMode() |
|||
Toast.makeText(this, "进入PCM模式", Toast.LENGTH_SHORT).show() |
|||
} |
|||
|
|||
findViewById<Button>(R.id.bt_stop_pcm_mode).setOnClickListener { |
|||
playService?.stopPcmMode() |
|||
Toast.makeText(this, "退出PCM模式", Toast.LENGTH_SHORT).show() |
|||
} |
|||
|
|||
findViewById<Button>(R.id.bt_play).setOnClickListener { |
|||
playService?.resumeOrPause() |
|||
} |
|||
|
|||
findViewById<Button>(R.id.bt_next).setOnClickListener { |
|||
playService?.playNext() |
|||
} |
|||
} |
|||
|
|||
override fun onDestroy() { |
|||
super.onDestroy() |
|||
unbindService(conn) |
|||
} |
|||
|
|||
} |
|||
|
|||
@ -0,0 +1,383 @@ |
|||
package com.yunqiinnovation.qq_music.pcm |
|||
|
|||
import android.app.NotificationChannel |
|||
import android.app.NotificationManager |
|||
import android.app.Service |
|||
import android.content.BroadcastReceiver |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.IntentFilter |
|||
import android.media.AudioFormat |
|||
import android.os.* |
|||
import androidx.core.app.NotificationCompat |
|||
import android.util.Log |
|||
import android.widget.Toast |
|||
import com.yunqiinnovation.qq_music.* |
|||
import com.yunqiinnovation.qq_music.R |
|||
import com.yunqiinnovation.qq_music.openid.OpenIDHelper |
|||
import com.yunqiinnovation.qq_music.util.QPlayBindHelper |
|||
import com.tencent.qqmusic.third.api.contract.* |
|||
|
|||
/** |
|||
* Created by clydeazhang on 2021/5/11 6:43 PM. |
|||
* Copyright (c) 2021 Tencent. All rights reserved. |
|||
*/ |
|||
class PlayerService : Service() { |
|||
|
|||
companion object { |
|||
private const val TAG = "PlayerService" |
|||
private const val KEY_ENTER_FOREGROUND = "KEY_ENTER_FOREGROUND" |
|||
private const val CHANNEL_ID = "default" |
|||
} |
|||
|
|||
private val qPlayBindHelper = QPlayBindHelper(this) |
|||
private var qqMusicApi: IQQMusicApi? = null |
|||
private var curPlayState: Int = 0 |
|||
private val handler = Handler(Looper.getMainLooper()) |
|||
|
|||
private var audioManager: AudioTrackManager? = null |
|||
private var audioManagerFloat: AudioTrackManager? = null |
|||
private var isReceiverRegistered = false |
|||
|
|||
/** |
|||
* QQ音乐事件回调 |
|||
*/ |
|||
private val eventListener = object : IQQMusicApiEventListener.Stub() { |
|||
override fun onEvent(event: String, extra: Bundle) { |
|||
extra.classLoader = this@PlayerService.classLoader |
|||
Log.d(TAG, "onEvent:$event extra:$extra") // 直接使用 Bundle.toString() |
|||
handler.post { |
|||
when (event) { |
|||
Events.API_EVENT_PLAY_STATE_CHANGED -> { |
|||
curPlayState = extra.getInt(Keys.API_EVENT_KEY_PLAY_STATE) |
|||
} |
|||
Events.API_EVENT_MEDIA_INFO_CHANGED -> { |
|||
onMediaInfoChanged(extra) |
|||
onMediaInfoChangedFloat(extra) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private var activeBroadcastReceiver = object : BroadcastReceiver() { |
|||
override fun onReceive(context: Context?, intent: Intent?) { |
|||
val ret = intent?.extras?.get("ret") |
|||
if (ret == "0") { |
|||
Log.d(TAG, "activeBroadcastReceiver 授权成功") |
|||
Toast.makeText(this@PlayerService, "授权成功", Toast.LENGTH_SHORT).show() |
|||
qqMusicApi?.registerEventListener( |
|||
arrayListOf( |
|||
Events.API_EVENT_PLAY_STATE_CHANGED, |
|||
Events.API_EVENT_PLAY_SONG_CHANGED, |
|||
Events.API_EVENT_MEDIA_INFO_CHANGED |
|||
), eventListener |
|||
) |
|||
} else { |
|||
Log.d(TAG, "activeBroadcastReceiver 授权失败($ret)") |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCreate() { |
|||
super.onCreate() |
|||
try { |
|||
val filter = IntentFilter() |
|||
filter.addAction("callback_verify_notify") |
|||
registerReceiver(activeBroadcastReceiver, filter) |
|||
isReceiverRegistered = true |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to register receiver", e) |
|||
} |
|||
|
|||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
val channelName = "默认" |
|||
val importance = NotificationManager.IMPORTANCE_DEFAULT |
|||
val channel = NotificationChannel(CHANNEL_ID, channelName, importance) |
|||
notificationManager.createNotificationChannel(channel) |
|||
} |
|||
} |
|||
|
|||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { |
|||
val enterForeground = intent?.getBooleanExtra(KEY_ENTER_FOREGROUND, false) ?: false |
|||
Log.d(TAG, "onStartCommand, enter foreground=$enterForeground") |
|||
if (enterForeground) { |
|||
val notification = NotificationCompat.Builder(this, CHANNEL_ID) |
|||
.setSmallIcon(R.drawable.ic_launcher_background) |
|||
.setContentTitle("demo标题") |
|||
.setContentText("demo正在播放音乐") |
|||
.build() |
|||
startForeground(1, notification) |
|||
} |
|||
return super.onStartCommand(intent, flags, startId) |
|||
} |
|||
|
|||
override fun onDestroy() { |
|||
super.onDestroy() |
|||
try { |
|||
if (isReceiverRegistered) { |
|||
unregisterReceiver(activeBroadcastReceiver) |
|||
isReceiverRegistered = false |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "Failed to unregister receiver", e) |
|||
} |
|||
|
|||
audioManager?.stopPlay() |
|||
audioManagerFloat?.stopPlay() |
|||
audioManager?.enterForeground = null |
|||
audioManagerFloat?.enterForeground = null |
|||
} |
|||
|
|||
private var printer: IPrint? = null |
|||
override fun onBind(intent: Intent?): IBinder? { |
|||
return object : IPlayService.Stub() { |
|||
override fun bindService() { |
|||
this@PlayerService.bindService() |
|||
} |
|||
|
|||
override fun startPcmMode() { |
|||
this@PlayerService.startPcmMode() |
|||
} |
|||
|
|||
override fun stopPcmMode() { |
|||
this@PlayerService.stopPcmMode() |
|||
} |
|||
|
|||
override fun resumeOrPause() { |
|||
this@PlayerService.resumeOrPause() |
|||
} |
|||
|
|||
override fun playNext() { |
|||
this@PlayerService.playNext() |
|||
} |
|||
|
|||
override fun setPrintMessageCallback(callback: IPrint?) { |
|||
this@PlayerService.printer = callback |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun playNext() { |
|||
val result = qqMusicApi?.execute("skipToNext", null) |
|||
val errorCode = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
if (errorCode != ErrorCodes.ERROR_OK) { |
|||
Log.d(TAG, "下一首失败($errorCode)") |
|||
} |
|||
} |
|||
|
|||
private fun resumeOrPause() { |
|||
if (isPlaying()) { |
|||
Log.d(TAG, "pauseMusic") |
|||
val result = qqMusicApi?.execute("pauseMusic", null) |
|||
val errorCode = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
if (errorCode != ErrorCodes.ERROR_OK) { |
|||
Log.d(TAG, "暂停音乐失败($errorCode)") |
|||
} |
|||
audioManager?.pause() |
|||
audioManagerFloat?.pause() |
|||
} else { |
|||
if (curPlayState == PlayState.PAUSED) { |
|||
Log.d(TAG, "resumeMusic") |
|||
val result = qqMusicApi?.execute("resumeMusic", null) |
|||
val errorCode = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
if (errorCode != ErrorCodes.ERROR_OK) { |
|||
Log.d(TAG, "继续播放音乐失败($errorCode)") |
|||
} |
|||
} else { |
|||
Log.d(TAG, "playMusic") |
|||
val result = qqMusicApi?.execute("playMusic", null) |
|||
val errorCode = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
|||
if (errorCode != ErrorCodes.ERROR_OK) { |
|||
Log.d(TAG, "开始播放音乐失败($errorCode)") |
|||
} |
|||
} |
|||
audioManager?.play() |
|||
audioManagerFloat?.play() |
|||
} |
|||
} |
|||
|
|||
private fun stopPcmMode() { |
|||
audioManager?.stopPlay() |
|||
audioManagerFloat?.stopPlay() |
|||
qqMusicApi?.execute("stopPcmMode", null) |
|||
} |
|||
|
|||
private fun startPcmMode() { |
|||
Log.d(TAG, "startPcmMode called") |
|||
|
|||
qqMusicApi?.executeAsync("startPcmMode", null, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(registerResult: Bundle?) { |
|||
registerResult?.classLoader = classLoader |
|||
|
|||
val ret = registerResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1 |
|||
Log.d(TAG, "onReturn $ret") |
|||
if (ret != ErrorCodes.ERROR_OK) { |
|||
Log.d(TAG, "startPcmMode return failed, ret=$ret") |
|||
return |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
private fun onMediaInfoChanged(registerResult: Bundle?) { |
|||
audioManager?.stopPlay() |
|||
audioManager = AudioTrackManager() |
|||
|
|||
audioManager?.printMessageCallback = { |
|||
printer?.print(it) |
|||
} |
|||
|
|||
audioManager?.enterForeground = { |
|||
Log.d(TAG, "enter foreground=$it") |
|||
val intent = Intent(this, PlayerService::class.java) |
|||
intent.putExtra(KEY_ENTER_FOREGROUND, it) |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
startForegroundService(intent) |
|||
} else { |
|||
startService(intent) |
|||
} |
|||
} |
|||
|
|||
val mediaInfo = registerResult?.getParcelable<Data.MediaInfo>(Keys.API_PARAM_KEY_MEDIA_INFO) |
|||
mediaInfo ?: return |
|||
Log.d(TAG, "initData, mediaInfo=$mediaInfo") |
|||
|
|||
audioManager?.let { manager -> |
|||
manager.stopPlay() |
|||
manager.initData( |
|||
mediaInfo.sampleRateInHz, |
|||
if (mediaInfo.channelConfig == 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO, |
|||
mediaInfo.audioFormat |
|||
) |
|||
|
|||
val parcelFileDescriptor = |
|||
registerResult.getParcelable<ParcelFileDescriptor>(Keys.API_PARAM_KEY_PCM_FILE_DESCRIPTOR) |
|||
parcelFileDescriptor?.let { |
|||
Log.d(TAG, "parcelFileDescriptor: $it") |
|||
manager.startPlayByFileDescriptor(it) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun onMediaInfoChangedFloat(registerResult: Bundle?) { |
|||
audioManagerFloat?.stopPlay() |
|||
audioManagerFloat = AudioTrackManager() |
|||
|
|||
audioManagerFloat?.printMessageCallback = { |
|||
printer?.print(it) |
|||
} |
|||
|
|||
audioManagerFloat?.enterForeground = { |
|||
Log.d(TAG, "enter foreground=$it") |
|||
val intent = Intent(this, PlayerService::class.java) |
|||
intent.putExtra(KEY_ENTER_FOREGROUND, it) |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
startForegroundService(intent) |
|||
} else { |
|||
startService(intent) |
|||
} |
|||
} |
|||
|
|||
val mediaInfo = registerResult?.getParcelable<Data.MediaInfo>(Keys.API_PARAM_KEY_MEDIA_INFO) |
|||
mediaInfo ?: return |
|||
Log.d(TAG, "initData, mediaInfo=$mediaInfo") |
|||
|
|||
audioManagerFloat?.let { manager -> |
|||
manager.stopPlay() |
|||
manager.initData( |
|||
mediaInfo.sampleRateInHz, |
|||
if (mediaInfo.channelConfig == 2) AudioFormat.CHANNEL_IN_STEREO else AudioFormat.CHANNEL_IN_MONO, |
|||
mediaInfo.audioFormat |
|||
) |
|||
|
|||
val parcelFileDescriptor = |
|||
registerResult.getParcelable<ParcelFileDescriptor>(Keys.API_PARAM_KEY_PCM_FILE_DESCRIPTOR_FLOAT) |
|||
parcelFileDescriptor?.let { |
|||
Log.d(TAG, "parcelFileDescriptor: $it") |
|||
manager.startPlayByFileDescriptor(it) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun bindService() { |
|||
qPlayBindHelper.ensureQQMusicBindByStartProcess { success -> |
|||
if (success && qPlayBindHelper.isBindQQMusic()) { |
|||
qqMusicApi = qPlayBindHelper.getQQMusicApi() |
|||
|
|||
val bundle = Bundle().apply { |
|||
putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) |
|||
putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM) |
|||
} |
|||
val result = qqMusicApi?.execute("hi", bundle) |
|||
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) |
|||
if (code == ErrorCodes.ERROR_API_NO_PERMISSION) { |
|||
val time = System.currentTimeMillis() |
|||
val nonce = time.toString() |
|||
val encryptString = OpenIDHelper.getEncryptString(nonce) |
|||
CommonCmd.verifyCallerIdentity( |
|||
this, |
|||
Config.OPENID_APPID, |
|||
packageName, |
|||
encryptString, |
|||
"qqmusicapidemo://" |
|||
) |
|||
} else { |
|||
Toast.makeText(this@PlayerService, "授权成功", Toast.LENGTH_SHORT).show() |
|||
qqMusicApi?.registerEventListener( |
|||
arrayListOf( |
|||
Events.API_EVENT_PLAY_STATE_CHANGED, |
|||
Events.API_EVENT_PLAY_SONG_CHANGED, |
|||
Events.API_EVENT_MEDIA_INFO_CHANGED |
|||
), eventListener |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun isPlaying(): Boolean { |
|||
Log.d(TAG, "[isPlaying]curPlayState:$curPlayState") |
|||
return (curPlayState == PlayState.STARTED |
|||
|| curPlayState == PlayState.INITIALIZED |
|||
|| curPlayState == PlayState.PREPARED |
|||
|| curPlayState == PlayState.PREPARING |
|||
|| curPlayState == PlayState.BUFFERING) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Bundle 扩展函数,用于将 Bundle 转换为可打印的字符串 |
|||
*/ |
|||
fun Bundle.toPrintableString(): String { |
|||
val sb = StringBuilder() |
|||
sb.append("{") |
|||
|
|||
for (key in keySet()) { |
|||
if (sb.length > 1) { |
|||
sb.append(", ") |
|||
} |
|||
|
|||
val value = get(key) |
|||
sb.append(key).append("=") |
|||
|
|||
when (value) { |
|||
is String -> sb.append("\"$value\"") |
|||
is Bundle -> sb.append(value.toPrintableString()) |
|||
is Array<*> -> sb.append(value.contentToString()) |
|||
is IntArray -> sb.append(value.contentToString()) |
|||
is LongArray -> sb.append(value.contentToString()) |
|||
is FloatArray -> sb.append(value.contentToString()) |
|||
is DoubleArray -> sb.append(value.contentToString()) |
|||
is BooleanArray -> sb.append(value.contentToString()) |
|||
is ByteArray -> sb.append(value.contentToString()) |
|||
is CharArray -> sb.append(value.contentToString()) |
|||
is ShortArray -> sb.append(value.contentToString()) |
|||
else -> sb.append(value?.toString() ?: "null") |
|||
} |
|||
} |
|||
|
|||
sb.append("}") |
|||
return sb.toString() |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
package com.yunqiinnovation.qq_music.util; |
|||
|
|||
/** |
|||
* Created by qitianliu on 2022/8/8. |
|||
*/ |
|||
public class BitConverter { |
|||
public static int intFromByteArray(byte[] sizeBuf, int offset) { |
|||
int ret = 0; |
|||
|
|||
for(int i = offset; i < offset + 4; ++i) { |
|||
ret <<= 8; |
|||
ret |= 255 & sizeBuf[i]; |
|||
} |
|||
|
|||
return ret; |
|||
} |
|||
} |
|||
@ -0,0 +1,177 @@ |
|||
package com.yunqiinnovation.qq_music.util |
|||
|
|||
import android.content.ComponentName |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.ServiceConnection |
|||
import android.os.Handler |
|||
import android.os.IBinder |
|||
import android.os.Looper |
|||
import android.util.Log |
|||
import android.widget.Toast |
|||
import com.tencent.qqmusic.third.api.contract.CommonCmd |
|||
import com.tencent.qqmusic.third.api.contract.CommonCmd.AIDL_PLATFORM_TYPE_PHONE |
|||
import com.tencent.qqmusic.third.api.contract.IQQMusicApi |
|||
|
|||
class QPlayBindHelper(private val context: Context, private val bindPlatform: String = AIDL_PLATFORM_TYPE_PHONE) { |
|||
companion object { |
|||
private const val TAG = "QQMusicBindHelper" |
|||
} |
|||
|
|||
private var api: IQQMusicApi? = null |
|||
|
|||
private val handler: Handler by lazy { Handler(Looper.getMainLooper()) } |
|||
|
|||
private var reBinding = false |
|||
private val deathRecipient by lazy { |
|||
IBinder.DeathRecipient { |
|||
Log.e(TAG, "[binderDied] ") |
|||
api = null |
|||
if (reBinding.not()) { |
|||
//reBindQQMusicService() |
|||
} |
|||
} |
|||
} |
|||
|
|||
private val conn = object : ServiceConnection { |
|||
var success: (() -> Unit)? = null |
|||
override fun onServiceDisconnected(name: ComponentName?) { |
|||
Log.e(TAG, "[onServiceDisconnected] ") |
|||
api = null |
|||
if (reBinding.not()) { |
|||
//reBindQQMusicService() |
|||
} |
|||
} |
|||
|
|||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { |
|||
api = IQQMusicApi.Stub.asInterface(service) |
|||
// 注册死亡代理 |
|||
try { |
|||
service?.linkToDeath(deathRecipient, 0) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "[onServiceConnected] link to death exception ", e) |
|||
} |
|||
success?.invoke() |
|||
success = null |
|||
} |
|||
} |
|||
|
|||
fun bindQQMusic(callback: ((Boolean) -> Unit)? = null, retry: Boolean = false) { |
|||
if (isBindQQMusic()) { |
|||
callback?.invoke(true) |
|||
} |
|||
// 必须显式绑定 |
|||
val intent = getQQMusicApiServiceIntent() |
|||
|
|||
val ret = try { |
|||
Log.d(TAG, "[bindQQMusic] bindService retry:$retry") |
|||
context.bindService(intent, conn, Context.BIND_AUTO_CREATE) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "startBindQQMusic failed") |
|||
false |
|||
} |
|||
if (!ret) { |
|||
// 绑定失败,可能是qq音乐进程未启动,或者可能是魅族系列手机,需要先startService,才能bind |
|||
if (retry.not()) { |
|||
val startComponent = try { |
|||
// 如果qq音乐进程没有启动,高版本的Android系统start service会返回null |
|||
context.startService(intent) |
|||
} catch (e: Throwable) { |
|||
} |
|||
if (startComponent != null) { |
|||
bindQQMusic(callback, true) |
|||
} else { |
|||
callback?.invoke(false) |
|||
} |
|||
} else { |
|||
callback?.invoke(false) |
|||
} |
|||
} else { |
|||
conn.success = { |
|||
callback?.invoke(true) |
|||
} |
|||
} |
|||
} |
|||
|
|||
fun unBindQQMusic() { |
|||
context.unbindServiceSafe(conn) |
|||
} |
|||
|
|||
/** |
|||
* bindService不成功时,调用一次startService,但还是需要调用方延迟后进行重试 |
|||
*/ |
|||
fun ensureQQMusicBind(callback: ((Boolean) -> Unit)? = null) { |
|||
bindQQMusic(callback) |
|||
} |
|||
|
|||
fun ensureQQMusicBindByStartProcess(isStartProcess: Boolean = true, callback: ((Boolean) -> Unit)? = null) { |
|||
bindQQMusic({ succeed -> |
|||
if (!succeed) { |
|||
if (isStartProcess) { |
|||
CommonCmd.startQQMusicProcess(context, context.packageName) |
|||
} |
|||
handler.postDelayed({ |
|||
ensureQQMusicBindByStartProcess(false, callback) |
|||
}, 800) |
|||
} else { |
|||
callback?.invoke(true) |
|||
} |
|||
}, true) |
|||
} |
|||
|
|||
fun isBindQQMusic() = api != null |
|||
|
|||
fun getQQMusicApi() = api |
|||
|
|||
private fun getQQMusicApiServiceIntent(): Intent { |
|||
var intent = Intent("com.tencent.qqmusic.third.api.QQMusicApiService") |
|||
// 必须显式绑定 |
|||
when (bindPlatform) { |
|||
CommonCmd.AIDL_PLATFORM_TYPE_PHONE -> { |
|||
intent = Intent("com.tencent.qqmusic.third.api.QQMusicApiService") |
|||
intent.`package` = "com.tencent.qqmusic" |
|||
} |
|||
CommonCmd.AIDL_PLATFORM_TYPE_CAR -> { |
|||
intent = Intent("com.tencent.qqmusiccar.third.api.QQMusicApiService") |
|||
intent.`package` = "com.tencent.qqmusiccar" |
|||
} |
|||
CommonCmd.AIDL_PLATFORM_TYPE_TV -> { |
|||
intent = Intent("com.tencent.qqmusictv.third.api.QQMusicApiService") |
|||
intent.`package` = "com.tencent.qqmusictv" |
|||
} |
|||
else -> { |
|||
Log.e(TAG, "platform 不匹配") |
|||
Toast.makeText(context, "请先在Config中填写配置信息!", Toast.LENGTH_SHORT).show() |
|||
} |
|||
} |
|||
return intent |
|||
} |
|||
|
|||
/** |
|||
* 重新绑定qq音乐 |
|||
*/ |
|||
private fun reBindQQMusicService() { |
|||
CommonCmd.startQQMusicProcess(context, context.packageName) |
|||
handler.postDelayed(Runnable { |
|||
ensureQQMusicBind { |
|||
if (it) { |
|||
Log.e(TAG, "重新绑定成功") |
|||
reBinding = false |
|||
} else { |
|||
Log.e(TAG, "重新绑定失败") |
|||
reBindQQMusicService() |
|||
} |
|||
} |
|||
}, 200) |
|||
reBinding = true |
|||
} |
|||
|
|||
} |
|||
|
|||
fun Context.unbindServiceSafe(conn: ServiceConnection) { |
|||
try { |
|||
unbindService(conn) |
|||
} catch (e: Throwable) { |
|||
Log.e("CommExt", "unbindService failed", e) |
|||
} |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
package com.yunqiinnovation.qq_music.util |
|||
|
|||
import android.content.Context |
|||
import android.os.Bundle |
|||
import android.os.DeadObjectException |
|||
import android.util.Log |
|||
import com.yunqiinnovation.qq_music.Config |
|||
import com.yunqiinnovation.qq_music.QQMusicLoginManager.Companion.LOGIN_FAILED |
|||
import com.yunqiinnovation.qq_music.openid.OpenIDHelper |
|||
import com.tencent.qqmusic.third.api.contract.* |
|||
|
|||
class QQMusicApiWrapper(val qqMusicApi: IQQMusicApi) { |
|||
companion object { |
|||
const val TAG = "QQMusicApiWrapper" |
|||
|
|||
private const val QQMUSIC_VERSION_10_3_0_1 = 10030001 |
|||
|
|||
private var qqMusicVersion: Int = -1 |
|||
|
|||
} |
|||
|
|||
fun verifyCallerIdentity(context: Context, callbackUri: String?) { |
|||
val time = System.currentTimeMillis() |
|||
val nonce = time.toString() |
|||
val encryptString = OpenIDHelper.getEncryptString(nonce) |
|||
|
|||
// 添加调试日志 |
|||
Log.d(TAG, "verifyCallerIdentity - APPID: ${Config.OPENID_APPID}") |
|||
Log.d(TAG, "verifyCallerIdentity - PackageName: ${context.packageName}") |
|||
Log.d(TAG, "verifyCallerIdentity - CallbackUri: $callbackUri") |
|||
Log.d(TAG, "verifyCallerIdentity - EncryptString: $encryptString") |
|||
|
|||
CommonCmd.verifyCallerIdentity( |
|||
context, |
|||
Config.OPENID_APPID, |
|||
context.packageName, |
|||
encryptString, |
|||
callbackUri |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* qq音乐10.3以后才在 DispacherActivityForThird 中支持 onNewIntent |
|||
*/ |
|||
fun isQQMusicCanHandleOnNewIntent() = getQQMusicVersion() >= QQMUSIC_VERSION_10_3_0_1 |
|||
|
|||
fun getQQMusicVersion(): Int { |
|||
if (qqMusicVersion != -1) { |
|||
return qqMusicVersion |
|||
} |
|||
val bundle = Bundle() |
|||
bundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) |
|||
bundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, CommonCmd.AIDL_PLATFORM_TYPE_PHONE) |
|||
val result = try { |
|||
qqMusicApi.execute("hi", bundle) |
|||
} catch (e: Exception) { |
|||
null |
|||
} |
|||
qqMusicVersion = result?.getInt(Keys.API_RETURN_KEY_VERSION) ?: -1 |
|||
return qqMusicVersion |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 申请授权,[success]返回授权成功后的加密数据;[failed]返回错误码和错误说明,错误码参考[LOGIN_FAILED]等常量 |
|||
*/ |
|||
fun requestAuthNew(success: (String) -> Unit, failed: (Int, String?) -> Unit) { |
|||
Log.i(TAG, "[requestAuth] ") |
|||
val time = System.currentTimeMillis() |
|||
val nonce = time.toString() |
|||
val encryptString = OpenIDHelper.getEncryptString(nonce) //解密&加密 |
|||
val params = Bundle() |
|||
params.putString(Keys.API_RETURN_KEY_ENCRYPT_STRING, encryptString) |
|||
|
|||
try { |
|||
qqMusicApi.executeAsync("requestAuth", params, object : IQQMusicApiCallback.Stub() { |
|||
override fun onReturn(result: Bundle) { |
|||
val code = result.getInt(Keys.API_RETURN_KEY_CODE) |
|||
Log.i(TAG, "[onReturn] code $code") |
|||
if (code == ErrorCodes.ERROR_OK) { |
|||
val qmEncryptString = |
|||
result.getString(Keys.API_RETURN_KEY_ENCRYPT_STRING) |
|||
if (qmEncryptString != null) { |
|||
success(qmEncryptString) |
|||
} else { |
|||
failed(LOGIN_FAILED, "qmEncryptString is null") |
|||
} |
|||
} else { |
|||
val errorMsg = result.getString(Keys.API_RETURN_KEY_ERROR) |
|||
failed(LOGIN_FAILED, errorMsg) |
|||
} |
|||
} |
|||
}) |
|||
} catch (e: Exception) { |
|||
if (e is DeadObjectException) { |
|||
Log.e(TAG, "[requestAuthInternal] dead object exception ", e) |
|||
failed(LOGIN_FAILED, "service is dead") |
|||
} else { |
|||
Log.e(TAG, "[requestAuthInternal] other exception ", e) |
|||
failed(LOGIN_FAILED, "other exception[${e.message}]") |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
<vector xmlns:aapt="http://schemas.android.com/aapt" |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:width="108dp" |
|||
android:height="108dp" |
|||
android:viewportHeight="108" |
|||
android:viewportWidth="108"> |
|||
<path |
|||
android:fillType="evenOdd" |
|||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z" |
|||
android:strokeColor="#00000000" |
|||
android:strokeWidth="1"> |
|||
<aapt:attr name="android:fillColor"> |
|||
<gradient |
|||
android:endX="78.5885" |
|||
android:endY="90.9159" |
|||
android:startX="48.7653" |
|||
android:startY="61.0927" |
|||
android:type="linear"> |
|||
<item |
|||
android:color="#44000000" |
|||
android:offset="0.0"/> |
|||
<item |
|||
android:color="#00000000" |
|||
android:offset="1.0"/> |
|||
</gradient> |
|||
</aapt:attr> |
|||
</path> |
|||
<path |
|||
android:fillColor="#FFFFFF" |
|||
android:fillType="nonZero" |
|||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z" |
|||
android:strokeColor="#00000000" |
|||
android:strokeWidth="1"/> |
|||
</vector> |
|||
@ -0,0 +1,171 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<vector |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:width="108dp" |
|||
android:height="108dp" |
|||
android:viewportHeight="108" |
|||
android:viewportWidth="108"> |
|||
<path |
|||
android:fillColor="#26A69A" |
|||
android:pathData="M0,0h108v108h-108z"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M9,0L9,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M19,0L19,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M29,0L29,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M39,0L39,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M49,0L49,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M59,0L59,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M69,0L69,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M79,0L79,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M89,0L89,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M99,0L99,108" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,9L108,9" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,19L108,19" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,29L108,29" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,39L108,39" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,49L108,49" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,59L108,59" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,69L108,69" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,79L108,79" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,89L108,89" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M0,99L108,99" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M19,29L89,29" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M19,39L89,39" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M19,49L89,49" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M19,59L89,59" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M19,69L89,69" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M19,79L89,79" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M29,19L29,89" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M39,19L39,89" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M49,19L49,89" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M59,19L59,89" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M69,19L69,89" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
<path |
|||
android:fillColor="#00000000" |
|||
android:pathData="M79,19L79,89" |
|||
android:strokeColor="#33FFFFFF" |
|||
android:strokeWidth="0.8"/> |
|||
</vector> |
|||
@ -0,0 +1,8 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
tools:context="com.tencent.qqmusic.api.demo.CallbackActivity"> |
|||
|
|||
</android.support.constraint.ConstraintLayout> |
|||
@ -0,0 +1,17 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
tools:context=".ExampleListActivity"> |
|||
|
|||
<ListView |
|||
android:id="@+id/lv_examples" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
app:layout_constraintBottom_toBottomOf="parent" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toTopOf="parent" /> |
|||
</android.support.constraint.ConstraintLayout> |
|||
@ -0,0 +1,20 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:orientation="vertical"> |
|||
|
|||
<android.support.v7.widget.AppCompatTextView |
|||
android:paddingVertical="15dp" |
|||
android:paddingHorizontal="15dp" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="@string/login_hint" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_bind_service" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="login" /> |
|||
|
|||
</LinearLayout> |
|||
@ -0,0 +1,170 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
tools:context="com.tencent.qqmusic.api.demo.MainActivity"> |
|||
|
|||
<android.support.design.widget.AppBarLayout |
|||
android:id="@+id/appbar_layout" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:theme="@style/AppTheme.AppBarOverlay" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toTopOf="parent"> |
|||
|
|||
<android.support.v7.widget.Toolbar |
|||
android:id="@+id/toolbar" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="?attr/actionBarSize" |
|||
android:background="?attr/colorPrimary" |
|||
app:popupTheme="@style/AppTheme.PopupOverlay" /> |
|||
|
|||
</android.support.design.widget.AppBarLayout> |
|||
|
|||
<ScrollView |
|||
android:id="@+id/scrollView2" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="5dp" |
|||
app:layout_constraintTop_toBottomOf="@id/appbar_layout"> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:orientation="vertical"> |
|||
|
|||
<TextView |
|||
android:id="@+id/tv_connect_state" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginTop="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:text="" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/tv_result" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginTop="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:text="" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/tv_current_song" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginLeft="8dp" |
|||
android:layout_marginTop="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:layout_marginRight="8dp" |
|||
android:text="" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/tv_song_list_size" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginLeft="8dp" |
|||
android:layout_marginTop="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:layout_marginRight="8dp" |
|||
android:text="songListSize:" /> |
|||
|
|||
<android.support.design.widget.TextInputLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:hint="action"> |
|||
|
|||
<AutoCompleteTextView |
|||
android:id="@+id/et_action" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginTop="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:completionThreshold="0" |
|||
android:ems="10" |
|||
android:inputType="textPersonName" |
|||
android:text="hi" /> |
|||
</android.support.design.widget.TextInputLayout> |
|||
|
|||
<android.support.design.widget.TextInputLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:hint="action params"> |
|||
|
|||
<EditText |
|||
android:id="@+id/et_params_key" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginTop="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:hint="enter key here" |
|||
android:text="midList" |
|||
app:layout_constraintEnd_toEndOf="parent" |
|||
app:layout_constraintStart_toStartOf="parent" |
|||
app:layout_constraintTop_toBottomOf="@+id/et_action" /> |
|||
</android.support.design.widget.TextInputLayout> |
|||
|
|||
<android.support.design.widget.TextInputLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:hint="action params value"> |
|||
|
|||
<EditText |
|||
android:id="@+id/et_params_value" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginTop="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:layout_marginBottom="8dp" |
|||
android:ems="10" |
|||
android:inputType="textMultiLine" |
|||
android:text="004Sw1Bp0tFS18" /> |
|||
</android.support.design.widget.TextInputLayout> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_execute" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:layout_marginBottom="8dp" |
|||
android:text="Execute(同步接口)" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_execute_async" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginStart="8dp" |
|||
android:layout_marginEnd="8dp" |
|||
android:layout_marginBottom="8dp" |
|||
android:text="Execute Async(异步接口)" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_register" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="register" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_unregister" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="unregister" /> |
|||
|
|||
|
|||
</LinearLayout> |
|||
|
|||
</ScrollView> |
|||
|
|||
|
|||
</android.support.constraint.ConstraintLayout> |
|||
@ -0,0 +1,48 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:orientation="vertical"> |
|||
|
|||
<android.support.v7.widget.AppCompatTextView |
|||
android:id="@+id/tv_information" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:gravity="center_horizontal" |
|||
/> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_bind_service" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="bind service" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_start_pcm_mode" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="Start pcm mode" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_stop_pcm_mode" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="Stop pcm mode" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_play" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="Resume/Pause" /> |
|||
|
|||
<Button |
|||
android:id="@+id/bt_next" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:text="Play next" /> |
|||
|
|||
<android.support.v7.widget.AppCompatTextView |
|||
android:text="@string/pcm_hint" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content"/> |
|||
</LinearLayout> |
|||
@ -0,0 +1,220 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|||
xmlns:tools="http://schemas.android.com/tools" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
tools:context=".VisualActivity"> |
|||
|
|||
<android.support.design.widget.AppBarLayout |
|||
android:id="@+id/appbar_layout" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:theme="@style/AppTheme.AppBarOverlay"> |
|||
|
|||
<android.support.v7.widget.Toolbar |
|||
android:id="@+id/toolbar" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="?attr/actionBarSize" |
|||
android:background="?attr/colorPrimary" |
|||
app:popupTheme="@style/AppTheme.PopupOverlay" /> |
|||
|
|||
</android.support.design.widget.AppBarLayout> |
|||
|
|||
<RelativeLayout |
|||
android:id="@+id/playerPanel" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_below="@id/appbar_layout" |
|||
android:layout_marginTop="5dp"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/SongPic" |
|||
android:layout_width="50dp" |
|||
android:layout_height="50dp" |
|||
android:layout_marginLeft="5dp" |
|||
android:layout_marginTop="5dp" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/txtPlayTime" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="25dp" |
|||
android:layout_alignParentRight="true" |
|||
android:layout_marginTop="5dp" |
|||
android:layout_marginRight="5dp" |
|||
android:text="时间" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/txtSongInfos" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="25dp" |
|||
android:layout_marginLeft="5dp" |
|||
android:layout_marginTop="5dp" |
|||
android:layout_toRightOf="@+id/SongPic" |
|||
android:ems="10" |
|||
android:text="歌曲信息" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/txtAlbum" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="25dp" |
|||
android:layout_below="@+id/txtSongInfos" |
|||
android:layout_marginLeft="5dp" |
|||
android:layout_marginTop="5dp" |
|||
android:layout_toRightOf="@+id/SongPic" |
|||
android:text="专辑-歌手" /> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/ll_button" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_below="@+id/SongPic" |
|||
android:orientation="horizontal"> |
|||
|
|||
<Button |
|||
android:id="@+id/btnActive" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:text="授权" /> |
|||
|
|||
<Button |
|||
android:id="@+id/btnPlayPre" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:onClick="onPlayPre" |
|||
android:text="上一首" /> |
|||
|
|||
<Button |
|||
android:id="@+id/btnPlayPause" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:onClick="onPlayPause" |
|||
android:text="播放" /> |
|||
|
|||
<Button |
|||
android:id="@+id/btnPlayNext" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:onClick="onPlayNext" |
|||
android:text="下一首" /> |
|||
|
|||
<Button |
|||
android:id="@+id/btnLove" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_weight="1" |
|||
android:onClick="onLoveClick" |
|||
android:text="收藏" /> |
|||
|
|||
</LinearLayout> |
|||
|
|||
<LinearLayout |
|||
android:id="@+id/ll_opi_button" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_below="@+id/ll_button" |
|||
android:orientation="horizontal"> |
|||
|
|||
<Button |
|||
android:id="@+id/btnOpiSearch" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:onClick="onClickOpiSearch" |
|||
android:text="Opi搜索" /> |
|||
|
|||
<Button |
|||
android:id="@+id/btnOpiMvTag" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:onClick="onClickOpiMvTag" |
|||
android:text="Opi重命名" /> |
|||
|
|||
<Button |
|||
android:id="@+id/btnSchemeAction" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:onClick="playSongMid" |
|||
android:text="自定义跳转" /> |
|||
<Button |
|||
android:id="@+id/btnSearch" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:onClick="onClickSearch" |
|||
android:text="搜索" /> |
|||
</LinearLayout> |
|||
|
|||
|
|||
<LinearLayout |
|||
android:id="@+id/ll_test_1" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="wrap_content" |
|||
android:layout_below="@+id/ll_opi_button" |
|||
android:orientation="horizontal"> |
|||
|
|||
<Button |
|||
android:id="@+id/btnLyric" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:onClick="onClickLyric" |
|||
android:text="歌词" /> |
|||
|
|||
<Button |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:onClick="testGetFolderList" |
|||
android:text="快退1s" /> |
|||
|
|||
<Button |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:onClick="testPlayList" |
|||
android:text="快进1s" /> |
|||
</LinearLayout> |
|||
|
|||
<TextView |
|||
android:id="@+id/txtResult" |
|||
android:layout_width="fill_parent" |
|||
android:layout_height="35dp" |
|||
android:layout_below="@+id/ll_test_1" |
|||
android:ems="10" |
|||
android:text="Debug informations" /> |
|||
|
|||
<ProgressBar |
|||
android:id="@+id/ProgressPlay" |
|||
style="@android:style/Widget.ProgressBar.Horizontal" |
|||
android:layout_width="fill_parent" |
|||
android:layout_height="5dp" |
|||
android:layout_below="@+id/txtResult" |
|||
android:layout_marginTop="2dp" |
|||
android:max="1000" |
|||
android:progress="0" |
|||
android:secondaryProgress="0" /> |
|||
</RelativeLayout> |
|||
|
|||
|
|||
<RelativeLayout |
|||
android:id="@+id/listview_container" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_below="@+id/playerPanel"> |
|||
|
|||
<ListView |
|||
android:id="@+id/listview_folder" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="5dp" /> |
|||
|
|||
<ListView |
|||
android:id="@+id/listview_song" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginTop="5dp" |
|||
android:visibility="gone" /> |
|||
</RelativeLayout> |
|||
|
|||
|
|||
</RelativeLayout> |
|||
@ -0,0 +1,21 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent"> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="match_parent" |
|||
android:layout_height="60dp" |
|||
android:orientation="horizontal"> |
|||
|
|||
<TextView |
|||
android:id="@+id/item_title" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginLeft="15dp" |
|||
android:layout_gravity="center_vertical" |
|||
android:text="标题" |
|||
android:textColor="#000" |
|||
android:textSize="20sp"/> |
|||
</LinearLayout> |
|||
</RelativeLayout> |
|||
@ -0,0 +1,45 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent"> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="55dp" |
|||
android:orientation="horizontal"> |
|||
|
|||
<ImageView |
|||
android:id="@+id/item_img" |
|||
android:layout_width="40dp" |
|||
android:layout_height="40dp" |
|||
android:layout_marginLeft="3dp" |
|||
android:layout_gravity="center_vertical" |
|||
android:src="@mipmap/mini_album_default" |
|||
/> |
|||
|
|||
<LinearLayout |
|||
android:layout_width="0dp" |
|||
android:layout_height="wrap_content" |
|||
android:layout_marginLeft="3dp" |
|||
android:layout_weight="2" |
|||
android:orientation="vertical"> |
|||
|
|||
<TextView |
|||
android:id="@+id/item_title" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:text="标题" |
|||
android:textColor="#000" |
|||
android:textSize="20sp"/> |
|||
|
|||
<TextView |
|||
android:id="@+id/item_content" |
|||
android:layout_width="wrap_content" |
|||
android:layout_height="wrap_content" |
|||
android:text="内容" |
|||
android:textColor="#000" |
|||
android:textSize="16sp"/> |
|||
|
|||
</LinearLayout> |
|||
</LinearLayout> |
|||
</RelativeLayout> |
|||
@ -0,0 +1,24 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="match_parent" |
|||
android:layout_marginBottom="5dp" |
|||
android:background="@color/colorPrimary" |
|||
android:orientation="vertical"> |
|||
|
|||
<View |
|||
android:layout_width="match_parent" |
|||
android:layout_height="2dp" |
|||
android:background="@color/colorPrimaryDark" /> |
|||
|
|||
<TextView |
|||
android:id="@+id/text_test_api" |
|||
android:layout_width="match_parent" |
|||
android:layout_height="45dp" |
|||
android:layout_gravity="center" |
|||
android:gravity="center" |
|||
android:text="点我测试!" |
|||
android:textColor="@color/white" |
|||
android:textSize="15sp" /> |
|||
|
|||
</LinearLayout> |
|||
@ -0,0 +1,14 @@ |
|||
<menu xmlns:android="http://schemas.android.com/apk/res/android" |
|||
xmlns:app="http://schemas.android.com/apk/res-auto"> |
|||
|
|||
<item |
|||
android:id="@+id/item_bind_platform" |
|||
android:title="设备类型:" |
|||
app:showAsAction="always" |
|||
/> |
|||
|
|||
<item |
|||
android:id="@+id/item_goto_test_api" |
|||
android:title="点我测试!" |
|||
app:showAsAction="never" /> |
|||
</menu> |
|||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,18 @@ |
|||
<resources> |
|||
<string-array name="bind_platform_array"> |
|||
<item>手机</item> |
|||
<item>TV</item> |
|||
<item>Car</item> |
|||
</string-array> |
|||
<string-array name="sear_type"> |
|||
<item>专辑</item> |
|||
<item>歌单</item> |
|||
<item>歌词</item> |
|||
<item>混合搜索</item> |
|||
<item>mv</item> |
|||
<item>歌手歌曲</item> |
|||
<item>歌手</item> |
|||
<item>歌曲</item> |
|||
<item>用户</item> |
|||
</string-array> |
|||
</resources> |
|||
@ -0,0 +1,7 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<resources> |
|||
<color name="colorPrimary">#3F51B5</color> |
|||
<color name="colorPrimaryDark">#303F9F</color> |
|||
<color name="colorAccent">#FF4081</color> |
|||
<color name="white">#ffffff</color> |
|||
</resources> |
|||
@ -0,0 +1,4 @@ |
|||
<resources> |
|||
<dimen name="fab_margin">16dp</dimen> |
|||
<dimen name="dimen_width">80dp</dimen> |
|||
</resources> |
|||
@ -0,0 +1,12 @@ |
|||
<resources> |
|||
<string name="app_name">ApiDemoQQMusic</string> |
|||
<string name="title_activity_qrcode">QRCodeActivity</string> |
|||
<string name="login_hint">使用QQ音乐手机端完成登录获取Openid OpenToken的例子\n |
|||
由于同一scheme在CallbackActivity和LoginExampleActivity都需要使用,避免混乱,AndroidManifest.xml中 |
|||
默认将LoginExampleActivity的intent-filter注释,完整体验LoginExampleActivity前将其反注释,并将CallbackActivity的intent-filter注释进行注释"</string> |
|||
<string name="pcm_hint">1. 完成如[API调用方式](https://developer.y.qq.com/qplayaidl.html#/overview?id=二、api调用方式)中提及到的与QQMusicApiService的绑定流程。 |
|||
2. 调用`executeAsync("startPcmMode")`使QQ音乐进入Pcm模式,如QQ音乐正在播放中,会停止播放。Client在该方法中传入callback,等待QQ音乐解码Pcm数据时回调。 |
|||
3. QQ音乐开始播放时,Client可主动调用`execute("playMusic")`,QQ音乐会开始解码并在上一步骤的callback中返回mediaInfo以及pcmFileDescriptor,mediaInfo为Data.MediaInfo类型,提供播放器初始化时使用的参数,pcmFileDescriptor为ParcelFileDescriptor类型,为Pcm数据传输用的文件描述符。 |
|||
4. 读取pcmFileDescriptor对应的数据流数据进行播放。 |
|||
5. 使用完毕时,调用`execute("stopPcmMode")`结束Pcm模式。QQ音乐会停止正在播放的音乐。</string> |
|||
</resources> |
|||
@ -0,0 +1,20 @@ |
|||
<resources> |
|||
|
|||
<!-- Base application theme. --> |
|||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> |
|||
<!-- Customize your theme here. --> |
|||
<item name="colorPrimary">@color/colorPrimary</item> |
|||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item> |
|||
<item name="colorAccent">@color/colorAccent</item> |
|||
</style> |
|||
|
|||
<style name="AppTheme.NoActionBar"> |
|||
<item name="windowActionBar">false</item> |
|||
<item name="windowNoTitle">true</item> |
|||
</style> |
|||
|
|||
<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar"/> |
|||
|
|||
<style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light"/> |
|||
|
|||
</resources> |
|||
@ -0,0 +1,38 @@ |
|||
.idea/ |
|||
.vagrant/ |
|||
.sconsign.dblite |
|||
.svn/ |
|||
|
|||
.DS_Store |
|||
*.swp |
|||
profile |
|||
|
|||
DerivedData/ |
|||
build/ |
|||
GeneratedPluginRegistrant.h |
|||
GeneratedPluginRegistrant.m |
|||
|
|||
.generated/ |
|||
|
|||
*.pbxuser |
|||
*.mode1v3 |
|||
*.mode2v3 |
|||
*.perspectivev3 |
|||
|
|||
!default.pbxuser |
|||
!default.mode1v3 |
|||
!default.mode2v3 |
|||
!default.perspectivev3 |
|||
|
|||
xcuserdata |
|||
|
|||
*.moved-aside |
|||
|
|||
*.pyc |
|||
*sync/ |
|||
Icon? |
|||
.tags* |
|||
|
|||
/Flutter/Generated.xcconfig |
|||
/Flutter/ephemeral/ |
|||
/Flutter/flutter_export_environment.sh |
|||
@ -0,0 +1,29 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# Run `pod lib lint qq_music.podspec` to validate before publishing. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'qq_music' |
|||
s.version = '0.0.1' |
|||
s.summary = 'A new Flutter project.' |
|||
s.description = <<-DESC |
|||
A new Flutter project. |
|||
DESC |
|||
s.homepage = 'http://example.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'Your Company' => 'email@example.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'qq_music/Sources/qq_music/**/*' |
|||
s.dependency 'Flutter' |
|||
s.platform = :ios, '12.0' |
|||
|
|||
# Flutter.framework does not contain a i386 slice. |
|||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
|||
s.swift_version = '5.0' |
|||
|
|||
# If your plugin requires a privacy manifest, for example if it uses any |
|||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your |
|||
# plugin's privacy impact, and then uncomment this line. For more information, |
|||
# see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files |
|||
# s.resource_bundles = {'qq_music_privacy' => ['qq_music/Sources/qq_music/PrivacyInfo.xcprivacy']} |
|||
end |
|||
@ -0,0 +1,32 @@ |
|||
// swift-tools-version: 5.9 |
|||
// The swift-tools-version declares the minimum version of Swift required to build this package. |
|||
|
|||
import PackageDescription |
|||
|
|||
let package = Package( |
|||
name: "qq_music", |
|||
platforms: [ |
|||
.iOS("12.0") |
|||
], |
|||
products: [ |
|||
.library(name: "qq-music", targets: ["qq_music"]) |
|||
], |
|||
dependencies: [], |
|||
targets: [ |
|||
.target( |
|||
name: "qq_music", |
|||
dependencies: [], |
|||
resources: [ |
|||
// If your plugin requires a privacy manifest, for example if it uses any required |
|||
// reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's |
|||
// privacy impact, and then uncomment these lines. For more information, see |
|||
// https://developer.apple.com/documentation/bundleresources/privacy_manifest_files |
|||
// .process("PrivacyInfo.xcprivacy"), |
|||
|
|||
// If you have other resources that need to be bundled with your plugin, refer to |
|||
// the following instructions to add them: |
|||
// https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package |
|||
] |
|||
) |
|||
] |
|||
) |
|||
@ -0,0 +1,14 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>NSPrivacyTrackingDomains</key> |
|||
<array/> |
|||
<key>NSPrivacyAccessedAPITypes</key> |
|||
<array/> |
|||
<key>NSPrivacyCollectedDataTypes</key> |
|||
<array/> |
|||
<key>NSPrivacyTracking</key> |
|||
<false/> |
|||
</dict> |
|||
</plist> |
|||
@ -0,0 +1,19 @@ |
|||
import Flutter |
|||
import UIKit |
|||
|
|||
public class QqMusicPlugin: NSObject, FlutterPlugin { |
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let channel = FlutterMethodChannel(name: "qq_music", binaryMessenger: registrar.messenger()) |
|||
let instance = QqMusicPlugin() |
|||
registrar.addMethodCallDelegate(instance, channel: channel) |
|||
} |
|||
|
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "getPlatformVersion": |
|||
result("iOS " + UIDevice.current.systemVersion) |
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,279 @@ |
|||
import 'package:flutter/services.dart'; |
|||
|
|||
class QqMusic { |
|||
static final QqMusic _instance = QqMusic._internal(); |
|||
|
|||
/// 获取单例 |
|||
factory QqMusic() => _instance; |
|||
|
|||
// 方法通道 |
|||
final MethodChannel _methodChannel = const MethodChannel( |
|||
'com.yunqiinnovation.qq_music', |
|||
); |
|||
|
|||
// 内部构造函数 |
|||
QqMusic._internal() {} |
|||
|
|||
/// 登陆qq音乐 |
|||
/// 返回登录结果,包含 openId, openToken, expireTime |
|||
Future<Map<String, dynamic>> login() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('login'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return {'success': false, 'error': e.message ?? '登录失败', 'code': e.code}; |
|||
} |
|||
} |
|||
|
|||
/// 搜索歌曲 |
|||
/// [keyword] 搜索关键词 |
|||
/// [searchType] 搜索类型 0-歌曲 1-专辑 2-歌手 |
|||
Future<Map<String, dynamic>> searchSongs({ |
|||
required String keyword, |
|||
int searchType = 7, // Data.SearchType.SEARCH_TYPE_SONG |
|||
}) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('searchSongs', { |
|||
'keyword': keyword, |
|||
'searchType': searchType, |
|||
}); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return {'success': false, 'error': e.message ?? '搜索失败', 'code': e.code}; |
|||
} |
|||
} |
|||
|
|||
/// 播放歌曲列表 |
|||
/// [songMids] 歌曲MID列表 |
|||
/// [index] 开始播放的索引,默认为0 |
|||
Future<Map<String, dynamic>> playSongs({ |
|||
required List<String> songMids, |
|||
int index = 0, |
|||
}) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('playSongs', { |
|||
'songMids': songMids, |
|||
'index': index, |
|||
}); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return {'success': false, 'error': e.message ?? '播放失败', 'code': e.code}; |
|||
} |
|||
} |
|||
|
|||
/// 播放音乐 |
|||
Future<Map<String, dynamic>> playMusic() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('playMusic'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return {'success': false, 'error': e.message ?? '播放失败', 'code': e.code}; |
|||
} |
|||
} |
|||
|
|||
/// 暂停音乐 |
|||
Future<Map<String, dynamic>> pauseMusic() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('pauseMusic'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return {'success': false, 'error': e.message ?? '暂停失败', 'code': e.code}; |
|||
} |
|||
} |
|||
|
|||
/// 恢复播放 |
|||
Future<Map<String, dynamic>> resumeMusic() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('resumeMusic'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return {'success': false, 'error': e.message ?? '恢复播放失败', 'code': e.code}; |
|||
} |
|||
} |
|||
|
|||
/// 停止音乐 |
|||
Future<Map<String, dynamic>> stopMusic() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('stopMusic'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return {'success': false, 'error': e.message ?? '停止失败', 'code': e.code}; |
|||
} |
|||
} |
|||
|
|||
/// 下一首 |
|||
Future<Map<String, dynamic>> skipToNext() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('skipToNext'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return { |
|||
'success': false, |
|||
'error': e.message ?? '切换下一首失败', |
|||
'code': e.code, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
/// 上一首 |
|||
Future<Map<String, dynamic>> skipToPrevious() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('skipToPrevious'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return { |
|||
'success': false, |
|||
'error': e.message ?? '切换上一首失败', |
|||
'code': e.code, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
/// 获取播放状态 |
|||
Future<Map<String, dynamic>> getPlaybackState() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('getPlaybackState'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return { |
|||
'success': false, |
|||
'error': e.message ?? '获取播放状态失败', |
|||
'code': e.code, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
/// 获取当前播放歌曲 |
|||
Future<Map<String, dynamic>> getCurrentSong() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('getCurrentSong'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return { |
|||
'success': false, |
|||
'error': e.message ?? '获取当前歌曲失败', |
|||
'code': e.code, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
/// 获取播放时长 |
|||
Future<Map<String, dynamic>> getPlayTime() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod('getPlayTime'); |
|||
return Map<String, dynamic>.from(result); |
|||
} on PlatformException catch (e) { |
|||
return { |
|||
'success': false, |
|||
'error': e.message ?? '获取播放时长失败', |
|||
'code': e.code, |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 登录结果类 |
|||
class QQMusicLoginResult { |
|||
final bool success; |
|||
final String? openId; |
|||
final String? openToken; |
|||
final String? expireTime; |
|||
final String? error; |
|||
final String? code; |
|||
|
|||
QQMusicLoginResult({ |
|||
required this.success, |
|||
this.openId, |
|||
this.openToken, |
|||
this.expireTime, |
|||
this.error, |
|||
this.code, |
|||
}); |
|||
|
|||
factory QQMusicLoginResult.fromMap(Map<String, dynamic> map) { |
|||
return QQMusicLoginResult( |
|||
success: map['success'] ?? false, |
|||
openId: map['openId'], |
|||
openToken: map['openToken'], |
|||
expireTime: map['expireTime'], |
|||
error: map['error'], |
|||
code: map['code'], |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 搜索结果类 |
|||
class QQMusicSearchResult { |
|||
final bool success; |
|||
final List<QQMusicSong>? songs; |
|||
final String? error; |
|||
final String? code; |
|||
|
|||
QQMusicSearchResult({ |
|||
required this.success, |
|||
this.songs, |
|||
this.error, |
|||
this.code, |
|||
}); |
|||
|
|||
factory QQMusicSearchResult.fromMap(Map<String, dynamic> map) { |
|||
List<QQMusicSong>? songs; |
|||
if (map['songs'] != null) { |
|||
songs = (map['songs'] as List) |
|||
.map((song) => QQMusicSong.fromMap(Map<String, dynamic>.from(song))) |
|||
.toList(); |
|||
} |
|||
|
|||
return QQMusicSearchResult( |
|||
success: map['success'] ?? false, |
|||
songs: songs, |
|||
error: map['error'], |
|||
code: map['code'], |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 歌曲信息类 |
|||
class QQMusicSong { |
|||
final String? mid; |
|||
final String? id; |
|||
final String? name; |
|||
final String? singer; |
|||
final String? album; |
|||
final int? duration; |
|||
final String? pic; |
|||
|
|||
QQMusicSong({ |
|||
this.mid, |
|||
this.id, |
|||
this.name, |
|||
this.singer, |
|||
this.album, |
|||
this.duration, |
|||
this.pic, |
|||
}); |
|||
|
|||
factory QQMusicSong.fromMap(Map<String, dynamic> map) { |
|||
return QQMusicSong( |
|||
mid: map['mid'], |
|||
id: map['id'], |
|||
name: map['name'], |
|||
singer: map['singer'], |
|||
album: map['album'], |
|||
duration: map['duration'], |
|||
pic: map['pic'], |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toMap() { |
|||
return { |
|||
'mid': mid, |
|||
'id': id, |
|||
'name': name, |
|||
'singer': singer, |
|||
'album': album, |
|||
'duration': duration, |
|||
'pic': pic, |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
name: qq_music |
|||
description: "A new Flutter project." |
|||
version: 0.0.1 |
|||
homepage: |
|||
|
|||
environment: |
|||
sdk: ^3.8.1 |
|||
flutter: '>=3.3.0' |
|||
|
|||
dependencies: |
|||
flutter: |
|||
sdk: flutter |
|||
plugin_platform_interface: ^2.0.2 |
|||
|
|||
dev_dependencies: |
|||
flutter_test: |
|||
sdk: flutter |
|||
flutter_lints: ^5.0.0 |
|||
|
|||
flutter: |
|||
|
|||
plugin: |
|||
platforms: |
|||
android: |
|||
package: com.yunqiinnovation.qq_music |
|||
pluginClass: QqMusicPlugin |
|||
ios: |
|||
pluginClass: QqMusicPlugin |
|||