From cd90ff0a3302ad716852195f55b092995c5f9f08 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Tue, 29 Jul 2025 20:34:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0qq=E9=9F=B3=E4=B9=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 1 + android/settings.gradle.kts | 6 +- lib/core/bindings/initial_binding.dart | 4 + lib/data/services/qq_musoc.dart | 157 ++++++ .../qqmusic/bindings/qqmusic_binding.dart | 9 + .../controllers/qqmusic_controller.dart | 326 ++++++++++++ lib/modules/qqmusic/models/song_model.dart | 49 ++ lib/modules/qqmusic/views/qqmusic_view.dart | 242 +++++++++ .../splash/controllers/splash_controller.dart | 4 +- lib/routes/app_pages.dart | 8 + lib/routes/app_routes.dart | 2 + local_plugins/qq_music/.gitignore | 33 ++ local_plugins/qq_music/.metadata | 33 ++ local_plugins/qq_music/CHANGELOG.md | 3 + local_plugins/qq_music/LICENSE | 1 + local_plugins/qq_music/README.md | 15 + local_plugins/qq_music/analysis_options.yaml | 4 + local_plugins/qq_music/android/.gitignore | 9 + local_plugins/qq_music/android/build.gradle | 79 +++ ...qqmusic-innovation-aidl-api-sdk-1.0.26.aar | Bin 0 -> 30029 bytes .../qq_music/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 9 + .../qq_music/IPlayService.aidl | 15 + .../com/yunqiinnovation/qq_music/IPrint.aidl | 8 + .../com/yunqiinnovation/qq_music/ApiSample.kt | 361 +++++++++++++ .../com/yunqiinnovation/qq_music/Config.java | 53 ++ .../qq_music/QQMusicLoginManager.kt | 239 +++++++++ .../qq_music/QQMusicManager.kt | 495 ++++++++++++++++++ .../yunqiinnovation/qq_music/QqMusicPlugin.kt | 393 ++++++++++++++ .../qq_music/openid/OpenIDHelper.java | 64 +++ .../qq_music/openid/RSAUtils.java | 314 +++++++++++ .../qq_music/pcm/AudioTrackManager.kt | 253 +++++++++ .../qq_music/pcm/PcmExampleActivityNew.kt | 79 +++ .../qq_music/pcm/PlayerService.kt | 383 ++++++++++++++ .../qq_music/util/BitConverter.java | 17 + .../qq_music/util/QPlayBindHelper.kt | 177 +++++++ .../qq_music/util/QQMusicApiWrapper.kt | 105 ++++ .../drawable-v24/ic_launcher_foreground.xml | 34 ++ .../res/drawable/ic_launcher_background.xml | 171 ++++++ .../src/main/res/layout/activity_callback.xml | 8 + .../main/res/layout/activity_example_list.xml | 17 + .../res/layout/activity_login_example.xml | 20 + .../src/main/res/layout/activity_main.xml | 170 ++++++ .../main/res/layout/activity_pcm_example.xml | 48 ++ .../src/main/res/layout/activity_visual.xml | 220 ++++++++ .../main/res/layout/folder_list_view_item.xml | 21 + .../main/res/layout/song_list_view_item.xml | 45 ++ .../src/main/res/layout/view_popview_more.xml | 24 + .../src/main/res/menu/toolbar_menu.xml | 14 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3056 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5024 bytes .../res/mipmap-hdpi/mini_album_default.png | Bin 0 -> 2287 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2096 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2858 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4569 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7098 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6464 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10676 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9250 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15523 bytes .../android/src/main/res/values/array.xml | 18 + .../android/src/main/res/values/colors.xml | 7 + .../android/src/main/res/values/dimens.xml | 4 + .../android/src/main/res/values/strings.xml | 12 + .../android/src/main/res/values/styles.xml | 20 + local_plugins/qq_music/ios/.gitignore | 38 ++ local_plugins/qq_music/ios/qq_music.podspec | 29 + .../qq_music/ios/qq_music/Package.swift | 32 ++ .../Sources/qq_music/PrivacyInfo.xcprivacy | 14 + .../Sources/qq_music/QqMusicPlugin.swift | 19 + local_plugins/qq_music/lib/qq_music.dart | 279 ++++++++++ local_plugins/qq_music/pubspec.yaml | 28 + pubspec.yaml | 2 + 73 files changed, 5241 insertions(+), 4 deletions(-) create mode 100644 lib/data/services/qq_musoc.dart create mode 100644 lib/modules/qqmusic/bindings/qqmusic_binding.dart create mode 100644 lib/modules/qqmusic/controllers/qqmusic_controller.dart create mode 100644 lib/modules/qqmusic/models/song_model.dart create mode 100644 lib/modules/qqmusic/views/qqmusic_view.dart create mode 100644 local_plugins/qq_music/.gitignore create mode 100644 local_plugins/qq_music/.metadata create mode 100644 local_plugins/qq_music/CHANGELOG.md create mode 100644 local_plugins/qq_music/LICENSE create mode 100644 local_plugins/qq_music/README.md create mode 100644 local_plugins/qq_music/analysis_options.yaml create mode 100644 local_plugins/qq_music/android/.gitignore create mode 100644 local_plugins/qq_music/android/build.gradle create mode 100644 local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar create mode 100644 local_plugins/qq_music/android/settings.gradle create mode 100644 local_plugins/qq_music/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl create mode 100644 local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PlayerService.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/util/BitConverter.java create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/util/QPlayBindHelper.kt create mode 100644 local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/util/QQMusicApiWrapper.kt create mode 100644 local_plugins/qq_music/android/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 local_plugins/qq_music/android/src/main/res/drawable/ic_launcher_background.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/activity_callback.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/activity_example_list.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/activity_login_example.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/activity_main.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/activity_pcm_example.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/activity_visual.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/folder_list_view_item.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/song_list_view_item.xml create mode 100644 local_plugins/qq_music/android/src/main/res/layout/view_popview_more.xml create mode 100644 local_plugins/qq_music/android/src/main/res/menu/toolbar_menu.xml create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-hdpi/mini_album_default.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 local_plugins/qq_music/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 local_plugins/qq_music/android/src/main/res/values/array.xml create mode 100644 local_plugins/qq_music/android/src/main/res/values/colors.xml create mode 100644 local_plugins/qq_music/android/src/main/res/values/dimens.xml create mode 100644 local_plugins/qq_music/android/src/main/res/values/strings.xml create mode 100644 local_plugins/qq_music/android/src/main/res/values/styles.xml create mode 100644 local_plugins/qq_music/ios/.gitignore create mode 100644 local_plugins/qq_music/ios/qq_music.podspec create mode 100644 local_plugins/qq_music/ios/qq_music/Package.swift create mode 100644 local_plugins/qq_music/ios/qq_music/Sources/qq_music/PrivacyInfo.xcprivacy create mode 100644 local_plugins/qq_music/ios/qq_music/Sources/qq_music/QqMusicPlugin.swift create mode 100644 local_plugins/qq_music/lib/qq_music.dart create mode 100644 local_plugins/qq_music/pubspec.yaml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index de01cd468..d26cbc350 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,6 +5,7 @@ repositories { mavenCentral() flatDir { dirs(project(":spotify_sdk").projectDir.resolve("libs")) + dirs(project(":qq_music").projectDir.resolve("libs")) // 添加这行 } } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 4199b3b58..af6904e7f 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -41,9 +41,10 @@ include(":agent_service") include(":speech") include(":chat_api") include(":bytedance_speech") - +include(":qq_music") // 添加这行 include(":jl_opus") -// 设置azure_speech项目的路径 + +// 设置项目路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") project(":jieli").projectDir = file("../local_plugins/jieli/android") @@ -54,4 +55,5 @@ project(":agent_service").projectDir = file("../local_plugins/agent_service/andr project(":speech").projectDir = file("../local_plugins/speech/android") project(":chat_api").projectDir = file("../local_plugins/chat_api/android") project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/android") +project(":qq_music").projectDir = file("../local_plugins/qq_music/android") // 添加这行 project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android") diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 73826786b..8c98da39d 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -1,3 +1,5 @@ +import 'package:voitrans/data/services/qq_musoc.dart'; + import '../../../data/services/user_portrait.dart'; import '../../../data/services/location_manager.dart'; @@ -63,6 +65,8 @@ class InitialBinding extends Bindings { // 会议助手音频上传 Get.lazyPut(() => UserportraitManager(), fenix: true); + // qq音乐服务 + Get.lazyPut(() => QqMusocService(), fenix: true); // 杰理蓝牙服务 diff --git a/lib/data/services/qq_musoc.dart b/lib/data/services/qq_musoc.dart new file mode 100644 index 000000000..d26a90241 --- /dev/null +++ b/lib/data/services/qq_musoc.dart @@ -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(); + + var isInitialized = false; + // 音乐服务实例 + final QqMusic _qqMusic = QqMusic(); + + /// 初始化 + @override + void onInit() { + super.onInit(); + } + + /// 登录QQ音乐 + Future> 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> 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> playSongs({ + required List 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> playMusic() async { + try { + final result = await _qqMusic.playMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '播放失败: ${e.toString()}'}; + } + } + + /// 暂停音乐 + Future> pauseMusic() async { + try { + final result = await _qqMusic.pauseMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '暂停失败: ${e.toString()}'}; + } + } + + /// 继续播放 + Future> resumeMusic() async { + try { + final result = await _qqMusic.resumeMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '继续播放失败: ${e.toString()}'}; + } + } + + /// 停止音乐 + Future> stopMusic() async { + try { + final result = await _qqMusic.stopMusic(); + return result; + } catch (e) { + return {'success': false, 'error': '停止失败: ${e.toString()}'}; + } + } + + /// 下一首 + Future> skipToNext() async { + try { + final result = await _qqMusic.skipToNext(); + return result; + } catch (e) { + return {'success': false, 'error': '下一首失败: ${e.toString()}'}; + } + } + + /// 上一首 + Future> skipToPrevious() async { + try { + final result = await _qqMusic.skipToPrevious(); + return result; + } catch (e) { + return {'success': false, 'error': '上一首失败: ${e.toString()}'}; + } + } + + /// 获取播放状态 + Future> getPlaybackState() async { + try { + final result = await _qqMusic.getPlaybackState(); + return result; + } catch (e) { + return {'success': false, 'error': '获取播放状态失败: ${e.toString()}'}; + } + } + + /// 获取当前播放歌曲 + Future> getCurrentSong() async { + try { + final result = await _qqMusic.getCurrentSong(); + return result; + } catch (e) { + return {'success': false, 'error': '获取当前歌曲失败: ${e.toString()}'}; + } + } + + /// 获取播放时间 + Future> getPlayTime() async { + try { + final result = await _qqMusic.getPlayTime(); + return result; + } catch (e) { + return {'success': false, 'error': '获取播放时间失败: ${e.toString()}'}; + } + } +} diff --git a/lib/modules/qqmusic/bindings/qqmusic_binding.dart b/lib/modules/qqmusic/bindings/qqmusic_binding.dart new file mode 100644 index 000000000..1050ea386 --- /dev/null +++ b/lib/modules/qqmusic/bindings/qqmusic_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/qqmusic_controller.dart'; + +class QQMusicBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => QQMusicController()); + } +} \ No newline at end of file diff --git a/lib/modules/qqmusic/controllers/qqmusic_controller.dart b/lib/modules/qqmusic/controllers/qqmusic_controller.dart new file mode 100644 index 000000000..a828389cc --- /dev/null +++ b/lib/modules/qqmusic/controllers/qqmusic_controller.dart @@ -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(); + + // 登录状态 + final isLoggedIn = false.obs; + final userInfo = Rxn(); + + // 搜索相关 + final searchResults = [].obs; + final isSearching = false.obs; + final searchKeyword = ''.obs; + + // 播放相关 + final currentSong = Rxn(); + final isPlaying = false.obs; + final currentPosition = 0.obs; + final totalDuration = 0.obs; + final playlist = [].obs; + final currentIndex = 0.obs; + + @override + void onInit() { + super.onInit(); + _checkLoginStatus(); + _startPlaybackStateMonitor(); + } + + /// 检查登录状态 + void _checkLoginStatus() { + // 这里可以检查本地存储的登录信息 + // 暂时设为未登录状态 + isLoggedIn.value = false; + } + + /// QQ音乐登录 + Future 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 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 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 playSong(SongModel song, {List? 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 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 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 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 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 _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 _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 _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(); + } +} diff --git a/lib/modules/qqmusic/models/song_model.dart b/lib/modules/qqmusic/models/song_model.dart new file mode 100644 index 000000000..e93470927 --- /dev/null +++ b/lib/modules/qqmusic/models/song_model.dart @@ -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 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 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')}'; + } +} \ No newline at end of file diff --git a/lib/modules/qqmusic/views/qqmusic_view.dart b/lib/modules/qqmusic/views/qqmusic_view.dart new file mode 100644 index 000000000..5cf842e76 --- /dev/null +++ b/lib/modules/qqmusic/views/qqmusic_view.dart @@ -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 { + 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(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, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }); + } +} \ No newline at end of file diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index 5a3f35a13..87e5f4f1c 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -104,9 +104,9 @@ class SplashController extends GetxController { } if (isLoggedIn) { await getAppConfig(); - Get.offAllNamed(Routes.pairing); + Get.offAllNamed(Routes.qqmusic); } else { - Get.offAllNamed(Routes.login); // 跳转到登录页 + Get.offAllNamed(Routes.qqmusic); // 跳转到登录页 } } diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 6e5aa0e5e..20fdfb6ab 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -1,4 +1,7 @@ import 'package:get/get.dart'; +import 'package:qq_music/qq_music.dart'; +import 'package:voitrans/modules/qqmusic/bindings/qqmusic_binding.dart'; +import 'package:voitrans/modules/qqmusic/views/qqmusic_view.dart'; import '/modules/ble/bindings/ble_binding.dart'; import '../modules/ble/views/ble_view.dart'; @@ -275,5 +278,10 @@ abstract class AppPages { page: () => const BleView(), binding: BleBinding(), ), + GetPage( + name: Routes.qqmusic, + page: () => const QQMusicView(), + binding: QQMusicBinding(), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 6bd4aab32..3a4fc6973 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -61,4 +61,6 @@ abstract class Routes { // ble设备管理 static const ble = '/ble'; + // ble设备管理 + static const qqmusic = '/qqmusic'; } diff --git a/local_plugins/qq_music/.gitignore b/local_plugins/qq_music/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/local_plugins/qq_music/.gitignore @@ -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/ diff --git a/local_plugins/qq_music/.metadata b/local_plugins/qq_music/.metadata new file mode 100644 index 000000000..709f51d7e --- /dev/null +++ b/local_plugins/qq_music/.metadata @@ -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' diff --git a/local_plugins/qq_music/CHANGELOG.md b/local_plugins/qq_music/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/local_plugins/qq_music/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/local_plugins/qq_music/LICENSE b/local_plugins/qq_music/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/local_plugins/qq_music/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/local_plugins/qq_music/README.md b/local_plugins/qq_music/README.md new file mode 100644 index 000000000..96d01f183 --- /dev/null +++ b/local_plugins/qq_music/README.md @@ -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. + diff --git a/local_plugins/qq_music/analysis_options.yaml b/local_plugins/qq_music/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/local_plugins/qq_music/analysis_options.yaml @@ -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 diff --git a/local_plugins/qq_music/android/.gitignore b/local_plugins/qq_music/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/local_plugins/qq_music/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_plugins/qq_music/android/build.gradle b/local_plugins/qq_music/android/build.gradle new file mode 100644 index 000000000..1a579ecfd --- /dev/null +++ b/local_plugins/qq_music/android/build.gradle @@ -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') + } +} diff --git a/local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar b/local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar new file mode 100644 index 0000000000000000000000000000000000000000..aba449c1eadb10f3402dc9397da369e9f0263fdc GIT binary patch literal 30029 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 NH@Osly9EI7e*pWccQyb3 literal 0 HcmV?d00001 diff --git a/local_plugins/qq_music/android/settings.gradle b/local_plugins/qq_music/android/settings.gradle new file mode 100644 index 000000000..135ddb68c --- /dev/null +++ b/local_plugins/qq_music/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'qq_music' diff --git a/local_plugins/qq_music/android/src/main/AndroidManifest.xml b/local_plugins/qq_music/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..64a0aad0b --- /dev/null +++ b/local_plugins/qq_music/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl new file mode 100644 index 000000000..718a193aa --- /dev/null +++ b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl @@ -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); +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl new file mode 100644 index 000000000..70d14f41c --- /dev/null +++ b/local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl @@ -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); +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt new file mode 100644 index 000000000..1f1fb70d7 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt @@ -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, 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, index: Int, block: (() -> Unit)) { + val midList = ArrayList() + 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, 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, 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, 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, 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, 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, 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, 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, block: (() -> Unit)) { + api?.registerEventListener(events, object : IQQMusicApiEventListener.Stub() { + override fun onEvent(event: String, extra: Bundle) { + block() + } + }) + } + + fun unregisterEventListener(api: IQQMusicApi?, events: ArrayList, 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, 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, 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, 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, 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) + } + }) + } + +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java new file mode 100644 index 000000000..b27ae44ea --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java @@ -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; + +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt new file mode 100644 index 000000000..05fe5c51d --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt new file mode 100644 index 000000000..5482419bc --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt @@ -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>) + fun onError(errorCode: Int, errorMessage: String) + } + + interface PlayCallback { + fun onSuccess() + fun onError(errorCode: Int, errorMessage: String) + } + + interface InfoCallback { + fun onSuccess(data: Map) + 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>() + + 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, + 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 + } +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt new file mode 100644 index 000000000..02ee2f5fd --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt @@ -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("keyword") + val searchType = call.argument("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>) { + // 确保在主线程中执行回调 + 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>("songMids") + val index = call.argument("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) { + 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) { + 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) { + 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 + } +} diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java new file mode 100644 index 000000000..4fb043f93 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java @@ -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; + } +} diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java new file mode 100644 index 000000000..0a0704fe5 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java @@ -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 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 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 keyMap) { + Key key = (Key) keyMap.get(PRIVATE_KEY); + return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); + } + + /** + * 获取公钥 + * + * @param keyMap 密钥对 + * @return + */ + public static String getPublicKey(Map keyMap) { + Key key = (Key) keyMap.get(PUBLIC_KEY); + return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); + } +} diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt new file mode 100644 index 000000000..7a8a868e0 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt @@ -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) + } + +} \ No newline at end of file diff --git a/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt new file mode 100644 index 000000000..fcff96851 --- /dev/null +++ b/local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/PcmExampleActivityNew.kt @@ -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