Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into new_dev

newdev_shunjiawei
lxm 1 year ago
parent
commit
8a48e7fd52
  1. 1
      android/app/build.gradle.kts
  2. 4
      android/app/sign/DeepSound.properties
  3. 2
      android/app/src/main/AndroidManifest.xml
  4. 9
      android/build.gradle.kts
  5. 6
      android/settings.gradle.kts
  6. BIN
      assets/images/qqmusic.png
  7. 379
      flutter_01.log
  8. 30
      ios/Runner/AppDelegate.swift
  9. 9
      ios/Runner/Info.plist
  10. 8
      lib/core/bindings/initial_binding.dart
  11. 2
      lib/core/translations/language/tl_ph.dart
  12. 2
      lib/core/translations/language/vi_vn.dart
  13. 60
      lib/data/models/song_model.dart
  14. 13
      lib/data/services/ble_manager.dart
  15. 486
      lib/data/services/qq_musoc.dart
  16. 70
      lib/modules/agent/controllers/agent_controller.dart
  17. 790
      lib/modules/agent/views/agent_view.dart
  18. 73
      lib/modules/agent/views/message_bubble.dart
  19. 81
      lib/modules/agent/views/music_card.dart
  20. 265
      lib/modules/agent/views/qqmusic_card.dart
  21. 36
      lib/modules/home/views/home_view.dart
  22. 130
      lib/modules/music/views/music_playlist_view.dart
  23. 245
      lib/modules/ota_upgrade/controllers/ota_core_contropller.dart
  24. 8
      lib/modules/ota_upgrade/controllers/ota_download_contropller.dart
  25. 57
      lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart
  26. 99
      lib/modules/ota_upgrade/services/device_checker.dart
  27. 43
      lib/modules/ota_upgrade/services/ota_status_manager.dart
  28. 114
      lib/modules/ota_upgrade/services/version_manager.dart
  29. 9
      lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart
  30. 9
      lib/modules/qqmusic/bindings/qqmusic_playlist_binding.dart
  31. 242
      lib/modules/qqmusic/controllers/qqmusic_playlist_controller.dart
  32. 651
      lib/modules/qqmusic/views/qqmusic_playlist_view.dart
  33. 9
      lib/modules/qqmusic_test/bindings/qqmusic_binding.dart
  34. 326
      lib/modules/qqmusic_test/controllers/qqmusic_controller.dart
  35. 49
      lib/modules/qqmusic_test/models/song_model.dart
  36. 242
      lib/modules/qqmusic_test/views/qqmusic_view.dart
  37. 8
      lib/modules/translation/bindings/translation_history_binding.dart
  38. 93
      lib/modules/translation/controllers/record_timer_controller.dart
  39. 179
      lib/modules/translation/controllers/translation_audiohistory_controller.dart
  40. 1102
      lib/modules/translation/controllers/translation_controller.dart
  41. 82
      lib/modules/translation/controllers/translation_detail_controller.dart
  42. 503
      lib/modules/translation/controllers/translation_history_controller.dart
  43. 76
      lib/modules/translation/models/translation_models.dart
  44. 3
      lib/modules/translation/views/translation_detail_view.dart
  45. 6
      lib/modules/translation/views/translation_history_view.dart
  46. 1
      lib/modules/translation/views/translation_view.dart
  47. 10
      lib/routes/app_pages.dart
  48. 2
      lib/routes/app_routes.dart
  49. 2
      local_plugins/agent_service/android/build.gradle.kts
  50. 200
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  51. 5
      local_plugins/agent_service/ios/agent_service/Package.swift
  52. 36
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  53. 187
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt
  54. 1
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  55. 9
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt
  56. 6
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  57. 933
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift
  58. 510
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/VolcanoTranslationServiceImpl.swift
  59. 4
      local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift
  60. 23
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  61. 305
      local_plugins/ble_service/ios/Classes/BleAccessorySetup.swift
  62. 93
      local_plugins/ble_service/ios/Classes/BleConst.swift
  63. 1266
      local_plugins/ble_service/ios/Classes/BleService.swift
  64. 4
      local_plugins/ble_service/ios/Classes/BleServicePlugin.h
  65. 12
      local_plugins/ble_service/ios/Classes/BleServicePlugin.m
  66. 374
      local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift
  67. 239
      local_plugins/ble_service/ios/Classes/SwiftOpusAudioProcessor.swift
  68. 1
      local_plugins/ble_service/ios/Classes/XCFrameworkExample.swift
  69. 12
      local_plugins/chat_api/analysis_options.yaml
  70. 1
      local_plugins/chat_api/android/build.gradle.kts
  71. 30
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt
  72. 14
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt
  73. 2
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt
  74. 504
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt
  75. 110
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt
  76. 4
      local_plugins/chat_api/ios/chat_api/Package.swift
  77. 6
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/MCPClient.swift
  78. 283
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/QQMusicSystemFunctionHandler.swift
  79. 33
      local_plugins/qq_music/.gitignore
  80. 33
      local_plugins/qq_music/.metadata
  81. 3
      local_plugins/qq_music/CHANGELOG.md
  82. 1
      local_plugins/qq_music/LICENSE
  83. 15
      local_plugins/qq_music/README.md
  84. 4
      local_plugins/qq_music/analysis_options.yaml
  85. 9
      local_plugins/qq_music/android/.gitignore
  86. 79
      local_plugins/qq_music/android/build.gradle
  87. BIN
      local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar
  88. 1
      local_plugins/qq_music/android/settings.gradle
  89. 9
      local_plugins/qq_music/android/src/main/AndroidManifest.xml
  90. 15
      local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPlayService.aidl
  91. 8
      local_plugins/qq_music/android/src/main/aidl/com/yunqiinnovation/qq_music/IPrint.aidl
  92. 361
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/ApiSample.kt
  93. 53
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/Config.java
  94. 240
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt
  95. 645
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt
  96. 666
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt
  97. 245
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt
  98. 64
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/OpenIDHelper.java
  99. 314
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/openid/RSAUtils.java
  100. 253
      local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/pcm/AudioTrackManager.kt

1
android/app/build.gradle.kts

@ -5,6 +5,7 @@ repositories {
mavenCentral() mavenCentral()
flatDir { flatDir {
dirs(project(":spotify_sdk").projectDir.resolve("libs")) dirs(project(":spotify_sdk").projectDir.resolve("libs"))
dirs(project(":qq_music").projectDir.resolve("libs")) // 添加这行
} }
} }

4
android/app/sign/DeepSound.properties

@ -1,4 +0,0 @@
storePassword=13071376716
keyPassword=13071376716
keyAlias=DeepSound
storeFile=sign/DeepSound.keystore

2
android/app/src/main/AndroidManifest.xml

@ -96,7 +96,7 @@
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="yqcxdeepsound"/> <data android:scheme="voitrans"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.

9
android/build.gradle.kts

@ -4,14 +4,17 @@ allprojects {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://storage.googleapis.com/download.flutter.io") } maven { url = uri("https://storage.googleapis.com/download.flutter.io") }
// 添加火山引擎Maven仓库 // 添加火山引擎Maven仓库
maven { maven {
url = uri("https://artifact.bytedance.com/repository/Volcengine/") url = uri("https://artifact.bytedance.com/repository/Volcengine/")
} }
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") } maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") }
// 修改为绝对路径,避免相对路径计算错误
flatDir {
dirs(file("${rootProject.projectDir}/../local_plugins/qq_music/android/libs"))
}
} }
} }
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()

6
android/settings.gradle.kts

@ -41,9 +41,10 @@ include(":agent_service")
include(":speech") include(":speech")
include(":chat_api") include(":chat_api")
include(":bytedance_speech") include(":bytedance_speech")
include(":qq_music") // 添加这行
include(":jl_opus") include(":jl_opus")
// 设置azure_speech项目的路径
// 设置项目路径
project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android")
project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android")
project(":jieli").projectDir = file("../local_plugins/jieli/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(":speech").projectDir = file("../local_plugins/speech/android")
project(":chat_api").projectDir = file("../local_plugins/chat_api/android") project(":chat_api").projectDir = file("../local_plugins/chat_api/android")
project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/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") project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android")

BIN
assets/images/qqmusic.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

379
flutter_01.log

@ -0,0 +1,379 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter build ios
## exception
ProcessException: ProcessException: Process exited abnormally with exit code -6:
Command line invocation:
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -list
User defaults from command line:
IDEPackageSupportUseBuiltinSCM = YES
Resolve Package Graph
Fetching from https://github.com/apple/swift-log.git (cached)
Fetching from https://github.com/apple/swift-protobuf.git (cached)
Fetching from https://github.com/firebase/leveldb.git (cached)
Fetching from https://github.com/google/abseil-cpp-binary.git (cached)
Fetching from https://github.com/apple/swift-http-types (cached)
Fetching from https://github.com/google/app-check.git (cached)
Fetching from https://github.com/google/interop-ios-for-google-sdks.git (cached)
Fetching from https://github.com/SDWebImage/SDWebImage (cached)
Fetching from https://github.com/ybrid/opus-swift.git (cached)
Fetching from https://github.com/google/GTMAppAuth.git (cached)
Fetching from https://github.com/google/GoogleSignIn-iOS.git (cached)
Fetching from https://github.com/TimOliver/TOCropViewController (cached)
Fetching from https://github.com/zhangao0086/DKImagePickerController (cached)
Fetching from https://github.com/zhangao0086/DKCamera (cached)
Fetching from https://github.com/google/gtm-session-fetcher.git (cached)
Fetching from https://github.com/google/GoogleDataTransport.git (cached)
Fetching from https://github.com/firebase/flutterfire (cached)
Fetching from https://github.com/SnapKit/SnapKit.git (cached)
Fetching from https://github.com/apple/swift-openapi-runtime (cached)
Fetching from https://github.com/Alamofire/Alamofire.git (cached)
Fetching from https://github.com/apple/swift-system.git (cached)
Fetching from https://github.com/firebase/nanopb.git (cached)
Fetching from https://github.com/google/promises.git (cached)
Fetching from https://github.com/google/GoogleAppMeasurement.git (cached)
Fetching from https://github.com/kirualex/SwiftyGif.git (cached)
Fetching from https://github.com/google/grpc-binary.git (cached)
Fetching from https://github.com/google/GoogleUtilities.git (cached)
Fetching from https://github.com/MacPaw/OpenAI.git (cached)
Fetching from https://github.com/zhangao0086/DKPhotoGallery (cached)
Fetching from https://github.com/loopwork-ai/eventsource.git (cached)
Fetching from https://github.com/openid/AppAuth-iOS.git (cached)
Fetching from https://github.com/modelcontextprotocol/swift-sdk.git (cached)
Fetching from https://github.com/firebase/firebase-ios-sdk (cached)
Creating working copy of package ‘abseil-cpp-binary’
Checking out 1.2024072200.0 of package ‘abseil-cpp-binary’
Creating working copy of package ‘app-check’
Checking out 11.2.0 of package ‘app-check’
Creating working copy of package ‘opus-swift’
Checking out 0.8.0 of package ‘opus-swift’
Creating working copy of package ‘SDWebImage’
Checking out 5.21.0 of package ‘SDWebImage’
Creating working copy of package ‘swift-http-types’
Checking out 1.4.0 of package ‘swift-http-types’
Creating working copy of package ‘interop-ios-for-google-sdks’
Checking out 101.0.0 of package ‘interop-ios-for-google-sdks’
Creating working copy of package ‘leveldb’
Checking out 1.22.5 of package ‘leveldb’
Creating working copy of package ‘GTMAppAuth’
Checking out 4.1.1 of package ‘GTMAppAuth’
Creating working copy of package ‘swift-log’
Checking out 1.6.3 of package ‘swift-log’
Creating working copy of package ‘swift-protobuf’
Checking out 1.29.0 of package ‘swift-protobuf’
Creating working copy of package ‘GoogleSignIn-iOS’
Checking out 8.0.0 of package ‘GoogleSignIn-iOS’
Creating working copy of package ‘DKCamera’
Checking out master (5c691d1) of package ‘DKCamera’
Creating working copy of package ‘gtm-session-fetcher’
Checking out 3.5.0 of package ‘gtm-session-fetcher’
Creating working copy of package ‘TOCropViewController’
Checking out 2.7.4 of package ‘TOCropViewController’
Creating working copy of package ‘DKImagePickerController’
Checking out 4.3.9 (0bdfeac) of package ‘DKImagePickerController’
Creating working copy of package ‘GoogleDataTransport’
Checking out 10.1.0 of package ‘GoogleDataTransport’
Creating working copy of package ‘swift-openapi-runtime’
Checking out 1.8.2 of package ‘swift-openapi-runtime’
Creating working copy of package ‘flutterfire’
Checking out main (f852df8) of package ‘flutterfire’
Creating working copy of package ‘SnapKit’
Checking out 5.7.1 of package ‘SnapKit’
Creating working copy of package ‘Alamofire’
Checking out 5.10.2 of package ‘Alamofire’
Creating working copy of package ‘swift-system’
Checking out 1.5.0 of package ‘swift-system’
Creating working copy of package ‘nanopb’
Checking out 2.30910.0 of package ‘nanopb’
Creating working copy of package ‘GoogleAppMeasurement’
Checking out 11.13.0 of package ‘GoogleAppMeasurement’
Creating working copy of package ‘promises’
Checking out 2.4.0 of package ‘promises’
Creating working copy of package ‘SwiftyGif’
Checking out 5.4.5 of package ‘SwiftyGif’
Creating working copy of package ‘grpc-binary’
Checking out 1.69.0 of package ‘grpc-binary’
Creating working copy of package ‘GoogleUtilities’
Checking out 8.1.0 of package ‘GoogleUtilities’
Creating working copy of package ‘OpenAI’
Checking out 0.4.3 of package ‘OpenAI’
Creating working copy of package ‘DKPhotoGallery’
Checking out master (311c1bc) of package ‘DKPhotoGallery’
Creating working copy of package ‘eventsource’
Checking out 1.1.1 of package ‘eventsource’
Creating working copy of package ‘AppAuth-iOS’
Checking out 1.7.6 of package ‘AppAuth-iOS’
Creating working copy of package ‘swift-sdk’
Checking out 0.9.0 of package ‘swift-sdk’
Creating working copy of package ‘firebase-ios-sdk’
Checking out 11.13.0 of package ‘firebase-ios-sdk’
'agent_service' dependency on '/Users/liwei/work/flutter/deepvoice_client/local_plugins/music_service/ios/user_portrait' conflicts with dependency on '/Users/liwei/work/flutter/deepvoice_client/local_plugins/user_portrait/ios/user_portrait' which has the same identity 'user_portrait'. this will be escalated to an error in future versions of SwiftPM.
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[NSMutableArray insertObjects:atIndexes:]: count of array (62) differs from count of index set (61)'
*** First throw call stack:
(
0 CoreFoundation 0x0000000188e62ca0 __exceptionPreprocess + 176
1 libobjc.A.dylib 0x0000000188926b90 objc_exception_throw + 88
2 CoreFoundation 0x0000000188f5d1dc -[__NSCFString characterAtIndex:].cold.1 + 0
3 CoreFoundation 0x0000000188e490b8 -[NSMutableArray insertObjects:atIndexes:] + 1444
4 IDEFoundation 0x000000010ce72874 __50-[IDEGroup insertGroupSubitems:atIndexes:context:]_block_invoke + 1084
5 DVTFoundation 0x000000010b67b16c -[DVTModelObjectGraph performBlockCoalescingModelChanges:] + 60
6 IDEFoundation 0x000000010ce72398 -[IDEGroup insertGroupSubitems:atIndexes:context:] + 248
7 DVTFoundation 0x000000010b639c30 -[NSMutableArray(DVTFoundationClassAdditions) dvt_sortedInsertOfObjects:withComparator:] + 184
8 IDESwiftPackageCore 0x000000011a08c15c $s19IDESwiftPackageCore23IDESPMWorkspaceDelegateC32registerDependencyFileReferences33_033DB97E8C2A552F3158921835EE9F98LL_27modelGraphSynchronizerTokenySayAA0abgH9ReferenceO010dependencyU0_AC16Reg 9 IDESwiftPackageCore 0x000000011a08f264 $s19IDESwiftPackageCore23IDESPMWorkspaceDelegateC27dependencyPackagesDidUpdate33_033DB97E8C2A552F3158921835EE9F98LL8packages14graphHasErrors25revalidatedFilePathCaches27modelGraphSynchronizerToke 10 IDESwiftPackageCore 0x000000011a08717c $s19IDESwiftPackageCore23IDESPMWorkspaceDelegateC27packageGraphDidFinishAction_8duration6resulty7SwiftPM12SPMWorkspaceC0bgJ0O_SdAG0oebgJ16ResolutionResultOtYaFyAC05ModelG17SynchronizerToken33_033 11 IDESwiftPackageCore 0x000000011a08ac18 $s19IDESwiftPackageCore23IDESPMWorkspaceDelegateC38disableWorkspaceContentSynchronization33_033DB97E8C2A552F3158921835EE9F98LL6duringyyAC27ModelGraphSynchronizerTokenAELLVXE_tFyyXEfU_TA + 20
12 DVTFoundation 0x000000010b84fd68 $sSo19DVTModelObjectGraphC13DVTFoundationE34performBlockCoalescingModelChangesyxxyXElFyyXEfU_ + 104
13 DVTFoundation 0x000000010b8134bc $sSDySSSo8NSObjectCGIgo_ACIego_TR + 20
14 DVTFoundation 0x000000010b84fa80 $sIeg_IyB_TR + 20
15 DVTFoundation 0x000000010b67b24c __58-[DVTModelObjectGraph performBlockCoalescingModelChanges:]_block_invoke + 104
16 DVTFoundation 0x000000010b674cf8 -[DVTModelGraphTransactionScope performTransaction:] + 60
17 DVTFoundation 0x000000010b67b1c4 -[DVTModelObjectGraph performBlockCoalescingModelChanges:] + 148
18 DVTFoundation 0x000000010b850520 $sSo19DVTModelObjectGraphC13DVTFoundationE34performBlockCoalescingModelChangesyxxyXElF + 344
19 IDESwiftPackageCore 0x000000011a07d32c $s19IDESwiftPackageCore23IDESPMWorkspaceDelegateC38disableWorkspaceContentSynchronization33_033DB97E8C2A552F3158921835EE9F98LL6duringyyAC27ModelGraphSynchronizerTokenAELLVXE_tF + 444
20 IDESwiftPackageCore 0x000000011a086198 $s19IDESwiftPackageCore23IDESPMWorkspaceDelegateC27packageGraphDidFinishAction_8duration6resulty7SwiftPM12SPMWorkspaceC0bgJ0O_SdAG0oebgJ16ResolutionResultOtYaFTY4_ + 164
21 IDESwiftPackageCore 0x000000011a05ba31 $sIeAgH_ytIeAgHr_TRTATQ0_ + 1
22 SwiftPM 0x000000014800cda5 $s7SwiftPM12SPMWorkspaceC26packageGraphActionFinished33_918CA8CFEB8D3E06D2EEA2771EA5EA00LLyyAC07PackageE16ResolutionResultAELLVYaFTQ1_ + 1
23 SwiftPM 0x000000014800c6c1 $s7SwiftPM12SPMWorkspaceC46processPackageGraphActionsInBackgroundIfNeeded33_918CA8CFEB8D3E06D2EEA2771EA5EA00LL010canProcessefG0yAC11AffirmativeO_tFyyYaYbcfU1_yyYacfU0_TQ1_ + 1
24 SwiftPM 0x000000014805b871 $s7SwiftPM33SPMWorkspaceOrchestrationDelegateP27packageGraphDidFinishAction_8duration6resultyAA0C0C07PackagegJ0O_SdAA0cemgJ16ResolutionResultOtYaFTjTQ0_ + 1
25 SwiftPM 0x00000001480d07f1 $sxIeAgHr_xs5Error_pIegHrzo_s8SendableRzs5NeverORs_r0_lTRyt_Tg5TQ0_ + 1
26 SwiftPM 0x0000000148057ff9 $sxIeAgHr_xs5Error_pIegHrzo_s8SendableRzs5NeverORs_r0_lTRyt_Tg5TATQ0_ + 1
27 libswift_Concurrency.dylib 0x0000000271af883d _ZL23completeTaskWithClosurePN5swift12AsyncContextEPNS_10SwiftErrorE + 1
)
** INTERNAL ERROR: Uncaught exception **
Uncaught Exception: *** -[NSMutableArray insertObjects:atIndexes:]: count of array (62) differs from count of index set (61)
Stack:
0 __exceptionPreprocess (in CoreFoundation)
1 objc_exception_throw (in libobjc.A.dylib)
2 -[__NSCFString characterAtIndex:].cold.1 (in CoreFoundation)
3 -[NSMutableArray insertObjects:atIndexes:] (in CoreFoundation)
4 __50-[IDEGroup insertGroupSubitems:atIndexes:context:]_block_invoke (in IDEFoundation)
5 -[DVTModelObjectGraph performBlockCoalescingModelChanges:] (in DVTFoundation)
6 -[IDEGroup insertGroupSubitems:atIndexes:context:] (in IDEFoundation)
7 -[NSMutableArray(DVTFoundationClassAdditions) dvt_sortedInsertOfObjects:withComparator:] (in DVTFoundation)
8 specialized IDESPMWorkspaceDelegate.registerDependencyFileReferences(_:modelGraphSynchronizerToken:) (in IDESwiftPackageCore)
9 specialized IDESPMWorkspaceDelegate.dependencyPackagesDidUpdate(packages:graphHasErrors:revalidatedFilePathCaches:modelGraphSynchronizerToken:) (in IDESwiftPackageCore)
10 closure #1 in IDESPMWorkspaceDelegate.packageGraphDidFinishAction(_:duration:result:) (in IDESwiftPackageCore)
11 partial apply for closure #1 in IDESPMWorkspaceDelegate.disableWorkspaceContentSynchronization(during:) (in IDESwiftPackageCore)
12 <deduplicated_symbol> (in DVTFoundation)
13 <deduplicated_symbol> (in DVTFoundation)
14 thunk for @escaping @callee_guaranteed () -> () (in DVTFoundation)
15 __58-[DVTModelObjectGraph performBlockCoalescingModelChanges:]_block_invoke (in DVTFoundation)
16 -[DVTModelGraphTransactionScope performTransaction:] (in DVTFoundation)
17 -[DVTModelObjectGraph performBlockCoalescingModelChanges:] (in DVTFoundation)
18 DVTModelObjectGraph.performBlockCoalescingModelChanges<A>(_:) (in DVTFoundation)
19 IDESPMWorkspaceDelegate.disableWorkspaceContentSynchronization(during:) (in IDESwiftPackageCore)
20 IDESPMWorkspaceDelegate.packageGraphDidFinishAction(_:duration:result:) (in IDESwiftPackageCore)
21 <deduplicated_symbol> (in IDESwiftPackageCore)
22 SPMWorkspace.packageGraphActionFinished(_:) (in SwiftPM)
23 closure #2 in closure #3 in SPMWorkspace.processPackageGraphActionsInBackgroundIfNeeded(canProcessPackageGraphActions:) (in SwiftPM)
24 <deduplicated_symbol> (in SwiftPM)
25 specialized thunk for @escaping @isolated(any) @callee_guaranteed @async () -> (@out A) (in SwiftPM)
26 <deduplicated_symbol> (in SwiftPM)
27 completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*) (in libswift_Concurrency.dylib)
Command: /usr/bin/arch -arm64e xcrun xcodebuild -list
```
#0 RunResult.throwException (package:flutter_tools/src/base/process.dart:118:5)
#1 _DefaultProcessUtils.run (package:flutter_tools/src/base/process.dart:344:19)
<asynchronous suspension>
#2 XcodeProjectInterpreter.getInfo (package:flutter_tools/src/ios/xcodeproj.dart:334:30)
<asynchronous suspension>
#3 XcodeBasedProject.projectInfo (package:flutter_tools/src/xcode_project.dart:197:29)
<asynchronous suspension>
#4 XcodeBasedProject.buildSettingsForBuildInfo (package:flutter_tools/src/xcode_project.dart:217:36)
<asynchronous suspension>
#5 IosProject._parseProductName (package:flutter_tools/src/xcode_project.dart:647:55)
<asynchronous suspension>
#6 IosProject.productName (package:flutter_tools/src/xcode_project.dart:635:29)
<asynchronous suspension>
#7 BuildableIOSApp.fromProject (package:flutter_tools/src/ios/application_package.dart:117:33)
<asynchronous suspension>
#8 FlutterApplicationPackageFactory.getPackageForPlatform (package:flutter_tools/src/flutter_application_package.dart:80:15)
<asynchronous suspension>
#9 _BuildIOSSubCommand.buildableIOSApp.<anonymous closure> (package:flutter_tools/src/commands/build_ios.dart:707:9)
<asynchronous suspension>
#10 _BuildIOSSubCommand.runCommand (package:flutter_tools/src/commands/build_ios.dart:748:33)
<asynchronous suspension>
#11 FlutterCommand.run.<anonymous closure> (package:flutter_tools/src/runner/flutter_command.dart:1563:27)
<asynchronous suspension>
#12 AppContext.run.<anonymous closure> (package:flutter_tools/src/base/context.dart:154:19)
<asynchronous suspension>
#13 CommandRunner.runCommand (package:args/command_runner.dart:212:13)
<asynchronous suspension>
#14 FlutterCommandRunner.runCommand.<anonymous closure> (package:flutter_tools/src/runner/flutter_command_runner.dart:494:9)
<asynchronous suspension>
#15 AppContext.run.<anonymous closure> (package:flutter_tools/src/base/context.dart:154:19)
<asynchronous suspension>
#16 FlutterCommandRunner.runCommand (package:flutter_tools/src/runner/flutter_command_runner.dart:431:5)
<asynchronous suspension>
#17 run.<anonymous closure>.<anonymous closure> (package:flutter_tools/runner.dart:98:11)
<asynchronous suspension>
#18 AppContext.run.<anonymous closure> (package:flutter_tools/src/base/context.dart:154:19)
<asynchronous suspension>
#19 main (package:flutter_tools/executable.dart:102:3)
<asynchronous suspension>
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.32.3, on macOS 15.5 24F74 darwin-arm64, locale zh-Hans-CN) [429ms]
• Flutter version 3.32.3 on channel stable at /Users/liwei/flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 5c1433509f (9 weeks ago), 2025-06-11 13:12:24 -0700
• Engine revision 31c4875c7a
• Dart version 3.8.1
• DevTools version 2.45.1
[!] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [2.8s]
• Android SDK at /Users/liwei/Library/Android/sdk
• Platform android-36, build-tools 36.0.0
• Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1)
! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses
[✓] Xcode - develop for iOS and macOS (Xcode 16.2) [1,682ms]
• Xcode at /Applications/Xcode.app/Contents/Developer
• Build 16C5032a
• CocoaPods version 1.16.2
[✓] Chrome - develop for the web [6ms]
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[✓] Android Studio (version 2024.2) [5ms]
• Android Studio at /Applications/Android Studio.app/Contents
• Flutter plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/9212-flutter
• Dart plugin can be installed from:
🔨 https://plugins.jetbrains.com/plugin/6351-dart
• Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1)
[✓] VS Code (version 1.103.0) [4ms]
• VS Code at /Applications/Visual Studio Code.app/Contents
• Flutter extension version 3.116.0
[✓] Connected device (5 available) [9.4s]
• RMX3310 (mobile) • a709b98b • android-arm64 • Android 14 (API 34)
• Voitrans的iPhoneXr (wireless) (mobile) • 00008020-000175D23C03002E • ios • iOS 18.6 22G86
• iPhone XR (wireless) (mobile) • 00008020-000140140260802E • ios • iOS 18.5 22F76
• macOS (desktop) • macos • darwin-arm64 • macOS 15.5 24F74 darwin-arm64
• Chrome (web) • chrome • web-javascript • Google Chrome 139.0.7258.67
! Error: Browsing on the local area network for 李伟的iPad. Ensure the device is unlocked and attached with a cable or
associated with the same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly. (code -27)
! Error: Browsing on the local area network for liwei. Ensure the device is unlocked and attached with a cable or
associated with the same local area network as this Mac.
The device must be opted into Developer Mode to connect wirelessly. (code -27)
[✓] Network resources [5.1s]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

30
ios/Runner/AppDelegate.swift

@ -10,4 +10,34 @@ import UIKit
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
// 添加URL回调监听
override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]
) -> Bool {
print("AppDelegate收到URL回调: \(url.absoluteString)")
// 首先尝试让父类处理
if super.application(app, open: url, options: options) {
return true
}
// 检查是否是VoiTrans相关的URL
if url.scheme == "voitrans" {
print("处理 voitrans URL: \(url.absoluteString)")
// 通过Flutter的方法通道通知QQ音乐插件处理这个URL
if let flutterViewController = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(name: "qq_music", binaryMessenger: flutterViewController.binaryMessenger)
channel.invokeMethod("openUrl", arguments: ["url": url.absoluteString]) { result in
print("QQ音乐插件处理URL结果: \(String(describing: result))")
}
}
return true
}
return false
}
} }

9
ios/Runner/Info.plist

@ -46,6 +46,14 @@
<string>com.saitong.voitrans</string> <string>com.saitong.voitrans</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleURLName</key>
<string>com.saitong.voitrans.qqmusic</string>
<key>CFBundleURLSchemes</key>
<array>
<string>voitrans</string>
</array>
</dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
@ -61,6 +69,7 @@
<array> <array>
<string>App-Prefs</string> <string>App-Prefs</string>
<string>prefs</string> <string>prefs</string>
<string>qqmusic</string>
<string>weixin</string> <string>weixin</string>
<string>weixinULAPI</string> <string>weixinULAPI</string>
<string>weixinURLParamsAPI</string> <string>weixinURLParamsAPI</string>

8
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/user_portrait.dart';
import '../../../data/services/location_manager.dart'; import '../../../data/services/location_manager.dart';
@ -69,6 +71,12 @@ class InitialBinding extends Bindings {
// 会议助手音频上传 // 会议助手音频上传
Get.lazyPut<UserportraitManager>(() => UserportraitManager(), fenix: true); Get.lazyPut<UserportraitManager>(() => UserportraitManager(), fenix: true);
// qq音乐服务
Get.lazyPut<QqMusocService>(() => QqMusocService(), fenix: true);
// 强制初始化 qq_music 插件
// Get.find<QqMusocService>().initializePlugin();
// 新手引导 // 新手引导
Get.lazyPut<NoviceGuideService>(() => NoviceGuideService(), fenix: true); Get.lazyPut<NoviceGuideService>(() => NoviceGuideService(), fenix: true);

2
lib/core/translations/language/tl_ph.dart

@ -768,7 +768,7 @@ const Map<String, String> tlPH = {
'pleaseGrantNecessaryPermissions': 'pleaseGrantNecessaryPermissions':
'Pakibigay ang mga kinakailangang pahintulot', 'Pakibigay ang mga kinakailangang pahintulot',
'checkFailedRetry': 'Nabigo ang pagsuri, pakisubukang muli', 'checkFailedRetry': 'Nabigo ang pagsuri, pakisubukang muli',
'pleaseConnectDeepSoundDevice': 'Pakikonekta ang isang DeepSound device', 'pleaseConnectDeepSoundDevice': 'Pakikonekta ang isang Voitrans device',
'verificationFailedRetry': 'Nabigo ang pag-verify, pakisubukang muli', 'verificationFailedRetry': 'Nabigo ang pag-verify, pakisubukang muli',
'invalidDeviceAddress': 'Hindi wastong address ng device', 'invalidDeviceAddress': 'Hindi wastong address ng device',
'bleDeviceAssociationFailed': 'Nabigo ang pag-uugnay ng BLE device', 'bleDeviceAssociationFailed': 'Nabigo ang pag-uugnay ng BLE device',

2
lib/core/translations/language/vi_vn.dart

@ -744,7 +744,7 @@ const Map<String, String> viVN = {
'retryAuthorization': 'Ủy quyền lại', 'retryAuthorization': 'Ủy quyền lại',
'pleaseGrantNecessaryPermissions': 'Vui lòng cấp các quyền cần thiết', 'pleaseGrantNecessaryPermissions': 'Vui lòng cấp các quyền cần thiết',
'checkFailedRetry': 'Kiểm tra thất bại, vui lòng thử lại', 'checkFailedRetry': 'Kiểm tra thất bại, vui lòng thử lại',
'pleaseConnectDeepSoundDevice': 'Vui lòng kết nối thiết bị DeepSound', 'pleaseConnectDeepSoundDevice': 'Vui lòng kết nối thiết bị Voitrans',
'verificationFailedRetry': 'Xác minh thất bại, vui lòng thử lại', 'verificationFailedRetry': 'Xác minh thất bại, vui lòng thử lại',
'invalidDeviceAddress': 'Địa chỉ thiết bị không hợp lệ', 'invalidDeviceAddress': 'Địa chỉ thiết bị không hợp lệ',
'bleDeviceAssociationFailed': 'Liên kết thiết bị BLE thất bại', 'bleDeviceAssociationFailed': 'Liên kết thiết bị BLE thất bại',

60
lib/data/models/song_model.dart

@ -0,0 +1,60 @@
class SongModel {
final String mid;
final String? id;
final String name;
final String artist;
final String album;
final int duration; // 毫秒
final String? albumPic;
SongModel({
required this.mid,
this.id,
required this.name,
required this.artist,
required this.album,
required this.duration,
this.albumPic,
});
factory SongModel.fromJson(Map<String, dynamic> json) {
return SongModel(
mid: json['mid'] ?? '',
id: json['id'],
name: json['name'] ?? '',
artist: json['artist'] ?? json['singer'] ?? '',
album: json['album'] ?? '',
duration: json['duration'] ?? 0,
albumPic: json['albumPic'] ?? json['pic'] ?? json['coverUri'],
);
}
Map<String, dynamic> toJson() {
return {
'mid': mid,
'id': id,
'name': name,
'artist': artist,
'singer': artist, // 兼容性字段
'album': album,
'duration': duration,
'albumPic': albumPic,
'pic': albumPic, // 兼容性字段
'coverUri': albumPic, // 兼容性字段
};
}
@override
String toString() {
return 'SongModel(mid: $mid, name: $name, artist: $artist, album: $album, duration: $duration)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SongModel && other.mid == mid;
}
@override
int get hashCode => mid.hashCode;
}

13
lib/data/services/ble_manager.dart

@ -123,8 +123,10 @@ class BleManager extends GetxService {
RxInt get connectionStateRx => _connectionState; RxInt get connectionStateRx => _connectionState;
// 连接状态监听 // 连接状态监听
final _isCall = false.obs;
final _isConnected = false.obs; final _isConnected = false.obs;
bool get isConnected => _isConnected.value; bool get isConnected => _isConnected.value;
bool get isCall => _isCall.value;
// 设备信息缓存 // 设备信息缓存
final deviceInfo = Rx<DeviceInfo?>(null); // 使用 Rx 包装 final deviceInfo = Rx<DeviceInfo?>(null); // 使用 Rx 包装
@ -262,8 +264,13 @@ class BleManager extends GetxService {
case BleCommands.GET_BATTERY_INFO: case BleCommands.GET_BATTERY_INFO:
_updateBatteryInfoFromData(data); _updateBatteryInfoFromData(data);
break; break;
case BleCommands.CONTROL_CODEC:
// 处理通话状态
print(" CONTROL_CODEC: $commandId,$data");
//_isCall.value = data['callStatus'] as bool? ?? false;
break;
default: default:
print(" 未处理的命令ID: $commandId,$data");
Logger.info('未处理的命令ID: $commandId'); Logger.info('未处理的命令ID: $commandId');
} }
} }
@ -283,7 +290,11 @@ class BleManager extends GetxService {
break; break;
case 'productColor': case 'productColor':
break; break;
case 'Error':
print(" Error: $commandName,$data");
break;
default: default:
print(" 未处理的命令名称: $commandName,$data");
Logger.info('未处理的命令名称: $commandName'); Logger.info('未处理的命令名称: $commandName');
} }
} }

486
lib/data/services/qq_musoc.dart

@ -0,0 +1,486 @@
import 'dart:async';
import 'dart:ffi';
import 'package:get/get.dart';
import 'package:qq_music/qq_music.dart';
import '../models/song_model.dart';
/// QQ音乐服务适配器 - 全局状态管理
class QqMusocService extends GetxService {
static QqMusocService get to => Get.find<QqMusocService>();
var isInitialized = false;
// 音乐服务实例
final QqMusic _qqMusic = QqMusic();
// 全局音乐状态
final isPlaying = false.obs;
final currentPosition = 0.obs; // 毫秒
final totalDuration = 0.obs; // 毫秒
final currentSong = Rxn<SongModel>();
// final playlist = <SongModel>[].obs;
// final currentIndex = 0.obs;
// 状态监听定时器
Timer? _stateMonitorTimer;
// 监听器列表 - 用于通知页面状态变化
final List<Function()> _listeners = [];
/// 初始化插件并尝试自动登录
Future<void> initializePlugin() async {
try {
// 先触发插件注册
// await _qqMusic.getPlatformVersion();
// 尝试自动登录
final result = await _qqMusic.login();
if (result['success'] == true) {
print('QQ音乐自动登录成功: ${result['openId']}');
Future.delayed(Duration(milliseconds: 1000), () {
syncCurrentPlayInfo(isFrest: true);
});
} else {
print('QQ音乐自动登录失败: ${result['error']}');
}
} catch (e) {
print('QQ音乐插件初始化失败: $e');
}
}
@override
void onInit() {
super.onInit();
print('QQ音乐 全局音乐服务初始化');
// 立即初始化插件
initializePlugin();
}
@override
void onClose() {
_stateMonitorTimer?.cancel();
_listeners.clear();
print('🎵 [QQMusicService] 全局音乐服务销毁');
super.onClose();
}
/// 添加状态监听器
void addListener(Function() listener) {
_listeners.add(listener);
}
/// 移除状态监听器
void removeListener(Function() listener) {
_listeners.remove(listener);
}
/// 通知所有监听器
void _notifyListeners() {
for (final listener in _listeners) {
try {
listener();
} catch (e) {
print('🔥 [QQMusicService] 监听器回调异常: $e');
}
}
}
/// 启动播放状态监控
void startPlaybackStateMonitor() {
// 取消之前的定时器
_stateMonitorTimer?.cancel();
print('🎵 [QQMusicService] 启动全局状态监控');
// 立即执行一次同步
_syncPlaybackInfo();
// 改为每2-3秒同步一次,减少频率
_stateMonitorTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
_syncPlaybackInfo();
});
print('🎵 [QQMusicService] 全局状态监控已启动');
}
/// 停止播放状态监控
void stopPlaybackStateMonitor() {
_stateMonitorTimer?.cancel();
_stateMonitorTimer = null;
print('⏹️ [QQMusicService] 全局状态监控已停止');
}
/// 统一同步播放信息
bool _isSyncing = false;
Future<void> _syncPlaybackInfo() async {
if (_isSyncing) {
print('🔄 [QQMusicService] 上次同步未完成,跳过本次');
return;
}
_isSyncing = true;
try {
final result = await _qqMusic
.syncCurrentPlayInfo()
.timeout(const Duration(milliseconds: 500)); // 500ms超时
if (result['success'] == true && result['data'] != null) {
final data = result['data'];
// 更新播放状态
final newIsPlaying = data['isPlaying'] ?? false;
final wasPlaying = isPlaying.value;
if (isPlaying.value != newIsPlaying) {
print(
'🎮 [QQMusicService] 全局播放状态变化: ${isPlaying.value} -> $newIsPlaying');
isPlaying.value = newIsPlaying;
}
// 如果从播放状态变为停止状态,自动停止监听
if (wasPlaying && !newIsPlaying) {
print('⏹️ [QQMusicService] 检测到播放停止,自动停止全局监听');
stopPlaybackStateMonitor();
_notifyListeners();
return;
}
// 如果当前不在播放状态,也停止监听
if (!newIsPlaying) {
print('⏸️ [QQMusicService] 当前未在播放,停止全局监听以节省资源');
stopPlaybackStateMonitor();
_notifyListeners();
return;
}
// 更新播放时间(Android端返回秒,转换为毫秒)
final currentTimeSec = data['currentTime'] ?? 0;
final totalTimeSec = data['totalTime'] ?? 0;
final newCurrentPosition = (currentTimeSec * 1000).round();
final newTotalDuration = (totalTimeSec * 1000).round();
if (currentPosition.value != newCurrentPosition) {
currentPosition.value = newCurrentPosition;
}
if (totalDuration.value != newTotalDuration) {
totalDuration.value = newTotalDuration;
}
// 更新当前歌曲信息
final songData = data['currentSong'];
if (songData != null) {
final newSong = SongModel(
mid: songData['mid'] ?? '',
name: songData['name'] ?? '',
artist: songData['singer'] ?? '',
album: songData['album'] ?? '',
albumPic: songData['coverUri'],
duration: songData['duration'] ?? 0,
);
if (currentSong.value?.mid != newSong.mid) {
print(
'🎵 [QQMusicService] 当前歌曲变化: ${currentSong.value?.name} -> ${newSong.name}');
currentSong.value = newSong;
}
}
// 通知所有监听器
_notifyListeners();
} else {
// 同步失败时停止监听
print('❌ [QQMusicService] 全局同步失败,停止监听');
stopPlaybackStateMonitor();
}
} on TimeoutException {
print('⏰ [QQMusicService] 同步超时,跳过本次更新');
} catch (e) {
print('💥 [QQMusicService] 全局同步异常: $e');
stopPlaybackStateMonitor();
} finally {
_isSyncing = false;
}
}
// 后台执行的同步方法
static Future<Map<String, dynamic>> _performSync(dynamic _) async {
final qqMusic = QqMusic();
return await qqMusic.syncCurrentPlayInfo();
}
/// 格式化时间
String formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
/// 登录QQ音乐
Future<Map<String, dynamic>> login() async {
try {
final result = await _qqMusic.login();
if (result['success'] == true) {
isInitialized = true;
return result;
} else {
return {'success': false, 'error': result['error'] ?? '登录失败'};
}
} catch (e) {
return {'success': false, 'error': '登录异常: ${e.toString()}'};
}
}
/// 搜索歌曲
Future<Map<String, dynamic>> searchSongs({
required String keyword,
int searchType = 0,
}) async {
try {
final result = await _qqMusic.searchSongs(
keyword: keyword,
searchType: searchType,
);
return result;
} catch (e) {
return {'success': false, 'error': '搜索失败: ${e.toString()}'};
}
}
/// 播放歌曲列表 - 更新全局状态
Future<Map<String, dynamic>> playSongs({
required List<String> songMids,
int index = 0,
List<SongModel>? songList,
SongModel? currentSongModel,
}) async {
try {
stopPlaybackStateMonitor();
isPlaying.value = true;
currentPosition.value = 0;
currentSong.value = currentSongModel;
final result = await _qqMusic.playSongs(
songMids: songMids,
index: index,
);
print('💥 [QQMusicService] playSongs: $result');
if (result['success'] == true) {
if (currentSongModel != null) {
currentSong.value = currentSongModel;
totalDuration.value = currentSongModel.duration;
}
isPlaying.value = true;
currentPosition.value = 0;
// 启动全局状态监听
Future.delayed(Duration(seconds: 1), () {
syncCurrentPlayInfo();
});
_notifyListeners();
} else {
isPlaying.value = false;
currentPosition.value = 0;
currentSong.value = null;
}
return result;
} catch (e) {
return {'success': false, 'error': '播放失败: ${e.toString()}'};
}
}
/// 播放音乐
Future<Map<String, dynamic>> playMusic() async {
try {
final result = await _qqMusic.playMusic();
if (result['success'] == true) {
isPlaying.value = true;
currentPosition.value = 0;
// 启动全局状态监听
Future.delayed(Duration(milliseconds: 500), () {
syncCurrentPlayInfo();
});
_notifyListeners();
}
return result;
} catch (e) {
return {'success': false, 'error': '播放失败: ${e.toString()}'};
}
}
/// 暂停音乐
Future<Map<String, dynamic>> pauseMusic() async {
try {
final result = await _qqMusic.pauseMusic();
if (result['success'] == true) {
isPlaying.value = false;
_notifyListeners();
}
return result;
} catch (e) {
return {'success': false, 'error': '暂停失败: ${e.toString()}'};
}
}
/// 继续播放
Future<Map<String, dynamic>> resumeMusic() async {
try {
final result = await _qqMusic.resumeMusic();
if (result['success'] == true) {
isPlaying.value = true;
startPlaybackStateMonitor();
_notifyListeners();
}
return result;
} catch (e) {
return {'success': false, 'error': '继续播放失败: ${e.toString()}'};
}
}
/// 停止音乐
Future<Map<String, dynamic>> stopMusic() async {
try {
final result = await _qqMusic.stopMusic();
if (result['success'] == true) {
isPlaying.value = false;
currentPosition.value = 0;
stopPlaybackStateMonitor();
_notifyListeners();
}
return result;
} catch (e) {
return {'success': false, 'error': '停止失败: ${e.toString()}'};
}
}
/// 下一首
Future<Map<String, dynamic>> skipToNext() async {
try {
final result = await _qqMusic.skipToNext();
if (result['success'] == true) {
// 更新当前索引
// if (currentIndex.value < playlist.length - 1) {
// currentIndex.value++;
// currentSong.value = playlist[currentIndex.value];
// }
_notifyListeners();
}
return result;
} catch (e) {
return {'success': false, 'error': '下一首失败: ${e.toString()}'};
}
}
/// 上一首
Future<Map<String, dynamic>> skipToPrevious() async {
try {
final result = await _qqMusic.skipToPrevious();
if (result['success'] == true) {
// 更新当前索引
// if (currentIndex.value > 0) {
// currentIndex.value--;
// currentSong.value = playlist[currentIndex.value];
// }
_notifyListeners();
}
return result;
} catch (e) {
return {'success': false, 'error': '上一首失败: ${e.toString()}'};
}
}
/// 获取播放状态
Future<Map<String, dynamic>> getPlaybackState() async {
try {
final result = await _qqMusic.getPlaybackState();
return result;
} catch (e) {
return {'success': false, 'error': '获取播放状态失败: ${e.toString()}'};
}
}
/// 获取当前播放歌曲
Future<Map<String, dynamic>> getCurrentSong() async {
try {
final result = await _qqMusic.getCurrentSong();
return result;
} catch (e) {
return {'success': false, 'error': '获取当前歌曲失败: ${e.toString()}'};
}
}
/// 获取播放时间
Future<Map<String, dynamic>> getPlayTime() async {
try {
final result = await _qqMusic.getPlayTime();
return result;
} catch (e) {
return {'success': false, 'error': '获取播放时间失败: ${e.toString()}'};
}
}
/// 同步当前播放信息 - 统一接口
Future<Map<String, dynamic>> syncCurrentPlayInfo(
{bool isFrest = false}) async {
try {
_stateMonitorTimer?.cancel(); //先关闭计时器
final result = await _qqMusic.syncCurrentPlayInfo();
print('💥 [QQMusicService] syncCurrentPlayInfo: $result');
// 如果同步成功,更新全局状态
if (result['success'] == true && result['data'] != null) {
final data = result['data'];
// 更新播放状态
final newIsPlaying = data['isPlaying'] ?? false;
if (isPlaying.value != newIsPlaying) {
isPlaying.value = newIsPlaying;
}
// 更新播放时间(Android端返回秒,转换为毫秒)
final currentTimeSec = data['currentTime'] ?? 0;
final totalTimeSec = data['totalTime'] ?? 0;
final newCurrentPosition = (currentTimeSec * 1000).round();
final newTotalDuration = (totalTimeSec * 1000).round();
if (currentPosition.value != newCurrentPosition) {
currentPosition.value = newCurrentPosition;
}
if (totalDuration.value != newTotalDuration) {
totalDuration.value = newTotalDuration;
}
// 更新当前歌曲信息
final songData = data['currentSong'];
if (songData != null) {
final newSong = SongModel(
mid: songData['mid'] ?? '',
name: songData['name'] ?? '',
artist: songData['singer'] ?? '',
album: songData['album'] ?? '',
albumPic: songData['coverUri'],
duration: songData['duration'] ?? 0,
);
if (currentSong.value?.mid != newSong.mid) {
currentSong.value = newSong;
}
}
if (newIsPlaying) {
//正在播放
startPlaybackStateMonitor();
} else {
stopPlaybackStateMonitor();
currentSong.value = null;
}
// 通知所有监听器
_notifyListeners();
}
return result;
} catch (e) {
return {'success': false, 'error': '同步播放信息失败: ${e.toString()}'};
}
}
}

70
lib/modules/agent/controllers/agent_controller.dart

@ -3,6 +3,10 @@ import 'dart:convert';
import 'dart:ffi'; import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'package:amap_service/amap_service.dart'; import 'package:amap_service/amap_service.dart';
import 'package:voitrans/data/models/song_model.dart';
import 'package:voitrans/data/services/ble_manager.dart';
import 'package:voitrans/data/services/bluetooth_manager.dart';
import 'package:voitrans/data/services/qq_musoc.dart';
import 'package:flutter_intro/flutter_intro.dart'; import 'package:flutter_intro/flutter_intro.dart';
import '../../../data/services/ble_manager.dart'; import '../../../data/services/ble_manager.dart';
import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/bluetooth_manager.dart';
@ -50,7 +54,7 @@ class AgentController extends GetxController {
final BleManager _bleManager = Get.find<BleManager>(); final BleManager _bleManager = Get.find<BleManager>();
//音乐播放管理 //音乐播放管理
final _musiceManager = Get.find<MusicManager>(); final _musiceManager = Get.find<MusicManager>();
final _qqmusicManager = Get.find<QqMusocService>();
// 导航管理 // 导航管理
final _navigationManager = Get.find<NavigationManager>(); final _navigationManager = Get.find<NavigationManager>();
RxBool isPlaying = false.obs; RxBool isPlaying = false.obs;
@ -166,6 +170,8 @@ class AgentController extends GetxController {
_setupTextInputListener(); //设置文本输入监听器 _setupTextInputListener(); //设置文本输入监听器
startVoiceInput(); //开启语音输入 startVoiceInput(); //开启语音输入
// _qqmusicManager.syncCurrentPlayInfo();
} }
Future<void> _initAppDir() async { Future<void> _initAppDir() async {
@ -732,7 +738,17 @@ class AgentController extends GetxController {
); );
} }
} }
if (metaResult.containsKey("maps_route_navigation")) { if (metaResult.containsKey("qqmusic_searchandplay_songs")) {
final qqmusic_search_songs =
metaResult['qqmusic_searchandplay_songs']
as Map<String, dynamic>;
final data = qqmusic_search_songs['data'];
if (data != null && data is List) {
Future.delayed(Duration(seconds: 1), () {
_qqmusicManager.syncCurrentPlayInfo();
});
}
} else if (metaResult.containsKey("maps_route_navigation")) {
//导航 //导航
final start = metaResult["maps_route_navigation"] final start = metaResult["maps_route_navigation"]
["card_navigation"]["start"] as String; ["card_navigation"]["start"] as String;
@ -1996,4 +2012,54 @@ class AgentController extends GetxController {
await seekToProgress( await seekToProgress(
currentIdInternal, newPosition, newDuration, newProgress); currentIdInternal, newPosition, newDuration, newProgress);
} }
/// 处理来自系统通知栏的停止操作
Future<void> playQQMusic(
List<SongModel> songs, SongModel song, int index) async {
try {
if (_qqmusicManager.isPlaying.value) {
if (_qqmusicManager.currentSong.value?.mid == song.mid) {
// 当前歌曲正在播放,暂停它
await _qqmusicManager.pauseMusic();
} else {
final songMids = songs.map((s) => s.mid).toList();
// 其他歌曲正在播放,播放这首歌
final result = await _qqmusicManager.playSongs(
songMids: songMids,
index: index,
songList: [song],
currentSongModel: song,
);
if (result['success'] == true) {
// 播放成功后,如果歌曲不在当前列表中,保持列表显示状态不变
// Get.snackbar('开始播放', song.name);
_qqmusicManager.syncCurrentPlayInfo();
}
return;
}
} else {
if (_qqmusicManager.currentSong.value?.mid == song.mid) {
// 当前歌曲已暂停,恢复播放
await _qqmusicManager.resumeMusic();
} else {
final songMids = songs.map((s) => s.mid).toList();
// 播放新歌曲
final result = await _qqmusicManager.playSongs(
songMids: songMids,
index: index,
songList: [song],
currentSongModel: song,
);
if (result['success'] == true) {
// 播放成功后,如果歌曲不在当前列表中,保持列表显示状态不变
// Get.snackbar('开始播放', song.name);
_qqmusicManager.syncCurrentPlayInfo();
}
}
}
} catch (e) {
// 静默处理错误,避免影响用户体验
print('播放控制失败: $e');
}
}
} }

790
lib/modules/agent/views/agent_view.dart

@ -339,14 +339,12 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
), ),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// 图片预览区域 - 仅在图片输入模式下显示 // 图片预览区域 - 仅在图片输入模式下显示
Obx(() { Obx(() {
if (controller.isImageInputActive.value && if (controller.isImageInputActive.value &&
controller.selectedImagePath.value != null) { controller.selectedImagePath.value != null) {
return Column( return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// 图片预览和关闭按钮 // 图片预览和关闭按钮
Stack( Stack(
@ -421,454 +419,432 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
// 输入控件区域 - 包含输入行和展开的选项 // 输入控件区域 - 包含输入行和展开的选项
Column( Column(
children: [ children: [
// 输入控件行 - 使用IntrinsicHeight确保按钮与输入框底部对齐 Row(
IntrinsicHeight( crossAxisAlignment:
child: Row( CrossAxisAlignment.end, // 所有元素底部对齐
crossAxisAlignment: children: [
CrossAxisAlignment.end, // 所有元素底部对齐 // 键盘/语音切换按钮
children: [ Obx(() {
// 键盘/语音切换按钮 final isTextMode =
Obx(() { controller.isTextInputMode.value;
final isTextMode = return GestureDetector(
controller.isTextInputMode.value; onTap: () {
return GestureDetector( controller.toggleInputMode();
onTap: () { },
controller.toggleInputMode(); child: Container(
}, width: 44.w,
child: Container( height: 44.w,
width: 44.w, margin: EdgeInsets.symmetric(vertical: 2.w),
height: 44.w, decoration: BoxDecoration(
decoration: BoxDecoration( color: isDarkMode
color: isDarkMode ? Colors.grey[800]
? Colors.grey[800] : Colors.grey[200],
: Colors.grey[200], borderRadius: BorderRadius.circular(22),
borderRadius: BorderRadius.circular(22),
),
child: Icon(
isTextMode
? Icons.mic_none
: Icons.keyboard,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 22.sp,
),
), ),
); child: Icon(
}), isTextMode
? Icons.mic_none
: Icons.keyboard,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 22.sp,
),
),
);
}),
SizedBox(width: 8.w), SizedBox(width: 8.w),
// 文本输入框或语音波形图 // 文本输入框或语音波形图
Expanded( Expanded(
child: Obx(() { child: Obx(() {
final isTextMode = final isTextMode =
controller.isTextInputMode.value; controller.isTextInputMode.value;
if (controller.isTextInputMode.value) { if (controller.isTextInputMode.value) {
// 文本输入模式 - 限制最大高度并只向上扩展 // 文本输入模式 - 限制最大高度并只向上扩展
return IntroStepBuilder( return IntroStepBuilder(
group: 'ai', group: 'ai',
order: 1, order: 1,
overlayBuilder: (params) { overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder( return IntroOverlayBuilder.aiBuilder(
params, params,
controller.noviceGuideService controller
.aiStepsList[ .noviceGuideService.aiStepsList[
controller.noviceGuideService
.aiStep]['start'],
buttonText: '确定输入',
onNext: () {
controller.textController.text =
controller.noviceGuideService controller.noviceGuideService
.aiStep]['start'], .aiStepsList[
buttonText: '确定输入', controller.noviceGuideService
onNext: () { .aiStep]['input'];
controller.textController.text = },
controller.noviceGuideService );
.aiStepsList[ },
controller onWidgetLoad: () {
.noviceGuideService controller.noviceGuideService
.aiStep]['input']; .startAIGuide();
},
builder:
(BuildContext context, GlobalKey key) =>
Container(
key: key,
constraints: BoxConstraints(
minHeight: 48.w,
),
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 5.w,
),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
borderRadius:
BorderRadius.circular(22.w),
border: Border.all(
color: isDarkMode
? Colors.grey[800]!
: Colors.grey.withOpacity(0.1),
width: 1.w,
),
),
child: Obx(() {
// 图片模式下使用更高的输入框
final isImageActive =
controller.isImageInputActive.value;
return TextField(
controller: controller.textController,
maxLines: isImageActive
? 3
: 5, // 设置合理的最大行数
minLines: isImageActive ? 2 : 1,
keyboardType:
TextInputType.multiline, // 启用多行键盘
textInputAction:
TextInputAction.newline, // 支持回车换行
onTap: () {
// 当点击输入框时,如果'+'选项已展开,则收缩
if (controller
.isPlusOptionsExpanded.value) {
controller.collapsePlusOptions();
}
}, },
style: TextStyle(
fontSize: 15.sp,
color: isDarkMode
? Colors.white
: Colors.grey[800],
),
decoration: InputDecoration(
hintText: isImageActive
? 'addQuestionAboutImage'
.tr // 添加对图片的问题或描述...
: 'inputMessage'.tr, // 输入消息...
hintStyle: TextStyle(
color: Colors.grey[500],
fontSize: 15.sp,
),
filled: false,
isDense: true,
border: InputBorder.none,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: isDarkMode
? Colors.grey[800]!
: Colors.grey[200]!,
width: 1.w,
),
),
contentPadding: EdgeInsets.zero,
),
onSubmitted: (_) =>
controller.sendMessage(),
); );
}, }),
onWidgetLoad: () { ),
controller.noviceGuideService );
.startAIGuide(); } else {
}, Timer? _holdTimer; // 增加延迟触发的计时器
builder: (BuildContext context, // 语音输入模式 - 按住说话模式
GlobalKey key) => return Listener(
Container( onPointerDown: (_) {
key: key, // 设置300ms延迟防止误触
constraints: BoxConstraints( _holdTimer = Timer(
minHeight: 44.h, const Duration(milliseconds: 100),
maxHeight: 120.h, () {
), controller.startPushToTalk();
// if (activeSpeaker == 0) {
// if (speakerId == 1) {
// controller
// .restoreOriginalAudioState();
// } else {
// controller
// .disableBluetoothAudio();
// }
// controller
// .setActiveSpeaker(speakerId);
// }
});
},
onPointerUp: (_) {
_holdTimer?.cancel();
controller.endPushToTalk();
},
onPointerCancel: (_) {
_holdTimer?.cancel();
controller.endPushToTalk();
},
// onLongPressStart: (_) {
// // 开始按住说话
// controller.startPushToTalk();
// },
// onLongPressEnd: (_) {
// // 结束按住说话
// controller.endPushToTalk();
// },
// onLongPressCancel: () {
// // 取消按住说话
// controller.endPushToTalk();
// },
child: Obx(() {
final isPushToTalkActive =
controller.isPushToTalkActive.value;
final isListening =
controller.isListening.value;
return Container(
height: 48.w,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 16.w), horizontal: 16.w),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDarkMode color: isPushToTalkActive
? Colors.grey[800] ? (isDarkMode
: Colors.grey[200], ? Colors.blue[900]
: Colors.blue[100])
: (isDarkMode
? Colors.grey[800]
: Colors.grey[200]),
borderRadius: borderRadius:
BorderRadius.circular(22.w), BorderRadius.circular(22.w),
border: Border.all( border: Border.all(
color: isDarkMode color: isPushToTalkActive
? Colors.grey[800]! ? Colors.blue
: Colors.grey.withOpacity(0.1), : (isDarkMode
width: 1.w, ? Colors.grey[800]!
: Colors.grey
.withOpacity(0.1)),
width:
isPushToTalkActive ? 2.w : 1.w,
), ),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment
.end, // 对齐到底部,实现向上扩展
children: [ children: [
Expanded( // 语音波形图 - 只在按住时显示
child: Obx(() { if (isPushToTalkActive) ...[
// 图片模式下使用更高的输入框 const AgentSoundBarsWidget(),
final isImageActive = controller //const Spacer(),
.isImageInputActive.value; ],
return TextField( // 状态文字和图标
controller: Expanded(
controller.textController, child: Row(
maxLines: isImageActive mainAxisAlignment:
? 3 isPushToTalkActive
: 5, // 设置合理的最大行数 ? MainAxisAlignment
minLines: .center
isImageActive ? 2 : 1, : MainAxisAlignment
keyboardType: TextInputType .center,
.multiline, // 启用多行键盘 children: [
textInputAction: Icon(
TextInputAction isPushToTalkActive
.newline, // 支持回车换行 ? Icons.mic
onTap: () { : Icons.mic_none,
// 当点击输入框时,如果'+'选项已展开,则收缩 size: 16.sp,
if (controller color: isPushToTalkActive
.isPlusOptionsExpanded ? Colors.blue
.value) { : Colors.grey[500],
controller
.collapsePlusOptions();
}
},
style: TextStyle(
fontSize: 15.sp,
color: isDarkMode
? Colors.white
: Colors.grey[800],
), ),
decoration: InputDecoration( const SizedBox(width: 6),
hintText: isImageActive Text(
? 'addQuestionAboutImage' isPushToTalkActive
.tr // 添加对图片的问题或描述... ? 'recording'
: 'inputMessage' .tr // 正在录音...
.tr, // 输入消息... : 'holdToSpeak'
hintStyle: TextStyle( .tr, // 按住说话
color: Colors.grey[500], style: TextStyle(
fontSize: 15.sp, fontSize: 14,
), fontWeight:
filled: false, FontWeight.w500,
border: InputBorder.none, color: isPushToTalkActive
focusedBorder: ? Colors.blue
OutlineInputBorder( : Colors.grey[600],
borderSide: BorderSide(
color: isDarkMode
? Colors.grey[800]!
: Colors.grey[200]!,
width: 1.w,
),
), ),
contentPadding:
EdgeInsets.symmetric(
vertical:
isImageActive
? 12.h
: 8.h),
), ),
onSubmitted: (_) => ],
controller.sendMessage(), ),
);
}),
), ),
], ],
), ),
), );
); }),
} else { );
Timer? _holdTimer; // 增加延迟触发的计时器 }
// 语音输入模式 - 按住说话模式 }),
return Listener( ),
onPointerDown: (_) {
// 设置300ms延迟防止误触
_holdTimer = Timer(
const Duration(milliseconds: 200),
() {
controller.startPushToTalk();
// if (activeSpeaker == 0) {
// if (speakerId == 1) {
// controller
// .restoreOriginalAudioState();
// } else {
// controller
// .disableBluetoothAudio();
// }
// controller
// .setActiveSpeaker(speakerId);
// }
});
},
onPointerUp: (_) {
_holdTimer?.cancel();
controller.endPushToTalk();
},
onPointerCancel: (_) {
_holdTimer?.cancel();
controller.endPushToTalk();
},
// onLongPressStart: (_) {
// // 开始按住说话
// controller.startPushToTalk();
// },
// onLongPressEnd: (_) {
// // 结束按住说话
// controller.endPushToTalk();
// },
// onLongPressCancel: () {
// // 取消按住说话
// controller.endPushToTalk();
// },
child: Obx(() {
final isPushToTalkActive =
controller.isPushToTalkActive.value;
final isListening =
controller.isListening.value;
return Container(
height: 52.h,
padding: EdgeInsets.symmetric(
horizontal: 16.w),
decoration: BoxDecoration(
color: isPushToTalkActive
? (isDarkMode
? Colors.blue[900]
: Colors.blue[100])
: (isDarkMode
? Colors.grey[800]
: Colors.grey[200]),
borderRadius:
BorderRadius.circular(22.w),
border: Border.all(
color: isPushToTalkActive
? Colors.blue
: (isDarkMode
? Colors.grey[800]!
: Colors.grey
.withOpacity(0.1)),
width: isPushToTalkActive
? 2.w
: 1.w,
),
),
child: Row(
children: [
// 语音波形图 - 只在按住时显示
if (isPushToTalkActive) ...[
const AgentSoundBarsWidget(),
//const Spacer(),
],
// 状态文字和图标
Expanded(
child: Row(
mainAxisAlignment:
isPushToTalkActive
? MainAxisAlignment
.center
: MainAxisAlignment
.center,
children: [
Icon(
isPushToTalkActive
? Icons.mic
: Icons.mic_none,
size: 16.sp,
color: isPushToTalkActive
? Colors.blue
: Colors.grey[500],
),
const SizedBox(width: 6),
Text(
isPushToTalkActive
? 'recording'
.tr // 正在录音...
: 'holdToSpeak'
.tr, // 按住说话
style: TextStyle(
fontSize: 14,
fontWeight:
FontWeight.w500,
color:
isPushToTalkActive
? Colors.blue
: Colors
.grey[600],
),
),
],
),
),
],
),
);
}),
);
}
}),
),
SizedBox(width: 8.w), SizedBox(width: 8.w),
// 动态按钮区域:展开按钮 + 发送按钮 或 图片选择按钮 // 动态按钮区域:展开按钮 + 发送按钮 或 图片选择按钮
Builder(builder: (context) { Builder(builder: (context) {
StepWidgetParams? widgetParams; StepWidgetParams? widgetParams;
return IntroStepBuilder( return IntroStepBuilder(
group: 'ai', group: 'ai',
order: 2, order: 2,
onHighlightWidgetTap: () async { onHighlightWidgetTap: () async {
await controller await controller
.noviceGuideService.aiStepsList[ .noviceGuideService.aiStepsList[
controller.noviceGuideService controller.noviceGuideService.aiStep]
.aiStep]['method'](); ['method']();
widgetParams!.onNext!(); widgetParams!.onNext!();
controller.sendMessage(); controller.sendMessage();
}, },
overlayBuilder: (params) { overlayBuilder: (params) {
widgetParams = params; widgetParams = params;
return IntroOverlayBuilder.aiBuilder( return IntroOverlayBuilder.aiBuilder(
params, params,
'点击发送,${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['send']}', '点击发送,${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['send']}',
alignment: false, alignment: false,
); );
}, },
builder: builder:
(BuildContext context, GlobalKey key) => (BuildContext context, GlobalKey key) =>
Obx( Obx(
key: key, key: key,
() { () {
final hasText = final hasText =
controller.hasTextInput.value; controller.hasTextInput.value;
final hasImage = final hasImage =
controller.isImageInputActive.value; controller.isImageInputActive.value;
final shouldShowSend = final shouldShowSend = hasText || hasImage;
hasText || hasImage; final shouldShowExpand =
final shouldShowExpand = controller controller.shouldShowExpandButton.value;
.shouldShowExpandButton.value;
if (shouldShowSend) { if (shouldShowSend) {
// 显示发送按钮(可能还有展开按钮) // 显示发送按钮(可能还有展开按钮)
return Column( return Column(
mainAxisSize: MainAxisSize.min, children: [
children: [ // 展开按钮(在发送按钮上方)
// 展开按钮(在发送按钮上方) if (shouldShowExpand && hasText)
if (shouldShowExpand && hasText) Padding(
Padding( padding:
padding: EdgeInsets.only( EdgeInsets.only(bottom: 40.h),
bottom: 40.h), child: GestureDetector(
child: GestureDetector( onTap: () {
onTap: () { // 打开底部弹出编辑模式
// 打开底部弹出编辑模式 controller
.openFullScreenEdit();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor:
Colors.transparent,
builder: (context) =>
const FullScreenEditor(),
).then((_) {
// 弹出页关闭时的回调
controller controller
.openFullScreenEdit(); .closeFullScreenEdit();
showModalBottomSheet( });
context: context, },
isScrollControlled: true, child: Container(
backgroundColor: width: 32.w,
Colors.transparent, height: 32.w,
builder: (context) => decoration: BoxDecoration(
const FullScreenEditor(), color: isDarkMode
).then((_) { ? Colors.grey[700]
// 弹出页关闭时的回调 : Colors.grey[300],
controller borderRadius:
.closeFullScreenEdit(); BorderRadius.circular(
}); 16.w),
}, ),
child: Container( child: Icon(
width: 32.w, Icons.expand_less,
height: 32.w, color: isDarkMode
decoration: BoxDecoration( ? Colors.grey[300]
color: isDarkMode : Colors.grey[700],
? Colors.grey[700] size: 20.sp,
: Colors.grey[300],
borderRadius:
BorderRadius.circular(
16.w),
),
child: Icon(
Icons.expand_less,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 20.sp,
),
), ),
),
),
// 发送按钮
GestureDetector(
onTap: () =>
controller.sendMessage(),
child: Container(
width: 44.w,
height: 44.w,
decoration: BoxDecoration(
color: primaryColor,
borderRadius:
BorderRadius.circular(
22.w),
),
child: Icon(
Icons.send,
color: Colors.white,
size: 22.sp,
), ),
), ),
), ),
], // 发送按钮
); GestureDetector(
} else { onTap: () =>
// 显示'+'选择按钮 controller.sendMessage(),
return Obx(() {
final isExpanded = controller
.isPlusOptionsExpanded.value;
return GestureDetector(
onTap: () {
controller.togglePlusOptions();
},
child: Container( child: Container(
width: 44.w, width: 44.w,
height: 44.w, height: 44.w,
margin: EdgeInsets.symmetric(
vertical: 2.w),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDarkMode color: primaryColor,
? Colors.grey[800]
: Colors.grey[200],
borderRadius: borderRadius:
BorderRadius.circular(22.w), BorderRadius.circular(22.w),
), ),
child: Icon( child: Icon(
isExpanded Icons.send,
? Icons.close color: Colors.white,
: Icons.add,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 22.sp, size: 22.sp,
), ),
), ),
); ),
}); ],
} );
}, } else {
), // 显示'+'选择按钮
); return Obx(() {
}), final isExpanded = controller
], .isPlusOptionsExpanded.value;
), return GestureDetector(
onTap: () {
controller.togglePlusOptions();
},
child: Container(
width: 44.w,
height: 44.w,
margin: EdgeInsets.symmetric(
vertical: 2.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
borderRadius:
BorderRadius.circular(22.w),
),
child: Icon(
isExpanded
? Icons.close
: Icons.add,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 22.sp,
),
),
);
});
}
},
),
);
}),
],
), ),
// 展开的'+'选择选项 // 展开的'+'选择选项

73
lib/modules/agent/views/message_bubble.dart

@ -1,6 +1,9 @@
import 'package:voitrans/core/utils/logger.dart';
import 'package:voitrans/modules/agent/views/qqmusic_card.dart';
import '../../../data/models/song_model.dart';
import '../../../modules/agent/views/navigation_card.dart'; import '../../../modules/agent/views/navigation_card.dart';
import 'weather_card.dart'; import 'weather_card.dart';
import 'music_card.dart';
import 'travel_card.dart'; import 'travel_card.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -216,30 +219,52 @@ class MessageBubble extends GetView<AgentController> {
// 判断消息内容,显示特殊卡片 // 判断消息内容,显示特殊卡片
if (!isUser && meta != null) { if (!isUser && meta != null) {
// 判断消息类型,显示不同的卡片 // 判断消息类型,显示不同的卡片
if (meta!.keys.contains('music_play')) { // if (meta!.keys.contains('music_play')) {
final card_music = // final card_music =
meta!["music_play"]['card_music'] as Map<String, dynamic>?; // meta!["music_play"]['card_music'] as Map<String, dynamic>?;
if (card_music != null) { // if (card_music != null) {
final musicId = card_music['id']; // final musicId = card_music['id'];
final musicName = card_music['name'] as String? ?? '未知歌曲'; // final musicName = card_music['name'] as String? ?? '未知歌曲';
final musicUrl = card_music['url'] as String? ?? ''; // final musicUrl = card_music['url'] as String? ?? '';
final musicSinger = card_music['sgener'] as String? ?? '未知歌手'; // final musicSinger = card_music['sgener'] as String? ?? '未知歌手';
final musicImage = card_music['image'] as String? ?? ''; // final musicImage = card_music['image'] as String? ?? '';
final ts = card_music['ts'] as int? ?? 0; // final ts = card_music['ts'] as int? ?? 0;
print("music_play card_music: $card_music"); // //音乐卡片
//音乐卡片 // return buildMusicItem(context, musicId, musicName, musicUrl,
return buildMusicItem(context, musicId, musicName, musicUrl, // musicSinger, musicImage, ts);
musicSinger, musicImage, ts); // }
} // } else if (meta!.keys.contains('music_playlist')) {
} else if (meta!.keys.contains('music_playlist')) { // final card_musiclist =
final card_musiclist = // meta!["music_playlist"]['card_musiclist'] as Map<String, dynamic>?;
meta!["music_playlist"]['card_musiclist'] as Map<String, dynamic>?; // if (card_musiclist != null) {
if (card_musiclist != null) { // final rawList = card_musiclist['musics'] as List? ?? [];
final rawList = card_musiclist['musics'] as List? ?? []; // final playlist =
final playlist = // rawList.map((e) => Map<String, dynamic>.from(e)).toList();
rawList.map((e) => Map<String, dynamic>.from(e)).toList(); // //音乐播放列表
// return buildMusicContent(context, playlist);
// }
// } else
if (meta!.keys.contains('qqmusic_searchandplay_songs')) {
final qqmusic_search_songs =
meta!['qqmusic_searchandplay_songs'] as Map<String, dynamic>;
final data = qqmusic_search_songs['data'];
if (data != null && data is List) {
//qq音乐
final rawList = data as List? ?? [];
final songList = rawList
.map((item) {
try {
return SongModel.fromJson(item);
} catch (e) {
Logger.e('QQMusicPlaylist', '解析歌曲数据失败: $e, 数据: $item');
return null;
}
})
.where((song) => song != null)
.cast<SongModel>()
.toList();
//音乐播放列表 //音乐播放列表
return buildMusicContent(context, playlist); return buildQQMusicContent(context, songList);
} }
} else if (meta!.keys.contains('maps_weather')) { } else if (meta!.keys.contains('maps_weather')) {
final maps_weather = meta!['maps_weather'] as Map<String, dynamic>; final maps_weather = meta!['maps_weather'] as Map<String, dynamic>;

81
lib/modules/agent/views/music_card.dart

@ -42,20 +42,60 @@ Widget buildMusicItem(BuildContext context, int id, String name, String url,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[100], color: isDarkMode ? Colors.grey[800] : Colors.grey[100],
borderRadius: BorderRadius.circular(8.r), borderRadius: BorderRadius.circular(8.r),
image: image.isNotEmpty
? DecorationImage(
image: NetworkImage(image),
fit: BoxFit.cover,
)
: null,
), ),
child: image.isEmpty child: Stack(
? Icon( children: [
Icons.music_note, // 主图片
size: 18.sp, Container(
color: Theme.of(context).primaryColor, width: 50.w,
) height: 50.h,
: null, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
image: image.isNotEmpty
? DecorationImage(
image: NetworkImage(image),
fit: BoxFit.cover,
)
: null,
),
child: image.isEmpty
? Icon(
Icons.music_note,
size: 18.sp,
color: Theme.of(context).primaryColor,
)
: null,
),
// 右下角音乐图标
if (image.isNotEmpty)
Positioned(
right: 2.w,
bottom: 2.w,
child: Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 1,
offset: const Offset(0, 0.5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
),
],
),
), ),
SizedBox(width: 12.w), SizedBox(width: 12.w),
Expanded( Expanded(
@ -158,21 +198,6 @@ Widget buildMusicContent(
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Container(
// padding: const EdgeInsets.all(12),
// decoration: BoxDecoration(
// color: Colors.white,
// borderRadius: BorderRadius.circular(12),
// boxShadow: [
// BoxShadow(
// color: Colors.black.withOpacity(0.03),
// blurRadius: 3,
// spreadRadius: 1,
// offset: const Offset(0, 1),
// ),
// ],
// ),
// child:
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

265
lib/modules/agent/views/qqmusic_card.dart

@ -0,0 +1,265 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:voitrans/modules/agent/widgets/circular_progress_button.dart';
import '../../../routes/app_routes.dart';
import '../../../data/services/qq_musoc.dart';
import '../../../data/models/song_model.dart';
/// 构建音乐项目
Widget buildQQMusicItem(
BuildContext context, List<SongModel> songs, SongModel song, int index) {
final qqMusicService = QqMusocService.to; // 使用QQ音乐全局服务
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03),
blurRadius: 3,
spreadRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
Container(
width: 50.w,
height: 50.h,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[100],
borderRadius: BorderRadius.circular(8.r),
),
child: Stack(
children: [
// 主图片
Container(
width: 50.w,
height: 50.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.r),
image: song.albumPic!.isNotEmpty
? DecorationImage(
image: NetworkImage(song.albumPic!),
fit: BoxFit.cover,
)
: null,
),
child: song.albumPic!.isEmpty
? Icon(
Icons.music_note,
size: 18.sp,
color: Theme.of(context).primaryColor,
)
: null,
),
// 右下角音乐图标
if (song.albumPic!.isNotEmpty)
Positioned(
right: 0.w,
bottom: 0.w,
child: Container(
width: 12.w,
height: 12.w,
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
],
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
song.name,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: isDarkMode
? Colors.white.withValues(alpha: 0.9)
: Colors.grey[800],
),
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 2.h),
Text(
song.artist,
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
],
),
),
Obx(() {
// 使用QQ音乐服务的全局状态
final isThisPlaying = qqMusicService.isPlaying.value &&
qqMusicService.currentSong.value?.mid == song.mid;
// 计算播放进度
final currentProgress =
isThisPlaying && qqMusicService.totalDuration.value > 0
? qqMusicService.currentPosition.value /
qqMusicService.totalDuration.value
: 0.0;
return CircularProgressButton(
isPlaying: isThisPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 36.0,
strokeWidth: 2.0,
onPlayPause: () {
try {
if (qqMusicService.isPlaying.value) {
if (qqMusicService.currentSong.value?.mid == song.mid) {
// 当前歌曲正在播放,暂停它
qqMusicService.pauseMusic();
} else {
final songMids = songs.map((s) => s.mid).toList();
// 其他歌曲正在播放,播放这首歌
qqMusicService.playSongs(
songMids: songMids,
index: index,
songList: [song],
currentSongModel: song,
);
}
} else {
if (qqMusicService.currentSong.value?.mid == song.mid) {
// 当前歌曲已暂停,恢复播放
qqMusicService.resumeMusic();
} else {
final songMids = songs.map((s) => s.mid).toList();
// 播放新歌曲
qqMusicService.playSongs(
songMids: songMids,
index: index,
songList: [song],
currentSongModel: song,
);
}
}
} catch (e) {
// 静默处理错误,避免影响用户体验
print('播放控制失败: $e');
}
},
onSeek: isThisPlaying
? (seekProgress) {
// QQ音乐暂不支持拖拽进度,可以在这里添加相关逻辑
// 或者显示提示信息
print('QQ音乐暂不支持拖拽进度');
}
: null,
);
}),
],
),
);
}
/// 构建音乐内容卡片
Widget buildQQMusicContent(BuildContext context, List<SongModel> songs) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'recommendedMusic'.tr, //以下是一些推荐的音乐:
style: TextStyle(
color:
isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 18.w,
height: 18.w,
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
const SizedBox(width: 6),
Text(
'playList'.tr, //播放列表
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[300]
: Colors.grey[700],
),
),
],
),
const SizedBox(height: 10),
// 动态生成音乐条目
...songs.asMap().entries.take(4).map((entry) {
final index = entry.key;
final item = entry.value;
return Column(
children: [
buildQQMusicItem(context, songs, item, index),
if (index != songs.length - 1)
Divider(
height: 1,
thickness: 1,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[800]
: Colors.grey[100],
),
],
);
}).toList(),
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: () {
// 跳转到完整歌单页面,传递完整的歌单数据
Get.toNamed(Routes.qqmusic, arguments: {"playlist": songs});
},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'moreMusic'.tr, //更多音乐,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
);
}

36
lib/modules/home/views/home_view.dart

@ -740,24 +740,26 @@ class HomeView extends GetView<HomeController> {
'太棒了!你已成功唤醒小言,接下来试试让它帮你做件事吧', '太棒了!你已成功唤醒小言,接下来试试让它帮你做件事吧',
); );
}, },
builder: (BuildContext context, GlobalKey key) => _buildFeatureCard( builder: (BuildContext context, GlobalKey key) => SizedBox(
key: key, key: key,
title: 'aiAssistant'.tr, // AI全能助手 child: _buildFeatureCard(
description: 'aiAssistantInfo'.tr, // 24小时专属顾问,解答问题、提供建议 title: 'aiAssistant'.tr, // AI全能助手
icon: Icons.assistant, description: 'aiAssistantInfo'.tr, // 24小时专属顾问,解答问题、提供建议
gradient: LinearGradient( icon: Icons.assistant,
begin: Alignment.topLeft, gradient: LinearGradient(
end: Alignment.bottomRight, begin: Alignment.topLeft,
colors: [ end: Alignment.bottomRight,
Color(0xFF0066CC).withOpacity(0.3), colors: [
Color(0xFF5E5CE6).withOpacity(0.3), Color(0xFF0066CC).withOpacity(0.3),
], Color(0xFF5E5CE6).withOpacity(0.3),
],
),
onTap: controller.exploreAIFeatures,
iconOnTop: true,
height: 175.h, // 增加高度
isDarkMode: isDarkMode,
iconColor: const Color(0xFF0066CC),
), ),
onTap: controller.exploreAIFeatures,
iconOnTop: true,
height: 175.h, // 增加高度
isDarkMode: isDarkMode,
iconColor: const Color(0xFF0066CC),
), ),
), ),
), ),
@ -821,7 +823,6 @@ class HomeView extends GetView<HomeController> {
// 特性卡片构建方法 // 特性卡片构建方法
Widget _buildFeatureCard({ Widget _buildFeatureCard({
GlobalKey? key,
required String title, required String title,
required String description, required String description,
required IconData icon, required IconData icon,
@ -833,7 +834,6 @@ class HomeView extends GetView<HomeController> {
Color iconColor = Colors.white, Color iconColor = Colors.white,
}) { }) {
return GestureDetector( return GestureDetector(
key: key,
onTap: onTap, onTap: onTap,
child: Container( child: Container(
height: height ?? 175.h, height: height ?? 175.h,

130
lib/modules/music/views/music_playlist_view.dart

@ -265,28 +265,63 @@ class MusicPlaylistView extends StatelessWidget {
), ),
], ],
), ),
child: image.isNotEmpty child: Stack(
? ClipRRect( children: [
borderRadius: BorderRadius.circular(10.r), image.isNotEmpty
child: Image.network( ? ClipRRect(
image, borderRadius: BorderRadius.circular(10.r),
fit: BoxFit.cover, child: Image.network(
errorBuilder: (context, error, stackTrace) { image,
return Icon( width: 52.w,
Icons.music_note, height: 52.w,
color: isDarkMode fit: BoxFit.cover,
? Colors.grey[500] errorBuilder: (context, error, stackTrace) {
: Colors.grey[600], return Icon(
size: 24.w, Icons.music_note,
); color: isDarkMode
}, ? Colors.grey[500]
: Colors.grey[600],
size: 24.w,
);
},
),
)
: Icon(
Icons.music_note,
color:
isDarkMode ? Colors.grey[500] : Colors.grey[600],
size: 24.w,
),
// 右下角音乐图标
if (image.isNotEmpty)
Positioned(
right: 2.w,
bottom: 2.w,
child: Container(
width: 14.w,
height: 14.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 1,
offset: const Offset(0, 0.5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
), ),
)
: Icon(
Icons.music_note,
color: isDarkMode ? Colors.grey[500] : Colors.grey[600],
size: 24.w,
), ),
],
),
), ),
SizedBox(width: 14.w), SizedBox(width: 14.w),
// 歌曲信息 // 歌曲信息
@ -391,17 +426,50 @@ class MusicPlaylistView extends StatelessWidget {
), ),
], ],
), ),
child: ClipRRect( child: Stack(
borderRadius: BorderRadius.circular(16.r), children: [
child: coverUrl.isNotEmpty ClipRRect(
? Image.network( borderRadius: BorderRadius.circular(16.r),
coverUrl, child: coverUrl.isNotEmpty
fit: BoxFit.cover, ? Image.network(
errorBuilder: (context, error, stackTrace) { coverUrl,
return _buildDefaultCover(isDarkMode, size: size); fit: BoxFit.cover,
}, width: size,
) height: size,
: _buildDefaultCover(isDarkMode, size: size), errorBuilder: (context, error, stackTrace) {
return _buildDefaultCover(isDarkMode, size: size);
},
)
: _buildDefaultCover(isDarkMode, size: size),
),
// 右下角音乐图标
Positioned(
right: 4.w,
bottom: 4.w,
child: Container(
width: size * 0.25,
height: size * 0.25,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
),
],
), ),
); );
} }

245
lib/modules/ota_upgrade/controllers/ota_core_contropller.dart

@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:logger/web.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
/// OTA核心服务
/// 整合设备检查、版本管理和状态管理功能
class OtaCoreService {
final BleManager _bleManager = Get.find<BleManager>();
final int maxRetryAttempts = 3;
int currentRetryCount = 0;
// ==================== 状态常量定义 ====================
static const int NOT_STARTED = -1; // 未开始
static const int VALIDATION_FILE = 0; // 验证文件
static const int ONUPGRADE = 1; // 升级中
static const int START_UPGRADING = 2; // 开始升级
static const int UPGRADE_SUCCESS = 3; // 升级成功
static const int UPGRADE_FAILED = 4; // 升级失败
static const int CONNECTION_DISCONNECTION = 5; // 连接断开
static const int CONNECTION_SUCCESS = 6; // 连接成功
static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件
/// 获取状态描述信息
/// [type] 状态类型
/// 返回对应的状态描述文本
static String getStatusMessage(int type) {
switch (type) {
case NOT_STARTED:
return "未开始".tr;
case VALIDATION_FILE:
return "验证文件中".tr;
case ONUPGRADE:
return "升级中".tr;
case START_UPGRADING:
return "开始升级".tr;
case UPGRADE_SUCCESS:
return "升级成功".tr;
case UPGRADE_FAILED:
return "升级失败".tr;
case CONNECTION_DISCONNECTION:
return "连接断开".tr;
case CONNECTION_SUCCESS:
return "连接成功".tr;
case DOWNLOADING_UP_FILES:
return "下载升级文件".tr;
default:
return "未知状态".tr;
}
}
// ==================== 设备检查功能 ====================
/// 检查设备电量是否满足升级条件
/// 返回true表示电量充足,可以进行升级
/// 返回false表示电量不足,不能进行升级
bool checkBatteryLevel() {
try {
final deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo == null) {
_showBatteryCheckFailedSnackbar('无法获取设备信息,请检查设备连接');
return false;
}
final leftBattery = deviceInfo.leftEarBattery;
final rightBattery = deviceInfo.rightEarBattery;
// 检查左右耳电量是否都大于30%
if (leftBattery <= 30 || rightBattery <= 30) {
_showLowBatteryDialog(leftBattery, rightBattery);
return false;
}
return true;
} catch (e) {
_showBatteryCheckFailedSnackbar('检查设备电量时发生错误:$e');
return false;
}
}
/// 显示电量不足对话框
/// [leftBattery] 左耳电量
/// [rightBattery] 右耳电量
void _showLowBatteryDialog(int leftBattery, int rightBattery) {
String message = '设备电量不足,无法进行固件升级\n';
message += '升级要求:左右耳电量均需大于30%\n';
message += '当前电量:左耳 ${leftBattery}%,右耳 ${rightBattery}%\n';
message += '请充电后再试';
Get.dialog(
AlertDialog(
title: Text(
'电量不足',
style: TextStyle(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
message,
style: TextStyle(
color: Colors.orange[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.orange[700],
),
child: const Text('确定'),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.orange[50],
),
);
}
/// 显示电量检查失败提示
/// [message] 错误信息
void _showBatteryCheckFailedSnackbar(String message) {
Get.snackbar(
'电量检查失败',
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: const Icon(
Icons.warning_amber,
color: Colors.white,
),
);
}
// ==================== 版本管理功能 ====================
/// 获取当前设备版本信息
/// 返回包含当前版本、产品ID和设备信息的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getCurrentVersionInfo() async {
DeviceInfo? deviceInfo = _bleManager.deviceInfo.value;
// 重试机制获取设备信息
while (currentRetryCount < maxRetryAttempts) {
deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo != null &&
deviceInfo.firmwareVersion.isNotEmpty &&
deviceInfo.firmwareProductId.isNotEmpty) {
break;
}
await _bleManager.fetchDeviceInfo();
await Future.delayed(const Duration(seconds: 5));
currentRetryCount++;
}
if (deviceInfo == null ||
deviceInfo.firmwareVersion.isEmpty ||
deviceInfo.firmwareProductId.isEmpty) {
throw "获取设备信息失败,请检查连接";
}
// 验证固件版本格式
final firmwareParts = deviceInfo.firmwareVersion.split('.');
final firstPart = firmwareParts.sublist(0, 3).join('.');
final secondPart = firmwareParts.sublist(3).join('.');
Logger().d("firstPart: $firstPart, secondPart: $secondPart");
if (firstPart != secondPart) {
throw "固件版本格式错误:${deviceInfo.firmwareVersion}";
}
return {
'currentVersion': firstPart,
'productId': deviceInfo.firmwareProductId,
'deviceInfo': deviceInfo,
};
}
/// 获取最新版本信息
/// 返回包含最新版本、更新描述和更新包地址的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getLatestVersionInfo() async {
DBProduct? product = AppConfig.getproduct("DeapSound");
if (product == null) {
throw "无法获取产品信息";
}
return {
'latestVersion': product.version ?? "",
'updateDescription': product.updatedescription ?? "",
'updatePackageAddress': product.updatepackageaddress ?? "",
};
}
/// 比较版本号,判断是否有新版本可用
/// [currentVersion] 当前版本号
/// [latestVersion] 最新版本号
/// 返回true表示有新版本可用(支持升级和降级)
bool isNewVersionAvailable(String currentVersion, String latestVersion) {
try {
List<int> currentParts =
currentVersion.split('.').map((e) => int.parse(e.trim())).toList();
List<int> latestParts =
latestVersion.split('.').map((e) => int.parse(e.trim())).toList();
// 确保两个版本号长度一致,短的用0补齐
int maxLength = currentParts.length > latestParts.length
? currentParts.length
: latestParts.length;
while (currentParts.length < maxLength) {
currentParts.add(0);
}
while (latestParts.length < maxLength) {
latestParts.add(0);
}
// 比较每一段,只要有不同就返回true(支持升级和降级)
for (int i = 0; i < maxLength; i++) {
if (latestParts[i] != currentParts[i]) {
return true;
}
}
return false;
} catch (e) {
Logger().d('版本比较错误: $e');
return false;
}
}
}

8
lib/modules/ota_upgrade/services/download_manager.dart → lib/modules/ota_upgrade/controllers/ota_download_contropller.dart

@ -5,14 +5,14 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:logger/web.dart'; import 'package:logger/web.dart';
/// 文件下载管理器 /// OTA下载服务
/// 负责处理固件包的下载、文件管理等功能 /// 负责处理固件包的下载、文件管理等功能
class DownloadManager { class OtaDownloadService {
late final Dio _dio; late final Dio _dio;
/// 构造函数 /// 构造函数
/// 初始化Dio实例并配置SSL证书处理 /// 初始化Dio实例并配置SSL证书处理
DownloadManager() { OtaDownloadService() {
_dio = Dio(BaseOptions( _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30), // 增加连接超时时间 connectTimeout: const Duration(seconds: 30), // 增加连接超时时间
receiveTimeout: Duration.zero, receiveTimeout: Duration.zero,
@ -150,4 +150,4 @@ class DownloadManager {
throw Exception('删除旧文件失败: $e'); throw Exception('删除旧文件失败: $e');
} }
} }
} }

57
lib/modules/ota_upgrade/controllers/ota_upgrade_controller.dart

@ -4,20 +4,17 @@ import 'package:get/get.dart';
import 'package:logger/web.dart'; import 'package:logger/web.dart';
import 'package:ota/ota.dart'; import 'package:ota/ota.dart';
import '../../../data/services/ble_manager.dart'; import '../../../data/services/ble_manager.dart';
import '../services/version_manager.dart'; import 'ota_core_contropller.dart';
import '../services/download_manager.dart'; import 'ota_download_contropller.dart';
import '../services/device_checker.dart'; import '../views/ota_upgrade_popup_view.dart';
import '../services/ui_manager.dart';
import '../services/ota_status_manager.dart';
/// OTA升级主控制器 - 重构后的简化版本 /// OTA升级主控制器 - 重构后的简化版本
/// 负责协调各个服务,管理升级流程的整体状态 /// 负责协调各个服务,管理升级流程的整体状态
class OtaUpgradeController extends GetxController { class OtaUpgradeController extends GetxController {
// 服务依赖 // 服务依赖 - 使用新的合并后的服务
final _versionManager = VersionManager(); final _coreService = OtaCoreService();
final _downloadManager = DownloadManager(); final _downloadService = OtaDownloadService();
final _deviceChecker = DeviceChecker(); final _uiService = OtaUpgradePopUp();
final _uiManager = UiManager();
final _bleManager = Get.find<BleManager>(); final _bleManager = Get.find<BleManager>();
final _jlota = Ota.instance; final _jlota = Ota.instance;
@ -26,10 +23,10 @@ class OtaUpgradeController extends GetxController {
final RxString latestVersion = ''.obs; final RxString latestVersion = ''.obs;
final RxString currentVersion = ''.obs; final RxString currentVersion = ''.obs;
final RxString detailedStatus = ''.obs; final RxString detailedStatus = ''.obs;
final RxString updatedescription = ''.obs; // 添加缺失的更新描述属性 final RxString updatedescription = ''.obs;
final RxDouble downloadProgress = 0.0.obs; final RxDouble downloadProgress = 0.0.obs;
final RxDouble progress = 0.0.obs; final RxDouble progress = 0.0.obs;
final RxInt type = OtaStatusManager.NOT_STARTED.obs; final RxInt type = OtaCoreService.NOT_STARTED.obs;
final RxBool isUpdateAvailable = true.obs; final RxBool isUpdateAvailable = true.obs;
final RxBool isDownloading = false.obs; final RxBool isDownloading = false.obs;
final RxBool isUpgrade = false.obs; final RxBool isUpgrade = false.obs;
@ -68,7 +65,7 @@ class OtaUpgradeController extends GetxController {
errorMessage.value = ''; errorMessage.value = '';
// 获取当前版本信息 // 获取当前版本信息
final currentInfo = await _versionManager.getCurrentVersionInfo(); final currentInfo = await _coreService.getCurrentVersionInfo();
currentVersion.value = currentInfo['currentVersion']; currentVersion.value = currentInfo['currentVersion'];
// 获取蓝牙地址 // 获取蓝牙地址
@ -78,7 +75,7 @@ class OtaUpgradeController extends GetxController {
} }
// 获取最新版本信息 // 获取最新版本信息
final latestInfo = await _versionManager.getLatestVersionInfo(); final latestInfo = await _coreService.getLatestVersionInfo();
latestVersion.value = latestInfo['latestVersion']; latestVersion.value = latestInfo['latestVersion'];
_updatePackageAddress = latestInfo['updatePackageAddress']; _updatePackageAddress = latestInfo['updatePackageAddress'];
@ -87,7 +84,7 @@ class OtaUpgradeController extends GetxController {
latestInfo['updateDescription'] ?? '本次更新包含性能优化和错误修复。'; latestInfo['updateDescription'] ?? '本次更新包含性能优化和错误修复。';
// 检查是否有更新 // 检查是否有更新
isUpdateAvailable.value = _versionManager.isNewVersionAvailable( isUpdateAvailable.value = _coreService.isNewVersionAvailable(
currentVersion.value, currentVersion.value,
latestVersion.value, latestVersion.value,
); );
@ -103,13 +100,13 @@ class OtaUpgradeController extends GetxController {
Future<void> checkUpdate() async { Future<void> checkUpdate() async {
try { try {
if (latestVersion.value.isNotEmpty) { if (latestVersion.value.isNotEmpty) {
_uiManager.showUpdateDialog(latestVersion.value, _startUpdateProcess); _uiService.showUpdateDialog(latestVersion.value, _startUpdateProcess);
} else { } else {
_uiManager.showErrorSnackbar('错误', '获取最新版本信息失败', _uiService.showErrorSnackbar('错误', '获取最新版本信息失败',
backgroundColor: Colors.orange[800]); backgroundColor: Colors.orange[800]);
} }
} catch (e) { } catch (e) {
_uiManager.showErrorSnackbar('错误', '获取版本信息失败', _uiService.showErrorSnackbar('错误', '获取版本信息失败',
backgroundColor: Colors.orange[800]); backgroundColor: Colors.orange[800]);
} }
} }
@ -123,7 +120,7 @@ class OtaUpgradeController extends GetxController {
/// 检查设备状态后开始下载和升级 /// 检查设备状态后开始下载和升级
Future<void> _startUpdateProcess() async { Future<void> _startUpdateProcess() async {
// 检查设备电量 // 检查设备电量
if (!_deviceChecker.checkBatteryLevel()) { if (!_coreService.checkBatteryLevel()) {
return; return;
} }
@ -140,9 +137,9 @@ class OtaUpgradeController extends GetxController {
isDownloading.value = true; isDownloading.value = true;
showDetailedStatus.value = true; showDetailedStatus.value = true;
type.value = OtaStatusManager.DOWNLOADING_UP_FILES; type.value = OtaCoreService.DOWNLOADING_UP_FILES;
final filePath = await _downloadManager.downloadFirmware( final filePath = await _downloadService.downloadFirmware(
downloadUrl: _updatePackageAddress, downloadUrl: _updatePackageAddress,
onProgress: (progress) { onProgress: (progress) {
downloadProgress.value = progress; downloadProgress.value = progress;
@ -190,7 +187,7 @@ class OtaUpgradeController extends GetxController {
/// [type] 状态类型 /// [type] 状态类型
/// 返回对应的状态描述文本 /// 返回对应的状态描述文本
String getStatusMessage(int type) { String getStatusMessage(int type) {
return OtaStatusManager.getStatusMessage(type); return OtaCoreService.getStatusMessage(type);
} }
/// 监听OTA事件 /// 监听OTA事件
@ -216,7 +213,7 @@ class OtaUpgradeController extends GetxController {
} }
break; break;
case 'onStartOTA': case 'onStartOTA':
type.value = OtaStatusManager.START_UPGRADING; type.value = OtaCoreService.START_UPGRADING;
isUpgrade.value = true; isUpgrade.value = true;
detailedStatus.value = 'OTA升级已开始...'; detailedStatus.value = 'OTA升级已开始...';
break; break;
@ -238,7 +235,7 @@ class OtaUpgradeController extends GetxController {
/// 处理升级成功 /// 处理升级成功
/// 更新状态并显示成功对话框 /// 更新状态并显示成功对话框
void _handleUpgradeSuccess() { void _handleUpgradeSuccess() {
type.value = OtaStatusManager.UPGRADE_SUCCESS; type.value = OtaCoreService.UPGRADE_SUCCESS;
isUpgrade.value = false; isUpgrade.value = false;
detailedStatus.value = '升级成功完成'; detailedStatus.value = '升级成功完成';
showDetailedStatus.value = false; showDetailedStatus.value = false;
@ -248,18 +245,18 @@ class OtaUpgradeController extends GetxController {
isUpdateAvailable.value = false; isUpdateAvailable.value = false;
// 显示成功对话框 // 显示成功对话框
_uiManager.showUpgradeSuccessDialog(); _uiService.showUpgradeSuccessDialog();
} }
/// 处理升级错误 /// 处理升级错误
/// [errorCode] 错误代码 /// [errorCode] 错误代码
/// 更新状态并显示错误提示 /// 更新状态并显示错误提示
void _handleUpgradeError(dynamic errorCode) { void _handleUpgradeError(dynamic errorCode) {
type.value = OtaStatusManager.UPGRADE_FAILED; type.value = OtaCoreService.UPGRADE_FAILED;
isUpgrade.value = false; isUpgrade.value = false;
detailedStatus.value = '升级失败,错误码:$errorCode'; detailedStatus.value = '升级失败,错误码:$errorCode';
showDetailedStatus.value = false; showDetailedStatus.value = false;
_uiManager.showErrorSnackbar( _uiService.showErrorSnackbar(
'升级失败', '升级失败',
'请检查设备连接或升级包,错误码:$errorCode', '请检查设备连接或升级包,错误码:$errorCode',
); );
@ -268,7 +265,7 @@ class OtaUpgradeController extends GetxController {
/// 处理连接断开 /// 处理连接断开
/// 更新连接状态 /// 更新连接状态
void _handleConnectionLost() { void _handleConnectionLost() {
type.value = OtaStatusManager.CONNECTION_DISCONNECTION; type.value = OtaCoreService.CONNECTION_DISCONNECTION;
isUpgrade.value = false; isUpgrade.value = false;
detailedStatus.value = '设备连接断开'; detailedStatus.value = '设备连接断开';
showDetailedStatus.value = false; showDetailedStatus.value = false;
@ -277,7 +274,7 @@ class OtaUpgradeController extends GetxController {
/// 处理连接成功 /// 处理连接成功
/// 更新连接状态 /// 更新连接状态
void _handleConnectionSuccess() { void _handleConnectionSuccess() {
type.value = OtaStatusManager.CONNECTION_SUCCESS; type.value = OtaCoreService.CONNECTION_SUCCESS;
isUpgrade.value = true; isUpgrade.value = true;
detailedStatus.value = '设备连接成功'; detailedStatus.value = '设备连接成功';
} }
@ -288,6 +285,6 @@ class OtaUpgradeController extends GetxController {
/// 设置错误状态并显示错误提示 /// 设置错误状态并显示错误提示
void _handleError(String title, String message) { void _handleError(String title, String message) {
errorMessage.value = message; errorMessage.value = message;
_uiManager.showErrorSnackbar(title, message); _uiService.showErrorSnackbar(title, message);
} }
} }

99
lib/modules/ota_upgrade/services/device_checker.dart

@ -1,99 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../../data/services/ble_manager.dart';
/// 设备检查服务
/// 负责检查设备状态,如电量检查等
class DeviceChecker {
final BleManager _bleManager = Get.find<BleManager>();
/// 检查设备电量是否满足升级条件
/// 返回true表示电量充足,可以进行升级
/// 返回false表示电量不足,不能进行升级
bool checkBatteryLevel() {
try {
final deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo == null) {
_showBatteryCheckFailedSnackbar('无法获取设备信息,请检查设备连接');
return false;
}
final leftBattery = deviceInfo.leftEarBattery;
final rightBattery = deviceInfo.rightEarBattery;
// 检查左右耳电量是否都大于30%
if (leftBattery <= 30 || rightBattery <= 30) {
_showLowBatteryDialog(leftBattery, rightBattery);
return false;
}
return true;
} catch (e) {
_showBatteryCheckFailedSnackbar('检查设备电量时发生错误:$e');
return false;
}
}
/// 显示电量不足对话框
/// [leftBattery] 左耳电量
/// [rightBattery] 右耳电量
void _showLowBatteryDialog(int leftBattery, int rightBattery) {
String message = '设备电量不足,无法进行固件升级\n';
message += '升级要求:左右耳电量均需大于30%\n';
message += '当前电量:左耳 ${leftBattery}%,右耳 ${rightBattery}%\n';
message += '请充电后再试';
Get.dialog(
AlertDialog(
title: Text(
'电量不足',
style: TextStyle(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
content: Text(
message,
style: TextStyle(
color: Colors.orange[600],
),
),
actions: [
TextButton(
onPressed: () => Get.back(),
style: TextButton.styleFrom(
foregroundColor: Colors.orange[700],
),
child: const Text('确定'),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
backgroundColor: Colors.orange[50],
),
);
}
/// 显示电量检查失败提示
/// [message] 错误信息
void _showBatteryCheckFailedSnackbar(String message) {
Get.snackbar(
'电量检查失败',
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange[600],
colorText: Colors.white,
icon: const Icon(
Icons.warning_amber,
color: Colors.white,
),
);
}
}

43
lib/modules/ota_upgrade/services/ota_status_manager.dart

@ -1,43 +0,0 @@
import 'package:get/get.dart';
/// OTA升级状态管理器
/// 负责管理OTA升级过程中的各种状态常量和状态描述
class OtaStatusManager {
static const int NOT_STARTED = -1; // 未开始
static const int VALIDATION_FILE = 0; // 验证文件
static const int ONUPGRADE = 1; // 升级中
static const int START_UPGRADING = 2; // 开始升级
static const int UPGRADE_SUCCESS = 3; // 升级成功
static const int UPGRADE_FAILED = 4; // 升级失败
static const int CONNECTION_DISCONNECTION = 5; // 连接断开
static const int CONNECTION_SUCCESS = 6; // 连接成功
static const int DOWNLOADING_UP_FILES = 7; // 下载升级文件
/// 获取状态描述信息
/// [type] 状态类型
/// 返回对应的状态描述文本
static String getStatusMessage(int type) {
switch (type) {
case NOT_STARTED:
return "未开始".tr;
case VALIDATION_FILE:
return "验证文件中".tr;
case ONUPGRADE:
return "升级中".tr;
case START_UPGRADING:
return "开始升级".tr;
case UPGRADE_SUCCESS:
return "升级成功".tr;
case UPGRADE_FAILED:
return "升级失败".tr;
case CONNECTION_DISCONNECTION:
return "连接断开".tr;
case CONNECTION_SUCCESS:
return "连接成功".tr;
case DOWNLOADING_UP_FILES:
return "下载升级文件".tr;
default:
return "未知状态".tr;
}
}
}

114
lib/modules/ota_upgrade/services/version_manager.dart

@ -1,114 +0,0 @@
import 'package:logger/web.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
import 'package:get/get.dart';
/// 版本管理服务
/// 负责处理设备版本信息获取、版本比较等功能
class VersionManager {
final BleManager _bleManager = Get.find<BleManager>();
final int maxRetryAttempts = 3;
int currentRetryCount = 0;
/// 获取当前设备版本信息
/// 返回包含当前版本、产品ID和设备信息的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getCurrentVersionInfo() async {
DeviceInfo? deviceInfo = _bleManager.deviceInfo.value;
// 重试机制获取设备信息
while (currentRetryCount < maxRetryAttempts) {
deviceInfo = _bleManager.deviceInfo.value;
if (deviceInfo != null &&
deviceInfo.firmwareVersion.isNotEmpty &&
deviceInfo.firmwareProductId.isNotEmpty) {
break;
}
await _bleManager.fetchDeviceInfo();
await Future.delayed(const Duration(seconds: 5));
currentRetryCount++;
}
if (deviceInfo == null ||
deviceInfo.firmwareVersion.isEmpty ||
deviceInfo.firmwareProductId.isEmpty) {
throw "获取设备信息失败,请检查连接";
}
// 验证固件版本格式
final firmwareParts = deviceInfo.firmwareVersion.split('.');
final firstPart = firmwareParts.sublist(0, 3).join('.');
final secondPart = firmwareParts.sublist(3).join('.');
Logger().d("firstPart: $firstPart, secondPart: $secondPart");
if (firstPart != secondPart) {
throw "固件版本格式错误:${deviceInfo.firmwareVersion}";
}
return {
'currentVersion': firstPart,
'productId': deviceInfo.firmwareProductId,
'deviceInfo': deviceInfo,
};
}
/// 获取最新版本信息
/// 返回包含最新版本、更新描述和更新包地址的Map
/// 抛出异常如果获取失败
Future<Map<String, dynamic>> getLatestVersionInfo() async {
DBProduct? product = AppConfig.getproduct("DeapSound");
if (product == null) {
throw "无法获取产品信息";
}
return {
'latestVersion': product.version ?? "",
'updateDescription': product.updatedescription ?? "",
'updatePackageAddress': product.updatepackageaddress ?? "",
};
}
/// 比较版本号,判断是否有新版本可用
/// [currentVersion] 当前版本号
/// [latestVersion] 最新版本号
/// 返回true表示有新版本可用(支持升级和降级)
bool isNewVersionAvailable(String currentVersion, String latestVersion) {
try {
List<int> currentParts = currentVersion
.split('.')
.map((e) => int.parse(e.trim()))
.toList();
List<int> latestParts =
latestVersion.split('.').map((e) => int.parse(e.trim())).toList();
// 确保两个版本号长度一致,短的用0补齐
int maxLength = currentParts.length > latestParts.length
? currentParts.length
: latestParts.length;
while (currentParts.length < maxLength) {
currentParts.add(0);
}
while (latestParts.length < maxLength) {
latestParts.add(0);
}
// 比较每一段,只要有不同就返回true(支持升级和降级)
for (int i = 0; i < maxLength; i++) {
if (latestParts[i] != currentParts[i]) {
return true;
}
}
return false;
} catch (e) {
Logger().d('版本比较错误: $e');
return false;
}
}
}

9
lib/modules/ota_upgrade/services/ui_manager.dart → lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart

@ -4,9 +4,9 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:logger/web.dart'; import 'package:logger/web.dart';
import '/routes/app_routes.dart'; import '/routes/app_routes.dart';
/// UI交互管理器 /// OTA UI服务
/// 负责处理所有与用户界面交互相关的功能 /// 负责处理所有与用户界面交互相关的功能
class UiManager { class OtaUpgradePopUp {
/// 显示更新确认对话框 /// 显示更新确认对话框
/// [latestVersion] 最新版本号 /// [latestVersion] 最新版本号
/// [onConfirm] 确认更新的回调函数 /// [onConfirm] 确认更新的回调函数
@ -101,7 +101,8 @@ class UiManager {
/// [title] 错误标题 /// [title] 错误标题
/// [message] 错误信息 /// [message] 错误信息
/// [backgroundColor] 背景颜色,可选 /// [backgroundColor] 背景颜色,可选
void showErrorSnackbar(String title, String message, {Color? backgroundColor}) { void showErrorSnackbar(String title, String message,
{Color? backgroundColor}) {
Get.snackbar( Get.snackbar(
title, title,
message, message,
@ -114,4 +115,4 @@ class UiManager {
), ),
); );
} }
} }

9
lib/modules/qqmusic/bindings/qqmusic_playlist_binding.dart

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/qqmusic_playlist_controller.dart';
class QQMusicPlaylistBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<QQMusicPlaylistController>(() => QQMusicPlaylistController());
}
}

242
lib/modules/qqmusic/controllers/qqmusic_playlist_controller.dart

@ -0,0 +1,242 @@
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import '../../../data/models/song_model.dart';
import '../../../data/services/qq_musoc.dart';
import '../../../core/utils/logger.dart';
class QQMusicPlaylistController extends GetxController {
final QqMusocService _qqMusicService = QqMusocService.to;
// 登录相关
final isLoggedIn = false.obs;
final userInfo = Rxn<Map<String, dynamic>>();
// 界面维护的播放列表状态
RxList<SongModel> playlist = <SongModel>[].obs;
RxInt currentIndex = 0.obs;
// 界面显示的当前歌曲(可能与实际播放的不同)
Rxn<SongModel> displayCurrentSong = Rxn<SongModel>();
// 界面播放状态(当前播放歌曲不在列表中时为未播放状态)
RxBool displayIsPlaying = false.obs;
// 从全局服务获取实际播放状态(仅用于同步)
RxBool get actualIsPlaying => _qqMusicService.isPlaying;
RxInt get currentPosition => _qqMusicService.currentPosition;
RxInt get totalDuration => _qqMusicService.totalDuration;
Rxn<SongModel> get actualCurrentSong => _qqMusicService.currentSong;
@override
void onInit() {
super.onInit();
_initializePlaylist();
print('🎵 [QQMusicPlaylistController] 控制器初始化完成');
}
/// 初始化播放列表
void _initializePlaylist() {
// 从路由参数获取播放列表
final arguments = Get.arguments;
if (arguments != null && arguments['playlist'] != null) {
final List<SongModel> songList = arguments['playlist'];
// 更新播放列表
playlist.assignAll(songList);
// 检查当前播放歌曲是否在列表中
_syncDisplayStateWithActualPlayback();
} else {
Logger.w('QQMusicPlaylist', '未接收到播放列表参数');
_setDefaultDisplayState();
}
// 添加状态监听器
_qqMusicService.addListener(_onMusicStateChanged);
// 界面初始化时同步播放状态和进度
_qqMusicService.syncCurrentPlayInfo();
}
/// 同步显示状态与实际播放状态
void _syncDisplayStateWithActualPlayback() {
if (playlist.isEmpty) {
_setDefaultDisplayState();
return;
}
final actualSong = actualCurrentSong.value;
if (actualSong != null) {
// 查找当前播放歌曲在列表中的位置
final index = playlist.indexWhere((song) => song.mid == actualSong.mid);
if (index != -1) {
// 当前播放歌曲在列表中
currentIndex.value = index;
displayCurrentSong.value = playlist[index];
displayIsPlaying.value = actualIsPlaying.value;
Logger.d('QQMusicPlaylist', '当前播放歌曲在列表中,索引: $index');
} else {
// 当前播放歌曲不在列表中,显示列表第一个,状态为未播放
_setDefaultDisplayState();
Logger.d('QQMusicPlaylist', '当前播放歌曲不在列表中,显示默认状态');
}
} else {
// 没有当前播放歌曲,显示列表第一个,状态为未播放
_setDefaultDisplayState();
Logger.d('QQMusicPlaylist', '无当前播放歌曲,显示默认状态');
}
}
/// 设置默认显示状态
void _setDefaultDisplayState() {
if (playlist.isNotEmpty) {
currentIndex.value = 0;
displayCurrentSong.value = playlist.first;
displayIsPlaying.value = false; // 默认为未播放状态
Logger.d('QQMusicPlaylist', '设置默认显示状态: ${playlist.first.name}');
} else {
currentIndex.value = 0;
displayCurrentSong.value = null;
displayIsPlaying.value = false;
Logger.w('QQMusicPlaylist', '播放列表为空,无法设置默认状态');
}
}
/// 音乐状态变化回调
void _onMusicStateChanged() {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!isClosed) {
// 重新同步显示状态
_syncDisplayStateWithActualPlayback();
update();
}
});
}
@override
void onClose() {
// 移除状态监听器
_qqMusicService.removeListener(_onMusicStateChanged);
super.onClose();
}
/// 播放歌曲
Future<void> playSong(SongModel song, {List<SongModel>? songList}) async {
try {
displayIsPlaying.value = true;
// 使用传入的歌曲列表或当前播放列表
final List<SongModel> finalSongList = songList ?? playlist.toList();
final songMids = finalSongList.map((s) => s.mid).toList();
final playIndex = finalSongList.indexOf(song);
// 歌曲在当前列表中,更新界面状态
currentIndex.value = playIndex;
displayCurrentSong.value = song;
final result = await _qqMusicService.playSongs(
songMids: songMids,
index: playIndex,
songList: finalSongList,
currentSongModel: song,
);
if (result['success'] == true) {
displayIsPlaying.value = true;
// _qqMusicService.syncCurrentPlayInfo();
// Get.snackbar('开始播放', song.name);
} else {
displayIsPlaying.value = false;
// Get.snackbar('播放失败', result['error'] ?? '未知错误');
}
} catch (e) {
displayIsPlaying.value = false;
// Get.snackbar('播放失败', e.toString());
}
}
/// 播放列表中的指定歌曲
Future<void> playPlaylistSong(int index) async {
if (index >= 0 && index < playlist.length) {
await playSong(playlist[index]);
}
}
/// 暂停/继续播放
Future<void> togglePlayPause() async {
// 检查当前显示的歌曲是否正在播放
final displaySong = displayCurrentSong.value;
final actualSong = actualCurrentSong.value;
if (displaySong != null &&
actualSong != null &&
displaySong.mid == actualSong.mid) {
// 显示的歌曲正在播放,执行暂停/继续
if (actualIsPlaying.value) {
await _qqMusicService.pauseMusic();
displayIsPlaying.value = false;
} else {
await _qqMusicService.resumeMusic();
displayIsPlaying.value = true;
}
} else {
// 显示的歌曲不是当前播放的,播放显示的歌曲
if (displaySong != null) {
await playSong(displaySong);
}
}
}
/// 下一首(列表内)
Future<void> nextSong() async {
if (playlist.isNotEmpty) {
final nextIndex = (currentIndex.value + 1) % playlist.length;
await playPlaylistSong(nextIndex);
}
}
/// 上一首(列表内)
Future<void> previousSong() async {
if (playlist.isNotEmpty) {
final prevIndex =
currentIndex.value > 0 ? currentIndex.value - 1 : playlist.length - 1;
await playPlaylistSong(prevIndex);
}
}
/// 停止播放
Future<void> stopMusic() async {
await _qqMusicService.stopMusic();
displayIsPlaying.value = false;
}
/// 格式化时间
String formatDuration(int milliseconds) {
return _qqMusicService.formatDuration(milliseconds);
}
/// 获取当前显示的播放状态(用于UI显示)
bool get isCurrentSongPlaying {
final displaySong = displayCurrentSong.value;
final actualSong = actualCurrentSong.value;
// 只有当显示的歌曲与实际播放的歌曲相同时,才显示播放状态
if (displaySong != null &&
actualSong != null &&
displaySong.mid == actualSong.mid) {
return actualIsPlaying.value;
}
return false;
}
/// 检查歌曲是否在当前播放列表中
bool isSongInPlaylist(SongModel song) {
return playlist.any((s) => s.mid == song.mid);
}
/// 更新播放列表(支持多个播放列表场景)
void updatePlaylist(List<SongModel> newPlaylist) {
playlist.assignAll(newPlaylist);
_syncDisplayStateWithActualPlayback();
Logger.d('QQMusicPlaylist', '播放列表已更新,歌曲数量: ${newPlaylist.length}');
}
}

651
lib/modules/qqmusic/views/qqmusic_playlist_view.dart

@ -0,0 +1,651 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:voitrans/modules/agent/widgets/circular_progress_button.dart';
import 'package:voitrans/modules/qqmusic/controllers/qqmusic_playlist_controller.dart';
import 'package:voitrans/data/models/song_model.dart'; // Use the data model instead
import '../../../core/utils/logger.dart';
class QqmusicPlaylistView extends GetView<QQMusicPlaylistController> {
const QqmusicPlaylistView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
ScreenUtil.init(context, designSize: const Size(375, 812));
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 20),
onPressed: () => Navigator.pop(context),
),
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
foregroundColor: isDarkMode ? Colors.white : Colors.black,
elevation: 0,
),
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
body: controller.playlist.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_off,
size: 64.w,
color: Colors.grey[400],
),
SizedBox(height: 16.h),
Text(
'noMusic'.tr,
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey[600],
),
),
],
),
)
: Column(
children: [
// 上部:当前播放音乐详情
Container(
height: MediaQuery.of(context).size.height * 0.38,
child: _buildCurrentPlayingSection(isDarkMode),
),
// 中部:播放全部按钮
_buildPlayAllSection(isDarkMode),
// 下部:歌单列表
Expanded(
child: _buildPlaylistSection(isDarkMode),
),
],
),
// 添加迷你播放器
bottomNavigationBar: _buildMiniPlayer(isDarkMode),
);
}
// 构建当前播放音乐详情部分
Widget _buildCurrentPlayingSection(bool isDarkMode) {
return SingleChildScrollView(
child: Obx(() {
// 使用 displayCurrentSong 而不是 actualCurrentSong
final displayCurrentSong = controller.displayCurrentSong.value;
if (displayCurrentSong == null) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 110.w,
color: Colors.grey[400],
),
SizedBox(height: 12.h),
Text(
'暂无播放音乐',
style: TextStyle(
fontSize: 16.sp,
color: Colors.grey[600],
),
),
],
),
);
}
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 封面图片 - 使用 displayCurrentSong
_buildCoverImage(displayCurrentSong.albumPic ?? '', isDarkMode,
size: 110.w),
SizedBox(height: 12.h),
// 歌曲信息 - 使用 displayCurrentSong
_buildSongInfo(displayCurrentSong.name, displayCurrentSong.artist,
isDarkMode),
SizedBox(height: 16.h),
// 进度条
_buildProgressBar(isDarkMode),
SizedBox(height: 15.h),
// 播放控制按钮
_buildPlayControls(isDarkMode),
],
),
);
}),
);
}
// 构建播放全部按钮部分
Widget _buildPlayAllSection(bool isDarkMode) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton.icon(
onPressed: () async {
if (controller.playlist.isNotEmpty) {}
},
icon: Icon(Icons.play_arrow, color: Colors.white, size: 20.w),
label: Text(
'playAll'.tr, // 播放全部
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(Get.context!).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.r),
),
elevation: 3,
shadowColor: Theme.of(Get.context!).primaryColor.withAlpha(102),
),
),
),
);
}
// 构建歌单列表部分
Widget _buildPlaylistSection(bool isDarkMode) {
return GetBuilder<QQMusicPlaylistController>(
builder: (controller) {
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h),
itemCount: controller.playlist.length,
itemBuilder: (context, index) {
final song = controller.playlist[index];
return _buildMusicItem(
context, controller.playlist, song, index, isDarkMode);
},
);
},
);
}
Widget _buildMusicItem(
BuildContext context,
List<SongModel> songs,
SongModel song,
int index,
bool isDarkMode,
) {
return GestureDetector(
onTap: () async {
await controller.playSong(song, songList: songs);
},
child: Container(
margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.all(14.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.white,
borderRadius: BorderRadius.circular(14.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.25 : 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 封面图片
Container(
width: 52.w,
height: 52.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.r),
color: isDarkMode ? Colors.grey[700] : Colors.grey[200],
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.08),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10.r),
child: Image.network(
song.albumPic ?? '',
width: 52.w,
height: 52.w,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.music_note,
color: isDarkMode
? Colors.grey[500]
: Colors.grey[600],
size: 24.w,
);
},
),
),
// 右下角音乐图标
Positioned(
right: 0.w,
bottom: 0.w,
child: Container(
width: 14.w,
height: 14.w,
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
],
)),
SizedBox(width: 14.w),
// 歌曲信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
song.name,
style: TextStyle(
fontSize: 15.5.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 3.h),
Text(
song.artist,
style: TextStyle(
fontSize: 13.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
height: 1.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(width: 14.w),
// 播放按钮
Obx(() {
// 使用 actualCurrentSong 的 mid 来判断是否是当前播放的歌曲
final isThactualIsPlaying = controller.actualIsPlaying.value &&
controller.actualCurrentSong.value?.mid == song.mid;
final currentProgress = isThactualIsPlaying
? controller.totalDuration.value > 0
? controller.currentPosition.value /
controller.totalDuration.value
: 0.0
: 0.0;
return CircularProgressButton(
isPlaying: isThactualIsPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 40.w,
strokeWidth: 2.5,
onPlayPause: () async {
try {
Logger.d('MusicPlaylistView',
'播放按钮被点击,当前播放状态: ${controller.actualIsPlaying.value}');
Logger.d('MusicPlaylistView',
'当前歌曲ID: ${controller.actualCurrentSong.value?.mid}, 点击歌曲ID: ${song.mid}');
if (controller.actualIsPlaying.value) {
if (controller.actualCurrentSong.value?.mid == song.mid) {
Logger.d('MusicPlaylistView', '暂停当前播放的歌曲');
await controller.togglePlayPause();
} else {
Logger.d('MusicPlaylistView', '播放新歌曲');
await controller.playSong(song, songList: songs);
}
} else {
Logger.d('MusicPlaylistView', '开始播放歌曲');
await controller.playSong(song, songList: songs);
}
} catch (e) {
Logger.e('MusicPlaylistView', '播放音乐时发生错误: $e');
}
},
onSeek: isThactualIsPlaying ? (seekProgress) {} : null,
);
}),
],
),
),
);
}
// 构建封面图片
Widget _buildCoverImage(String coverUrl, bool isDarkMode,
{required double size}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
blurRadius: 15,
spreadRadius: 2,
offset: const Offset(0, 6),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: coverUrl.isNotEmpty
? Image.network(
coverUrl,
fit: BoxFit.cover,
width: size,
height: size,
errorBuilder: (context, error, stackTrace) {
return _buildDefaultCover(isDarkMode, size: size);
},
)
: _buildDefaultCover(isDarkMode, size: size),
),
// 右下角音乐图标
Positioned(
right: 0.w,
bottom: 0.w,
child: Container(
width: size * 0.3,
height: size * 0.3,
child: ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: Image.asset(
'assets/images/qqmusic.png',
fit: BoxFit.contain,
),
),
),
),
],
),
);
}
// 构建默认封面
Widget _buildDefaultCover(bool isDarkMode, {required double size}) {
return Container(
color: isDarkMode ? Colors.grey[800] : Colors.grey[300],
child: Icon(
Icons.music_note,
size: size * 0.55,
color: isDarkMode ? Colors.grey[600] : Colors.grey[500],
),
);
}
// 构建歌曲信息
Widget _buildSongInfo(String title, String artist, bool isDarkMode) {
return Column(
children: [
Text(
title,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 6.h),
Text(
artist,
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
// 构建进度条 - 参考qqmusic_test的简洁设计
Widget _buildProgressBar(bool isDarkMode) {
return Obx(() {
// 计算进度值,确保在0.0-1.0范围内
double progressValue = 0.0;
if (controller.totalDuration.value > 0) {
progressValue =
(controller.currentPosition.value / controller.totalDuration.value)
.clamp(0.0, 1.0);
}
return Column(
children: [
// 时间显示
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(controller.currentPosition.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
Text(
_formatDuration(controller.totalDuration.value),
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
],
),
SizedBox(height: 8.h),
// 进度条 - 使用LinearProgressIndicator,更简洁
LinearProgressIndicator(
value: progressValue,
backgroundColor: isDarkMode ? Colors.grey[700] : Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(Get.context!).primaryColor),
minHeight: 4.h,
),
],
);
});
}
// 构建迷你播放器 - 参考qqmusic_test的设计
Widget _buildMiniPlayer(bool isDarkMode) {
return Obx(() {
if (controller.actualCurrentSong.value == null) {
return const SizedBox.shrink();
}
final song = controller.actualCurrentSong.value!;
return Container(
height: 80.h,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Column(
children: [
// 进度条
LinearProgressIndicator(
value: controller.totalDuration.value > 0
? (controller.currentPosition.value /
controller.totalDuration.value)
.clamp(0.0, 1.0)
: 0,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(Get.context!).primaryColor),
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Row(
children: [
// 歌曲信息
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
song.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
Text(
song.artist,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isDarkMode
? Colors.grey[400]
: Colors.grey[600],
fontSize: 12.sp,
),
),
],
),
),
// 控制按钮
Row(
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: controller.previousSong,
color: isDarkMode ? Colors.white : Colors.black,
),
IconButton(
icon: Icon(
controller.actualIsPlaying.value
? Icons.pause
: Icons.play_arrow,
),
onPressed: controller.togglePlayPause,
color: Theme.of(Get.context!).primaryColor,
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: controller.nextSong,
color: isDarkMode ? Colors.white : Colors.black,
),
],
),
],
),
),
),
],
),
);
});
}
// 构建播放控制按钮
Widget _buildPlayControls(bool isDarkMode) {
return Obx(() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () async {
await controller.previousSong();
},
iconSize: 32.w,
padding: EdgeInsets.all(8.w), // 增大触摸区域
icon: Icon(
Icons.skip_previous,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
SizedBox(width: 16.w),
Container(
width: 56.w,
height: 56.w,
decoration: BoxDecoration(
color: Theme.of(Get.context!).primaryColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Theme.of(Get.context!).primaryColor.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 1,
offset: const Offset(0, 3),
),
],
),
child: IconButton(
onPressed: () async {
await controller.togglePlayPause();
},
padding: EdgeInsets.zero,
icon: Icon(
controller.actualIsPlaying.value
? Icons.pause
: Icons.play_arrow,
size: 28.w,
color: Colors.white,
),
),
),
SizedBox(width: 16.w),
IconButton(
onPressed: () async {
await controller.nextSong();
},
iconSize: 32.w,
padding: EdgeInsets.all(8.w), // 增大触摸区域
icon: Icon(
Icons.skip_next,
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
],
);
});
}
// ...(其他辅助方法保持不变,如_formatDuration, _togglePlayPause等)
// 格式化时间显示
String _formatDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}

9
lib/modules/qqmusic_test/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>(() => QQMusicController());
}
}

326
lib/modules/qqmusic_test/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<QqMusocService>();
// 登录状态
final isLoggedIn = false.obs;
final userInfo = Rxn<QQMusicLoginResult>();
// 搜索相关
final searchResults = <SongModel>[].obs;
final isSearching = false.obs;
final searchKeyword = ''.obs;
// 播放相关
final currentSong = Rxn<SongModel>();
final isPlaying = false.obs;
final currentPosition = 0.obs;
final totalDuration = 0.obs;
final playlist = <SongModel>[].obs;
final currentIndex = 0.obs;
@override
void onInit() {
super.onInit();
_checkLoginStatus();
_startPlaybackStateMonitor();
}
/// 检查登录状态
void _checkLoginStatus() {
// 这里可以检查本地存储的登录信息
// 暂时设为未登录状态
isLoggedIn.value = false;
}
/// QQ音乐登录
Future<void> login() async {
try {
print('开始QQ音乐登录...');
Get.dialog(
const Center(
child: CircularProgressIndicator(),
),
barrierDismissible: false,
);
// 添加超时处理
final result = await _qqMusic.login().timeout(
const Duration(seconds: 30),
onTimeout: () {
print('QQ音乐登录超时');
return {'success': false, 'error': '登录超时,请重试'};
},
);
print('QQ音乐登录结果: $result');
Get.back(); // 关闭加载对话框
if (result['success'] == true) {
userInfo.value = QQMusicLoginResult.fromMap(result);
isLoggedIn.value = true;
print('QQ音乐登录成功: ${result['openId']}');
Get.snackbar('登录成功', '欢迎使用QQ音乐');
} else {
print('QQ音乐登录失败: ${result['error']}');
Get.snackbar('登录失败', result['error'] ?? '未知错误');
}
} catch (e) {
print('QQ音乐登录异常: $e');
Get.back();
Get.snackbar('登录失败', e.toString());
}
}
/// 退出登录
void logout() {
userInfo.value = null;
isLoggedIn.value = false;
currentSong.value = null;
isPlaying.value = false;
playlist.clear();
Get.snackbar('已退出', '已退出QQ音乐登录');
}
/// 搜索歌曲
Future<void> searchSongs(String keyword) async {
if (keyword.trim().isEmpty) return;
print('开始搜索歌曲: $keyword');
searchKeyword.value = keyword;
isSearching.value = true;
try {
// 检查登录状态
if (!isLoggedIn.value) {
print('用户未登录,无法搜索');
Get.snackbar('搜索失败', '请先登录QQ音乐');
return;
}
print('调用搜索接口...');
final result = await _qqMusic.searchSongs(keyword: keyword).timeout(
const Duration(seconds: 15),
onTimeout: () {
print('搜索超时');
return {'success': false, 'error': '搜索超时,请重试'};
},
);
print('搜索结果: $result');
if (result['success'] == true && result['songs'] != null) {
final List<dynamic> songsData = result['songs'];
print('找到 ${songsData.length} 首歌曲');
final songs = songsData.map((songData) {
return SongModel(
mid: songData['mid'] ?? '',
name: songData['name'] ?? '',
artist: songData['singer'] ?? '',
album: songData['album'] ?? '',
albumPic: songData['pic'],
duration: songData['duration'] ?? 0,
);
}).toList();
searchResults.assignAll(songs);
print('搜索成功,更新UI');
} else {
print('搜索失败: ${result['error']}');
Get.snackbar('搜索失败', result['error'] ?? '搜索结果为空');
searchResults.clear();
}
} catch (e) {
print('搜索异常: $e');
Get.snackbar('搜索失败', e.toString());
searchResults.clear();
} finally {
print('搜索完成,重置状态');
isSearching.value = false;
}
}
/// 播放歌曲
Future<void> playSong(SongModel song, {List<SongModel>? songList}) async {
try {
currentSong.value = song;
if (songList != null) {
playlist.assignAll(songList);
currentIndex.value = songList.indexOf(song);
} else {
playlist.assignAll([song]);
currentIndex.value = 0;
}
// 获取歌曲MID列表
final songMids = playlist.map((s) => s.mid).toList();
final playIndex = currentIndex.value;
// 调用QQ音乐播放接口
final result = await _qqMusic.playSongs(
songMids: songMids,
index: playIndex,
);
if (result['success'] == true) {
isPlaying.value = true;
totalDuration.value = song.duration;
currentPosition.value = 0;
Get.snackbar('开始播放', song.name);
} else {
Get.snackbar('播放失败', result['error'] ?? '未知错误');
}
} catch (e) {
Get.snackbar('播放失败', e.toString());
}
}
/// 暂停/继续播放
Future<void> togglePlayPause() async {
try {
if (isPlaying.value) {
final result = await _qqMusic.pauseMusic();
if (result['success'] == true) {
isPlaying.value = false;
}
} else {
final result = await _qqMusic.resumeMusic();
if (result['success'] == true) {
isPlaying.value = true;
}
}
} catch (e) {
Get.snackbar('操作失败', e.toString());
}
}
/// 上一首
Future<void> previousSong() async {
try {
final result = await _qqMusic.skipToPrevious();
if (result['success'] == true) {
if (playlist.isNotEmpty) {
int newIndex = currentIndex.value - 1;
if (newIndex < 0) newIndex = playlist.length - 1;
currentIndex.value = newIndex;
currentSong.value = playlist[newIndex];
}
await _updateCurrentSong();
}
} catch (e) {
Get.snackbar('切换失败', e.toString());
}
}
/// 下一首
Future<void> nextSong() async {
try {
final result = await _qqMusic.skipToNext();
if (result['success'] == true) {
if (playlist.isNotEmpty) {
int newIndex = currentIndex.value + 1;
if (newIndex >= playlist.length) newIndex = 0;
currentIndex.value = newIndex;
currentSong.value = playlist[newIndex];
}
await _updateCurrentSong();
}
} catch (e) {
Get.snackbar('切换失败', e.toString());
}
}
/// 停止播放
Future<void> stopMusic() async {
try {
final result = await _qqMusic.stopMusic();
if (result['success'] == true) {
isPlaying.value = false;
currentPosition.value = 0;
}
} catch (e) {
Get.snackbar('停止失败', e.toString());
}
}
/// 更新当前播放歌曲信息
Future<void> _updateCurrentSong() async {
try {
final result = await _qqMusic.getCurrentSong();
if (result['success'] == true && result['song'] != null) {
final songData = result['song'];
final song = SongModel(
mid: songData['mid'] ?? '',
name: songData['name'] ?? '',
artist: songData['singer'] ?? '',
album: songData['album'] ?? '',
albumPic: songData['pic'],
duration: songData['duration'] ?? 0,
);
currentSong.value = song;
}
} catch (e) {
print('更新当前歌曲信息失败: $e');
}
}
/// 启动播放状态监控
void _startPlaybackStateMonitor() {
// 每秒检查一次播放状态和时间
Stream.periodic(const Duration(seconds: 1)).listen((_) async {
if (isLoggedIn.value) {
await _updatePlaybackState();
await _updatePlayTime();
}
});
}
/// 更新播放状态
Future<void> _updatePlaybackState() async {
try {
final result = await _qqMusic.getPlaybackState();
if (result['success'] == true && result['state'] != null) {
final state = result['state'];
// QQ音乐播放状态:0-停止,1-播放,2-暂停
isPlaying.value = state == 1;
}
} catch (e) {
// 静默处理错误,避免频繁弹窗
}
}
/// 更新播放时间
Future<void> _updatePlayTime() async {
try {
final result = await _qqMusic.getPlayTime();
if (result['success'] == true) {
currentPosition.value = result['currentTime'] ?? 0;
totalDuration.value = result['totalTime'] ?? 0;
}
} catch (e) {
// 静默处理错误,避免频繁弹窗
}
}
/// 跳转到指定位置(暂时保留,QQ音乐可能不支持)
void seekTo(int position) {
currentPosition.value = position;
// QQ音乐SDK可能不支持跳转功能
Get.snackbar('提示', '当前版本不支持跳转功能');
}
@override
void onClose() {
// 清理资源
super.onClose();
}
}

49
lib/modules/qqmusic_test/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<String, dynamic> json) {
return SongModel(
mid: json['mid'] ?? '',
name: json['name'] ?? '',
artist: json['artist'] ?? '',
album: json['album'] ?? '',
albumPic: json['albumPic'],
duration: json['duration'] ?? 0,
isFavorite: json['isFavorite'] ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'mid': mid,
'name': name,
'artist': artist,
'album': album,
'albumPic': albumPic,
'duration': duration,
'isFavorite': isFavorite,
};
}
String get durationText {
final minutes = duration ~/ 60;
final seconds = duration % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}

242
lib/modules/qqmusic_test/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<QQMusicController> {
const QQMusicView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('QQ音乐'),
actions: [
Obx(() => controller.isLoggedIn.value
? IconButton(
icon: const Icon(Icons.logout),
onPressed: controller.logout,
)
: IconButton(
icon: const Icon(Icons.login),
onPressed: controller.login,
)),
],
),
body: Obx(() {
if (!controller.isLoggedIn.value) {
return _buildLoginView();
}
return _buildMainView();
}),
bottomNavigationBar: Obx(() {
if (controller.currentSong.value != null) {
return _buildMiniPlayer();
}
return const SizedBox.shrink();
}),
);
}
Widget _buildLoginView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.music_note,
size: 100,
color: Colors.blue,
),
const SizedBox(height: 20),
const Text(
'请先登录QQ音乐',
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: controller.login,
child: const Text('登录QQ音乐'),
),
],
),
);
}
Widget _buildMainView() {
return Column(
children: [
_buildSearchBar(),
Expanded(
child: Obx(() {
if (controller.isSearching.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.searchResults.isEmpty) {
return const Center(
child: Text('搜索歌曲开始您的音乐之旅'),
);
}
return _buildSearchResults();
}),
),
],
);
}
Widget _buildSearchBar() {
return Container(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: const InputDecoration(
hintText: '搜索歌曲、歌手、专辑',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onSubmitted: controller.searchSongs,
),
);
}
Widget _buildSearchResults() {
return ListView.builder(
itemCount: controller.searchResults.length,
itemBuilder: (context, index) {
final song = controller.searchResults[index];
return _buildSongItem(song);
},
);
}
Widget _buildSongItem(SongModel song) {
return ListTile(
leading: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: song.albumPic != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
song.albumPic!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.music_note);
},
),
)
: const Icon(Icons.music_note),
),
title: Text(
song.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${song.artist} • ${song.album}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(song.durationText),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.play_arrow),
onPressed: () => controller.playSong(
song,
songList: controller.searchResults,
),
),
],
),
);
}
Widget _buildMiniPlayer() {
return Obx(() {
final song = controller.currentSong.value!;
return Container(
height: 80,
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Column(
children: [
// 进度条
Obx(() => LinearProgressIndicator(
value: controller.totalDuration.value > 0
? controller.currentPosition.value /
controller.totalDuration.value
: 0,
backgroundColor: Colors.grey[300],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
)),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
// 歌曲信息
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
song.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
song.artist,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
// 控制按钮
Row(
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: controller.previousSong,
),
Obx(() => IconButton(
icon: Icon(
controller.isPlaying.value
? Icons.pause
: Icons.play_arrow,
),
onPressed: controller.togglePlayPause,
)),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: controller.nextSong,
),
],
),
],
),
),
),
],
),
);
});
}
}

8
lib/modules/translation/bindings/translation_history_binding.dart

@ -1,11 +1,11 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/translation_history_controller.dart'; import '../controllers/translation_audiohistory_controller.dart';
class TranslationHistoryBinding extends Bindings { class TranslationHistoryBinding extends Bindings {
@override @override
void dependencies() { void dependencies() {
Get.lazyPut<TranslationHistoryController>( Get.lazyPut<TranslationAudioHistoryController>(
() => TranslationHistoryController(), () => TranslationAudioHistoryController(),
); );
} }
} }

93
lib/modules/translation/controllers/record_timer_controller.dart

@ -0,0 +1,93 @@
import 'dart:async';
import 'package:get/get.dart';
import '../../../core/utils/logger.dart';
/// 录音计时器管理类
/// 负责管理录音过程中的计时功能
class RecordTimerController extends GetxController {
static const String TAG = 'RecordTimerController';
// 计时器相关状态
final RxString recordDurationText = '00:00'.obs;
Timer? _recordTimer;
int _seconds = 0;
bool _isTimerPaused = false;
/// 开始计时器
/// 从00:00开始计时,每秒更新一次显示
void startTimer() {
_seconds = 0;
recordDurationText.value = '00:00';
_recordTimer = Timer.periodic(Duration(seconds: 1), (_) {
_seconds++;
final minutes = (_seconds ~/ 60).toString().padLeft(2, '0');
final seconds = (_seconds % 60).toString().padLeft(2, '0');
recordDurationText.value = '$minutes:$seconds';
});
Logger.info('录音计时器已开始');
}
/// 暂停计时器
/// 保持当前时间显示,停止计时
void pauseTimer() {
if (_recordTimer != null && !_isTimerPaused) {
_recordTimer?.cancel();
_recordTimer = null;
_isTimerPaused = true;
Logger.info('录音计时器已暂停');
}
}
/// 恢复计时器
/// 从暂停的时间点继续计时
void resumeTimer() {
if (_isTimerPaused) {
_recordTimer = Timer.periodic(Duration(seconds: 1), (_) {
_seconds++;
final minutes = (_seconds ~/ 60).toString().padLeft(2, '0');
final seconds = (_seconds % 60).toString().padLeft(2, '0');
recordDurationText.value = '$minutes:$seconds';
});
_isTimerPaused = false;
Logger.info('录音计时器已恢复');
}
}
/// 重置计时器
/// 停止计时并重置为00:00
void resetTimer() {
_recordTimer?.cancel();
_recordTimer = null;
_seconds = 0;
_isTimerPaused = false;
recordDurationText.value = '00:00';
Logger.info('录音计时器已重置');
}
/// 停止计时器
/// 完全停止计时器并重置状态
void stopTimer() {
_recordTimer?.cancel();
_recordTimer = null;
_isTimerPaused = false;
recordDurationText.value = '00:00';
_seconds = 0;
Logger.info('录音计时器已停止');
}
/// 获取当前计时秒数
int get currentSeconds => _seconds;
/// 获取当前是否暂停状态
bool get isPaused => _isTimerPaused;
/// 获取当前是否正在计时
bool get isRunning => _recordTimer != null && !_isTimerPaused;
/// 释放资源
void dispose() {
_recordTimer?.cancel();
_recordTimer = null;
Logger.info('录音计时器管理器已释放');
}
}

179
lib/modules/translation/controllers/translation_audiohistory_controller.dart

@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../controllers/translation_controller.dart';
import 'dart:async';
import '../models/translation_models.dart'; // 添加异步支持
class TranslationAudioHistoryController extends GetxController {
// 引用主翻译控制器,以访问历史记录数据
final TranslationController translationController =
Get.find<TranslationController>();
// 按日期分组的历史记录
final Rx<Map<String, List<TranslationItem>>> groupedHistory =
Rx<Map<String, List<TranslationItem>>>({});
// 排序后的日期列表
final RxList<String> sortedDates = <String>[].obs;
// 会话映射表,用于存储每个会话的所有项目
final Rx<Map<String, List<TranslationItem>>> sessionMap =
Rx<Map<String, List<TranslationItem>>>({});
// 加载状态
final isLoading = true.obs;
@override
void onInit() {
super.onInit();
// 确保所有翻译操作都已停止
translationController.stopAll();
// 异步加载数据,避免阻塞主线程
loadHistoryData();
}
// 异步加载历史数据
Future<void> loadHistoryData() async {
isLoading.value = true;
// 使用 compute 或 Future.microtask 在后台处理数据
await Future.microtask(() {
_groupHistoryByDate();
_groupHistoryBySession();
});
isLoading.value = false;
}
// 按日期对历史记录进行分组
void _groupHistoryByDate() {
final Map<String, List<TranslationItem>> newGroupedHistory = {};
// 获取完整的历史记录,而不仅仅是当前显示的记录
final List<TranslationItem> allHistory =
translationController.getAllHistory();
// 筛选出每个会话的第一条记录
final List<TranslationItem> firstItemsOfSessions =
allHistory.where((item) => item.isFirstInSession).toList();
for (var item in firstItemsOfSessions) {
final DateTime date = item.timestamp;
String key;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final itemDate = DateTime(date.year, date.month, date.day);
if (itemDate == today) {
key = 'TODAY';
} else {
key = DateFormat('yyyy-MM-dd').format(date);
}
if (!newGroupedHistory.containsKey(key)) {
newGroupedHistory[key] = [];
}
newGroupedHistory[key]!.add(item);
}
// 对每个日期组内的项目按时间倒序排序
newGroupedHistory.forEach((key, items) {
items.sort((a, b) => b.timestamp.compareTo(a.timestamp));
});
// 更新分组后的历史记录
groupedHistory.value = newGroupedHistory;
// 按日期排序
final List<String> dates = newGroupedHistory.keys.toList()
..sort((a, b) {
if (a == 'TODAY') return -1;
if (b == 'TODAY') return 1;
if (a == '1 DAY AGO') return -1;
if (b == '1 DAY AGO') return 1;
if (a == '2 DAYS AGO') return -1;
if (b == '2 DAYS AGO') return 1;
return b.compareTo(a); // 降序排列其他日期
});
sortedDates.value = dates;
}
// 按会话ID对历史记录进行分组
void _groupHistoryBySession() {
final Map<String, List<TranslationItem>> newSessionMap = {};
// 获取完整的历史记录进行会话分组
final List<TranslationItem> allHistory =
translationController.getAllHistory();
for (var item in allHistory) {
if (!newSessionMap.containsKey(item.sessionId)) {
newSessionMap[item.sessionId] = [];
}
newSessionMap[item.sessionId]!.add(item);
}
// 对每个会话内的项目按时间升序排序
newSessionMap.forEach((key, items) {
items.sort((a, b) => a.timestamp.compareTo(b.timestamp));
});
// 更新会话映射表
sessionMap.value = newSessionMap;
}
// 获取指定会话的所有翻译项
List<TranslationItem> getSessionItems(String sessionId) {
return sessionMap.value[sessionId] ?? [];
}
// 播放翻译项
void playTranslationItem(TranslationItem item) {
translationController.playTranslationItem(item);
}
// 播放指定文本
void playText(String text) {
translationController.playTranslatedText(text);
}
// 检查是否有历史记录
bool get hasHistory => translationController.translationHistory.isNotEmpty;
// 删除指定会话的历史记录
Future<void> deleteSessionHistory(String sessionId) async {
try {
// 调用主控制器的删除方法
translationController.deleteSessionHistory(sessionId);
// 重新加载和分组历史数据
await loadHistoryData();
// 显示删除成功提示
Get.snackbar(
'删除成功',
'已删除该会话的所有翻译记录',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Get.isDarkMode ? Colors.grey[800] : Colors.grey[200],
colorText: Get.isDarkMode ? Colors.white : Colors.black,
duration: const Duration(seconds: 2),
);
} catch (e) {
// 显示删除失败提示
Get.snackbar(
'删除失败',
'删除会话记录时发生错误: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[400],
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
}
}
}

1102
lib/modules/translation/controllers/translation_controller.dart

File diff suppressed because it is too large

82
lib/modules/translation/controllers/translation_detail_controller.dart

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../data/services/volcano_ai_service.dart'; import '../../../data/services/volcano_ai_service.dart';
import '../controllers/translation_controller.dart'; import '../controllers/translation_controller.dart';
import '../controllers/translation_history_controller.dart'; import 'translation_audiohistory_controller.dart';
import '../models/translation_models.dart'; // 添加这行导入
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'dart:async'; import 'dart:async';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
@ -13,25 +14,25 @@ class TranslationDetailController extends GetxController {
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); final VolcanoAIService _aiService = Get.find<VolcanoAIService>();
final GetStorage _storage = GetStorage(); final GetStorage _storage = GetStorage();
late final TranslationUsageService _usageService; late final TranslationUsageService _usageService;
// 存储键前缀 // 存储键前缀
static const String _summaryKeyPrefix = 'translation_summary_'; static const String _summaryKeyPrefix = 'translation_summary_';
// 超时设置(秒) // 超时设置(秒)
static const int _defaultTimeoutDuration = 30; static const int _defaultTimeoutDuration = 30;
static const int _extendedTimeoutDuration = 60; static const int _extendedTimeoutDuration = 60;
// 状态变量 // 状态变量
final selectedTabIndex = 0.obs; final selectedTabIndex = 0.obs;
final isGeneratingSummary = false.obs; final isGeneratingSummary = false.obs;
final summaryResult = ''.obs; final summaryResult = ''.obs;
final isLoading = true.obs; final isLoading = true.obs;
// 当前会话信息 // 当前会话信息
late final TranslationItem currentItem; late final TranslationItem currentItem;
late final List<TranslationItem> sessionItems; late final List<TranslationItem> sessionItems;
late final TranslationHistoryController historyController; late final TranslationAudioHistoryController historyController;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -59,7 +60,7 @@ class TranslationDetailController extends GetxController {
isLoading.value = false; isLoading.value = false;
} }
} }
// 异步初始化方法 // 异步初始化方法
Future<void> _asyncInitialize() async { Future<void> _asyncInitialize() async {
isLoading.value = true; isLoading.value = true;
@ -76,7 +77,7 @@ class TranslationDetailController extends GetxController {
isLoading.value = false; isLoading.value = false;
} }
} }
// 加载保存的摘要 // 加载保存的摘要
void loadSavedSummary() { void loadSavedSummary() {
try { try {
@ -88,62 +89,64 @@ class TranslationDetailController extends GetxController {
Logger.error('加载摘要失败: ${e.toString()}'); Logger.error('加载摘要失败: ${e.toString()}');
} }
} }
// 保存摘要 // 保存摘要
void saveSummary(String summary) { void saveSummary(String summary) {
if (summary.isEmpty) return; if (summary.isEmpty) return;
try { try {
_storage.write(_getSummaryKey(), summary); _storage.write(_getSummaryKey(), summary);
} catch (e) { } catch (e) {
Logger.error('保存摘要失败: ${e.toString()}'); Logger.error('保存摘要失败: ${e.toString()}');
} }
} }
// 获取摘要的存储键 // 获取摘要的存储键
String _getSummaryKey() => _summaryKeyPrefix + currentItem.sessionId; String _getSummaryKey() => _summaryKeyPrefix + currentItem.sessionId;
// 切换选项卡 // 切换选项卡
void switchTab(int index) => selectedTabIndex.value = index; void switchTab(int index) => selectedTabIndex.value = index;
// 播放翻译项 // 播放翻译项
void playTranslationItem(TranslationItem item) => historyController.playTranslationItem(item); void playTranslationItem(TranslationItem item) =>
historyController.playTranslationItem(item);
// 播放文本 // 播放文本
void playText(String text) => historyController.playText(text); void playText(String text) => historyController.playText(text);
// 获取合并的源文本 // 获取合并的源文本
String getCombinedSourceText() => sessionItems.map((e) => e.sourceText).join('\n\n'); String getCombinedSourceText() =>
sessionItems.map((e) => e.sourceText).join('\n\n');
// 获取合并的翻译文本 // 获取合并的翻译文本
String getCombinedTranslatedText() => sessionItems.map((e) => e.translatedText).join('\n\n'); String getCombinedTranslatedText() =>
sessionItems.map((e) => e.translatedText).join('\n\n');
// 使用延长的超时时间重试提取摘要 // 使用延长的超时时间重试提取摘要
Future<String> retryExtractSummaryWithExtendedTimeout(String text) async { Future<String> retryExtractSummaryWithExtendedTimeout(String text) async {
return _extractSummaryInternal(text, _extendedTimeoutDuration); return _extractSummaryInternal(text, _extendedTimeoutDuration);
} }
// 提取摘要 // 提取摘要
Future<String> extractSummary(String text) async { Future<String> extractSummary(String text) async {
return _extractSummaryInternal(text, _defaultTimeoutDuration); return _extractSummaryInternal(text, _defaultTimeoutDuration);
} }
// 内部摘要提取方法,避免代码重复 // 内部摘要提取方法,避免代码重复
Future<String> _extractSummaryInternal(String text, int timeoutDuration) async { Future<String> _extractSummaryInternal(
String text, int timeoutDuration) async {
if (text.isEmpty) { if (text.isEmpty) {
return '无法生成摘要:文本为空'; return '无法生成摘要:文本为空';
} }
isGeneratingSummary.value = true; isGeneratingSummary.value = true;
try { try {
// 构建提示词 // 构建提示词
final messages = [ final messages = [
{'role': 'user', 'content': '请为以下文本生成一个简洁的摘要,突出重点内容:\n\n$text'}, {'role': 'user', 'content': '请为以下文本生成一个简洁的摘要,突出重点内容:\n\n$text'},
]; ];
// 系统提示词 // 系统提示词
const systemPrompt = ''' const systemPrompt = '''
你是一个专业的文本摘要助手,擅长从会议记录、对话和文本中提取关键信息并生成简洁的摘要。 你是一个专业的文本摘要助手,擅长从会议记录、对话和文本中提取关键信息并生成简洁的摘要。
@ -160,24 +163,26 @@ class TranslationDetailController extends GetxController {
4. 摘要总长度应该是原文的1/3或更短 4. 摘要总长度应该是原文的1/3或更短
5. 确保列表中的每个要点都简明扼要,突出重点 5. 确保列表中的每个要点都简明扼要,突出重点
'''; ''';
String result; String result;
try { try {
// 使用超时限制 // 使用超时限制
result = await _aiService.sendMessage( result = await _aiService
.sendMessage(
messages: messages, messages: messages,
systemPrompt: systemPrompt, systemPrompt: systemPrompt,
).timeout(Duration(seconds: timeoutDuration), onTimeout: () { )
.timeout(Duration(seconds: timeoutDuration), onTimeout: () {
throw TimeoutException('请求超时', Duration(seconds: timeoutDuration)); throw TimeoutException('请求超时', Duration(seconds: timeoutDuration));
}); });
} on TimeoutException catch (_) { } on TimeoutException catch (_) {
final timeoutMessage = timeoutDuration == _defaultTimeoutDuration final timeoutMessage = timeoutDuration == _defaultTimeoutDuration
? '生成摘要超时,请稍后重试' ? '生成摘要超时,请稍后重试'
: '生成摘要超时,请检查网络连接后重试'; : '生成摘要超时,请检查网络连接后重试';
summaryResult.value = timeoutMessage; summaryResult.value = timeoutMessage;
return summaryResult.value; return summaryResult.value;
} }
// 更新结果并保存 // 更新结果并保存
if (result.isNotEmpty) { if (result.isNotEmpty) {
summaryResult.value = result; summaryResult.value = result;
@ -201,20 +206,19 @@ class TranslationDetailController extends GetxController {
isGeneratingSummary.value = false; isGeneratingSummary.value = false;
} }
} }
// 删除单条翻译记录 // 删除单条翻译记录
Future<void> deleteTranslationItem(TranslationItem item) async { Future<void> deleteTranslationItem(TranslationItem item) async {
try { try {
// 调用主控制器的删除方法 // 调用主控制器的删除方法
historyController.translationController.deleteTranslationItem( historyController.translationController
item.sessionId, .deleteTranslationItem(item.sessionId, item.timestamp);
item.timestamp
);
// 从当前会话项目列表中移除该项目 // 从当前会话项目列表中移除该项目
sessionItems.removeWhere((sessionItem) => sessionItems.removeWhere((sessionItem) =>
sessionItem.sessionId == item.sessionId && sessionItem.sessionId == item.sessionId &&
sessionItem.timestamp.millisecondsSinceEpoch == item.timestamp.millisecondsSinceEpoch); sessionItem.timestamp.millisecondsSinceEpoch ==
item.timestamp.millisecondsSinceEpoch);
// 重新加载历史控制器的数据 // 重新加载历史控制器的数据
await historyController.loadHistoryData(); await historyController.loadHistoryData();
@ -261,4 +265,4 @@ class TranslationDetailController extends GetxController {
historyController.translationController.stopAll(); historyController.translationController.stopAll();
super.onClose(); super.onClose();
} }
} }

503
lib/modules/translation/controllers/translation_history_controller.dart

@ -1,174 +1,371 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:get_storage/get_storage.dart';
import '../controllers/translation_controller.dart'; import '../../../core/utils/logger.dart';
import 'dart:async'; // 添加异步支持 import '../models/translation_models.dart';
class TranslationHistoryController extends GetxController { /// 翻译历史记录管理类
// 引用主翻译控制器,以访问历史记录数据 /// 负责管理翻译历史的存储、加载、分页等功能
final TranslationController translationController = Get.find<TranslationController>(); class TranslationHistoryManager extends GetxController {
static const String TAG = 'TranslationHistoryManager';
// 按日期分组的历史记录
final Rx<Map<String, List<TranslationItem>>> groupedHistory = Rx<Map<String, List<TranslationItem>>>({}); // 存储相关配置
static const String _historyKey = 'translation_history';
// 排序后的日期列表 static const int _maxStorageItems = 1000; // 存储限制
final RxList<String> sortedDates = <String>[].obs; static const int _maxDisplayItems = 20; // 显示限制
// 会话映射表,用于存储每个会话的所有项目 final GetStorage _storage = GetStorage();
final Rx<Map<String, List<TranslationItem>>> sessionMap = Rx<Map<String, List<TranslationItem>>>({});
// 翻译历史数据
// 加载状态 final translationHistory = <TranslationItem>[].obs;
final isLoading = true.obs;
// 分页加载相关状态
@override final isLoadingMore = false.obs;
void onInit() { final hasMoreHistory = true.obs;
super.onInit(); final currentPage = 1.obs;
// 确保所有翻译操作都已停止 final pageSize = 20;
translationController.stopAll(); final isLoadingMoreError = false.obs;
// 异步加载数据,避免阻塞主线程 bool _scrollListenerSetup = false;
loadHistoryData();
// 滚动控制器
final ScrollController scrollController = ScrollController();
/// 初始化历史管理器
void initialize() {
loadTranslationHistory();
_setupScrollListener();
} }
// 异步加载历史数据 /// 加载翻译历史记录
Future<void> loadHistoryData() async { /// 初始化时只加载最近的显示条数,提高性能
isLoading.value = true; void loadTranslationHistory() {
final historyJson = _storage.read(_historyKey);
// 使用 compute 或 Future.microtask 在后台处理数据 if (historyJson != null) {
await Future.microtask(() { try {
_groupHistoryByDate(); final List<dynamic> historyList = jsonDecode(historyJson);
_groupHistoryBySession(); final List<TranslationItem> allHistory =
}); historyList.map((item) => TranslationItem.fromJson(item)).toList();
isLoading.value = false; // 初始化分页状态
currentPage.value = 1;
isLoadingMore.value = false;
isLoadingMoreError.value = false;
// 主页面只显示最近的记录,保证界面性能
if (allHistory.length > _maxDisplayItems) {
translationHistory.value =
allHistory.sublist(allHistory.length - _maxDisplayItems);
hasMoreHistory.value = true;
} else {
translationHistory.value = allHistory;
hasMoreHistory.value = false;
}
Logger.info('翻译历史加载成功: ${translationHistory.length}条记录');
} catch (e) {
_storage.remove(_historyKey);
Logger.error('加载翻译历史失败: ${e.toString()}');
}
} else {
// 没有历史数据时初始化分页状态
hasMoreHistory.value = false;
currentPage.value = 1;
isLoadingMore.value = false;
isLoadingMoreError.value = false;
}
} }
// 按日期对历史记录进行分组 /// 保存翻译历史记录
void _groupHistoryByDate() { /// 只保存完成态记录,过滤中间态记录
final Map<String, List<TranslationItem>> newGroupedHistory = {}; void saveTranslationHistory() {
try {
// 获取完整的历史记录,而不仅仅是当前显示的记录 // 只保存完成态记录
final List<TranslationItem> allHistory = translationController.getAllHistory(); final List<TranslationItem> finalRecords =
translationHistory.where((item) => !item.isIntermediate).toList();
// 筛选出每个会话的第一条记录
final List<TranslationItem> firstItemsOfSessions = allHistory // 应用存储限制
.where((item) => item.isFirstInSession) List<TranslationItem> historyToSave = finalRecords;
.toList(); if (finalRecords.length > _maxStorageItems) {
historyToSave =
for (var item in firstItemsOfSessions) { finalRecords.sublist(finalRecords.length - _maxStorageItems);
final DateTime date = item.timestamp;
String key;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final itemDate = DateTime(date.year, date.month, date.day);
if (itemDate == today) {
key = 'TODAY';
}
else {
key = DateFormat('yyyy-MM-dd').format(date);
} }
if (!newGroupedHistory.containsKey(key)) { // 保存到本地存储
newGroupedHistory[key] = []; final List<Map<String, dynamic>> historyJson =
historyToSave.map((item) => item.toJson()).toList();
_storage.write(_historyKey, jsonEncode(historyJson));
Logger.info('翻译历史保存成功: 存储${historyToSave.length}条记录');
} catch (e) {
Logger.error('保存翻译历史失败: ${e.toString()}');
}
}
/// 添加新的翻译记录
/// [item] 要添加的翻译记录
/// [scrollToBottom] 是否滚动到底部
void addTranslationItem(TranslationItem item, {bool scrollToBottom = true}) {
translationHistory.add(item);
if (scrollToBottom) {
_scrollToBottom();
}
}
/// 更新翻译记录
/// [timestamp] 记录的时间戳,用于定位记录
/// [updater] 更新函数
bool updateTranslationItem(
DateTime timestamp, Function(TranslationItem) updater) {
final index = translationHistory
.indexWhere((item) => item.timestamp.isAtSameMomentAs(timestamp));
if (index != -1) {
updater(translationHistory[index]);
translationHistory.refresh();
return true;
}
return false;
}
/// 加载更多历史记录(分页加载)
Future<void> loadMoreHistory() async {
if (isLoadingMore.value || !hasMoreHistory.value) {
return;
}
try {
isLoadingMore.value = true;
isLoadingMoreError.value = false;
final nextPage = currentPage.value + 1;
Logger.i(TAG, '正在加载第 $nextPage 页翻译历史...');
// 从存储中获取所有历史记录
final historyJson = _storage.read(_historyKey);
if (historyJson != null) {
final List<dynamic> historyList = jsonDecode(historyJson);
final List<TranslationItem> allHistory =
historyList.map((item) => TranslationItem.fromJson(item)).toList();
// 计算分页参数
final totalItems = allHistory.length;
final endIndex = totalItems - ((nextPage - 1) * pageSize);
final startIndex = totalItems - (nextPage * pageSize);
if (endIndex > 0) {
final actualStartIndex = startIndex < 0 ? 0 : startIndex;
final actualEndIndex = endIndex > totalItems ? totalItems : endIndex;
final pageItems =
allHistory.sublist(actualStartIndex, actualEndIndex);
if (pageItems.isNotEmpty) {
await Future.delayed(const Duration(milliseconds: 300));
// reverse模式下:将新记录插入到现有记录列表的开头
final updatedHistory = [...pageItems, ...translationHistory];
translationHistory.assignAll(updatedHistory);
// 更新分页状态
currentPage.value = nextPage;
hasMoreHistory.value = startIndex > 0;
Logger.i(TAG, '成功加载 ${pageItems.length} 条历史记录');
} else {
hasMoreHistory.value = false;
}
} else {
hasMoreHistory.value = false;
}
} else {
hasMoreHistory.value = false;
} }
} catch (e) {
newGroupedHistory[key]!.add(item); Logger.e(TAG, '加载更多历史记录失败: $e');
isLoadingMoreError.value = true;
} finally {
isLoadingMore.value = false;
} }
// 对每个日期组内的项目按时间倒序排序
newGroupedHistory.forEach((key, items) {
items.sort((a, b) => b.timestamp.compareTo(a.timestamp));
});
// 更新分组后的历史记录
groupedHistory.value = newGroupedHistory;
// 按日期排序
final List<String> dates = newGroupedHistory.keys.toList()
..sort((a, b) {
if (a == 'TODAY') return -1;
if (b == 'TODAY') return 1;
if (a == '1 DAY AGO') return -1;
if (b == '1 DAY AGO') return 1;
if (a == '2 DAYS AGO') return -1;
if (b == '2 DAYS AGO') return 1;
return b.compareTo(a); // 降序排列其他日期
});
sortedDates.value = dates;
} }
// 按会话ID对历史记录进行分组
void _groupHistoryBySession() {
final Map<String, List<TranslationItem>> newSessionMap = {};
// 获取完整的历史记录进行会话分组 /// 获取所有完成态历史记录
final List<TranslationItem> allHistory = translationController.getAllHistory(); List<TranslationItem> getAllHistory() {
try {
final List<TranslationItem> result = <TranslationItem>[];
final Set<String> existingIds = <String>{};
// 添加存储的历史记录
final historyJson = _storage.read(_historyKey);
if (historyJson != null) {
final List<dynamic> historyList = jsonDecode(historyJson);
final List<TranslationItem> storedHistory =
historyList.map((item) => TranslationItem.fromJson(item)).toList();
for (var item in allHistory) { for (final item in storedHistory) {
if (!newSessionMap.containsKey(item.sessionId)) { final id =
newSessionMap[item.sessionId] = []; '${item.sessionId}_${item.timestamp.millisecondsSinceEpoch}';
if (!existingIds.contains(id)) {
existingIds.add(id);
result.add(item);
}
}
} }
newSessionMap[item.sessionId]!.add(item); // 添加当前内存中的完成态记录
for (final item in translationHistory) {
if (!item.isIntermediate) {
final id =
'${item.sessionId}_${item.timestamp.millisecondsSinceEpoch}';
if (!existingIds.contains(id)) {
existingIds.add(id);
result.add(item);
}
}
}
// 按时间排序
result.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return result;
} catch (e) {
Logger.error('获取完整历史记录失败: ${e.toString()}');
return translationHistory.where((item) => !item.isIntermediate).toList();
} }
// 对每个会话内的项目按时间升序排序
newSessionMap.forEach((key, items) {
items.sort((a, b) => a.timestamp.compareTo(b.timestamp));
});
// 更新会话映射表
sessionMap.value = newSessionMap;
}
// 获取指定会话的所有翻译项
List<TranslationItem> getSessionItems(String sessionId) {
return sessionMap.value[sessionId] ?? [];
} }
// 播放翻译项 /// 清除所有翻译历史
void playTranslationItem(TranslationItem item) { void clearTranslationHistory() {
translationController.playTranslationItem(item); translationHistory.clear();
_storage.remove(_historyKey);
Logger.info('翻译历史已清除');
} }
// 播放指定文本 /// 删除指定会话的所有翻译记录
void playText(String text) { void deleteSessionHistory(String sessionId) {
translationController.playTranslatedText(text); try {
// 从内存中删除
final originalCount = translationHistory.length;
translationHistory.removeWhere((item) => item.sessionId == sessionId);
final removedFromMemory = originalCount - translationHistory.length;
// 从存储中删除
final historyJson = _storage.read(_historyKey);
int removedFromStorage = 0;
if (historyJson != null) {
final List<dynamic> historyList = jsonDecode(historyJson);
final List<TranslationItem> storedHistory =
historyList.map((item) => TranslationItem.fromJson(item)).toList();
final originalStoredCount = storedHistory.length;
storedHistory.removeWhere((item) => item.sessionId == sessionId);
removedFromStorage = originalStoredCount - storedHistory.length;
// 重新保存
final List<Map<String, dynamic>> updatedHistoryJson =
storedHistory.map((item) => item.toJson()).toList();
_storage.write(_historyKey, jsonEncode(updatedHistoryJson));
}
translationHistory.refresh();
Logger.info(
'删除会话历史成功: sessionId=$sessionId, 内存删除${removedFromMemory}条, 存储删除${removedFromStorage}条');
} catch (e) {
Logger.error('删除会话历史失败: ${e.toString()}');
rethrow;
}
} }
// 检查是否有历史记录
bool get hasHistory => translationController.translationHistory.isNotEmpty;
// 删除指定会话的历史记录 /// 删除指定的单条翻译记录
Future<void> deleteSessionHistory(String sessionId) async { void deleteTranslationItem(String sessionId, DateTime timestamp) {
try { try {
// 调用主控制器的删除方法 // 从内存中删除
translationController.deleteSessionHistory(sessionId); final originalCount = translationHistory.length;
translationHistory.removeWhere((item) =>
// 重新加载和分组历史数据 item.sessionId == sessionId &&
await loadHistoryData(); item.timestamp.millisecondsSinceEpoch ==
timestamp.millisecondsSinceEpoch);
// 显示删除成功提示 final removedFromMemory = originalCount - translationHistory.length;
Get.snackbar(
'删除成功', // 从存储中删除
'已删除该会话的所有翻译记录', final historyJson = _storage.read(_historyKey);
snackPosition: SnackPosition.BOTTOM, int removedFromStorage = 0;
backgroundColor: Get.isDarkMode ? Colors.grey[800] : Colors.grey[200],
colorText: Get.isDarkMode ? Colors.white : Colors.black, if (historyJson != null) {
duration: const Duration(seconds: 2), final List<dynamic> historyList = jsonDecode(historyJson);
); final List<TranslationItem> storedHistory =
historyList.map((item) => TranslationItem.fromJson(item)).toList();
final originalStoredCount = storedHistory.length;
storedHistory.removeWhere((item) =>
item.sessionId == sessionId &&
item.timestamp.millisecondsSinceEpoch ==
timestamp.millisecondsSinceEpoch);
removedFromStorage = originalStoredCount - storedHistory.length;
// 重新保存
final List<Map<String, dynamic>> updatedHistoryJson =
storedHistory.map((item) => item.toJson()).toList();
_storage.write(_historyKey, jsonEncode(updatedHistoryJson));
}
translationHistory.refresh();
Logger.info(
'删除单条翻译记录成功: sessionId=$sessionId, 内存删除${removedFromMemory}条, 存储删除${removedFromStorage}条');
} catch (e) { } catch (e) {
// 显示删除失败提示 Logger.error('删除单条翻译记录失败: ${e.toString()}');
Get.snackbar( rethrow;
'删除失败',
'删除会话记录时发生错误: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[400],
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
} }
} }
}
/// 滚动到底部
void scrollToBottom({bool animate = true}) {
_scrollToBottom(animate: animate);
}
/// 设置滚动监听器
void _setupScrollListener() {
if (_scrollListenerSetup) return;
scrollController.addListener(() {
// reverse模式下:检查是否滚动到底部
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 100) {
if (hasMoreHistory.value && !isLoadingMore.value) {
Logger.d(TAG, '触发加载更多翻译历史记录');
loadMoreHistory();
}
}
});
_scrollListenerSetup = true;
}
/// 内部滚动到底部方法
void _scrollToBottom({bool animate = true}) {
if (scrollController.hasClients && translationHistory.isNotEmpty) {
try {
const targetPosition = 0.0; // reverse模式下最新消息在顶部
if (animate) {
scrollController.animateTo(
targetPosition,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} else {
scrollController.jumpTo(targetPosition);
}
} catch (e) {
Logger.error('滚动到顶部失败: ${e.toString()}');
}
}
}
/// 释放资源
void dispose() {
scrollController.dispose();
Logger.info('翻译历史管理器已释放');
}
}

76
lib/modules/translation/models/translation_models.dart

@ -0,0 +1,76 @@
import 'package:get/get.dart';
import '../../../data/services/language_manager.dart';
/// 翻译项模型
/// 表示一条翻译记录的完整信息
class TranslationItem {
String sourceText;
String translatedText;
String sourceLanguageCode;
String targetLanguageCode;
final DateTime timestamp;
String sessionId;
final bool isFirstInSession;
bool isIntermediate; // 是否为中间结果
TranslationItem({
required this.sourceText,
required this.translatedText,
required this.sourceLanguageCode,
required this.targetLanguageCode,
required this.timestamp,
required this.sessionId,
this.isFirstInSession = false,
this.isIntermediate = false,
});
/// 从JSON创建TranslationItem实例
factory TranslationItem.fromJson(Map<String, dynamic> json) {
DateTime timestamp;
try {
timestamp = DateTime.parse(json['timestamp']);
} catch (e) {
timestamp = DateTime.now();
}
return TranslationItem(
sourceText: json['sourceText'] ?? '',
translatedText: json['translatedText'] ?? '',
sourceLanguageCode: json['sourceLanguageCode'] ?? 'zh-CN',
targetLanguageCode: json['targetLanguageCode'] ?? 'en-US',
timestamp: timestamp,
sessionId:
json['sessionId'] ?? DateTime.now().millisecondsSinceEpoch.toString(),
isFirstInSession: json['isFirstInSession'] ?? false,
isIntermediate: false, // 从存储加载的都是最终结果
);
}
/// 转换为JSON格式
Map<String, dynamic> toJson() {
return {
'sourceText': sourceText,
'translatedText': translatedText,
'sourceLanguageCode': sourceLanguageCode,
'targetLanguageCode': targetLanguageCode,
'timestamp': timestamp.toIso8601String(),
'sessionId': sessionId,
'isFirstInSession': isFirstInSession,
// 不保存isIntermediate,因为存储的都是最终结果
};
}
/// 获取源语言的中文名称
String get sourceLanguageName {
final languageManager = Get.find<LanguageManager>();
return languageManager.getChineseNameByAsrCode(sourceLanguageCode) ??
'未知语言';
}
/// 获取目标语言的中文名称
String get targetLanguageName {
final languageManager = Get.find<LanguageManager>();
return languageManager.getChineseNameByAsrCode(targetLanguageCode) ??
'未知语言';
}
}

3
lib/modules/translation/views/translation_detail_view.dart

@ -2,12 +2,13 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../controllers/translation_controller.dart'; import '../controllers/translation_controller.dart';
import '../controllers/translation_history_controller.dart'; import '../controllers/translation_audiohistory_controller.dart';
import '../controllers/translation_detail_controller.dart'; import '../controllers/translation_detail_controller.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../models/translation_models.dart';
class TranslationDetailView extends GetView<TranslationDetailController> { class TranslationDetailView extends GetView<TranslationDetailController> {
TranslationDetailView({Key? key}) : super(key: key); TranslationDetailView({Key? key}) : super(key: key);

6
lib/modules/translation/views/translation_history_view.dart

@ -3,13 +3,15 @@ import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../controllers/translation_controller.dart'; import '../controllers/translation_controller.dart';
import '../controllers/translation_history_controller.dart'; import '../controllers/translation_audiohistory_controller.dart';
import '../bindings/translation_detail_binding.dart'; import '../bindings/translation_detail_binding.dart';
import '../models/translation_models.dart';
import 'translation_detail_view.dart'; import 'translation_detail_view.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../data/services/language_manager.dart'; import '../../../data/services/language_manager.dart';
class TranslationHistoryView extends GetView<TranslationHistoryController> { class TranslationHistoryView
extends GetView<TranslationAudioHistoryController> {
const TranslationHistoryView({Key? key}) : super(key: key); const TranslationHistoryView({Key? key}) : super(key: key);
@override @override

1
lib/modules/translation/views/translation_view.dart

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../controllers/translation_controller.dart'; import '../controllers/translation_controller.dart';
import '../models/translation_models.dart';
import '../views/translation_history_view.dart'; import '../views/translation_history_view.dart';
import '../bindings/translation_history_binding.dart'; import '../bindings/translation_history_binding.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';

10
lib/routes/app_pages.dart

@ -1,4 +1,9 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:qq_music/qq_music.dart';
import 'package:voitrans/modules/qqmusic/bindings/qqmusic_playlist_binding.dart';
import 'package:voitrans/modules/qqmusic/views/qqmusic_playlist_view.dart';
import 'package:voitrans/modules/qqmusic_test/bindings/qqmusic_binding.dart';
import 'package:voitrans/modules/qqmusic_test/views/qqmusic_view.dart';
import '/modules/ble/bindings/ble_binding.dart'; import '/modules/ble/bindings/ble_binding.dart';
import '../modules/ble/views/ble_view.dart'; import '../modules/ble/views/ble_view.dart';
@ -275,5 +280,10 @@ abstract class AppPages {
page: () => const BleView(), page: () => const BleView(),
binding: BleBinding(), binding: BleBinding(),
), ),
GetPage(
name: Routes.qqmusic,
page: () => const QqmusicPlaylistView(),
binding: QQMusicPlaylistBinding(),
),
]; ];
} }

2
lib/routes/app_routes.dart

@ -61,4 +61,6 @@ abstract class Routes {
// ble设备管理 // ble设备管理
static const ble = '/ble'; static const ble = '/ble';
// ble设备管理
static const qqmusic = '/qqmusic';
} }

2
local_plugins/agent_service/android/build.gradle.kts

@ -53,4 +53,6 @@ dependencies {
add("compileOnly", project(":location_service")) add("compileOnly", project(":location_service"))
add("compileOnly", project(":bytedance_speech")) add("compileOnly", project(":bytedance_speech"))
add("compileOnly", project(":user_portrait")) add("compileOnly", project(":user_portrait"))
add("compileOnly", project(":qq_music"))
} }

200
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -13,6 +13,7 @@ import com.yunqiinnovation.music_service.MusicServiceStarter
import com.yunqiinnovation.amap_service.NavigationServiceHelper import com.yunqiinnovation.amap_service.NavigationServiceHelper
import com.yunqiinnovation.user_portrait.UserPortrait import com.yunqiinnovation.user_portrait.UserPortrait
import com.yunqiinnovation.location_service.LocationService import com.yunqiinnovation.location_service.LocationService
import com.yunqiinnovation.qq_music.QQMusicSingleton
import android.util.Log import android.util.Log
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.Collections import java.util.Collections
@ -31,6 +32,11 @@ import com.yunqiinnovation.agent_service.Utils
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.Date
/** /**
* 代理服务事件监听接口 * 代理服务事件监听接口
@ -874,6 +880,7 @@ object AgentService : CoroutineScope {
var userMetadata = JSONObject() var userMetadata = JSONObject()
var reply = true; //是否回复 var reply = true; //是否回复
var broadcast = true //是否播发 var broadcast = true //是否播发
var nocard = false;
// 如果有图片,添加图片信息到元数据 // 如果有图片,添加图片信息到元数据
if (hasImage) { if (hasImage) {
@ -888,6 +895,9 @@ object AgentService : CoroutineScope {
if (systemPrompt.isNotEmpty()) { if (systemPrompt.isNotEmpty()) {
val mapdata = UserPortrait.getAll().toMutableMap() val mapdata = UserPortrait.getAll().toMutableMap()
mapdata["language"] = language mapdata["language"] = language
// 添加当前时间(格式化字符串)
val currentTimeFormatted = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
mapdata["current_time"] = currentTimeFormatted
// Log.d(TAG, "liwei--------- 用户画像: ${mapdata}") // Log.d(TAG, "liwei--------- 用户画像: ${mapdata}")
val _systemPrompt = Utils.fillTemplate(systemPrompt, mapdata) val _systemPrompt = Utils.fillTemplate(systemPrompt, mapdata)
messagesWithSystemPrompt.put(createSystemMessage(_systemPrompt)) messagesWithSystemPrompt.put(createSystemMessage(_systemPrompt))
@ -958,7 +968,7 @@ object AgentService : CoroutineScope {
return return
} }
responseBuilder.append(token) responseBuilder.append(token)
if (speakResponse && reply && broadcast) { if (speakResponse && reply && broadcast && token.isNotEmpty()) {
ttsService?.speakStream(token) ttsService?.speakStream(token)
// 在开始流式TTS时立即停止气泡音 // 在开始流式TTS时立即停止气泡音
audioPlayer?.stopAudio() audioPlayer?.stopAudio()
@ -1061,13 +1071,16 @@ object AgentService : CoroutineScope {
aiMetadata = resultmap["meta"] as String; aiMetadata = resultmap["meta"] as String;
broadcast = resultmap["broadcast"] as Boolean; broadcast = resultmap["broadcast"] as Boolean;
reply = !(resultmap["notreply"] as Boolean); reply = !(resultmap["notreply"] as Boolean);
sendEvent( nocard = resultmap["nocard"] as Boolean;
"function_call_result", mapOf( if (!nocard){
"function_call" to functionCall.toString(), sendEvent(
"result" to functionCallResult.toString(), "function_call_result", mapOf(
"meta" to aiMetadata, "function_call" to functionCall.toString(),
"result" to functionCallResult.toString(),
"meta" to aiMetadata,
)
) )
) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e( Log.e(
TAG, TAG,
@ -1778,6 +1791,7 @@ object AgentService : CoroutineScope {
val metadata: MutableMap<String, Any> = mutableMapOf() val metadata: MutableMap<String, Any> = mutableMapOf()
var broadcast = true; var broadcast = true;
var notreply = false; var notreply = false;
var nocard = false;
if (textStr.isNotEmpty()) { if (textStr.isNotEmpty()) {
val meta = JSONObject(textStr) val meta = JSONObject(textStr)
if (meta.has("broadcast")) { if (meta.has("broadcast")) {
@ -1786,6 +1800,10 @@ object AgentService : CoroutineScope {
if (meta.has("notreply")) { if (meta.has("notreply")) {
notreply = meta["notreply"] as Boolean notreply = meta["notreply"] as Boolean
} }
if (meta.has("nocard")) {
nocard = meta["nocard"] as Boolean
}
metadata[toolname] = meta // 现在可以赋值 metadata[toolname] = meta // 现在可以赋值
metadata["notreply"] = notreply // 现在可以赋值 metadata["notreply"] = notreply // 现在可以赋值
val metaStr = JSONObject(metadata as Map<*, *>).toString() val metaStr = JSONObject(metadata as Map<*, *>).toString()
@ -1840,19 +1858,52 @@ object AgentService : CoroutineScope {
} else { } else {
Log.w(TAG, "card_musiclist.musics 不是一个数组") Log.w(TAG, "card_musiclist.musics 不是一个数组")
} }
} else if (meta.has("card_navigation")) { //导航服务 } else if (toolname == "qqmusic_searchandplay_songs" && meta.has("data")) { //qq音乐
//"{\"mid\": \"$mid\", \"name\": \"$name\", \"artist\": \"$artist\", \"album\": \"$album\", \"duration\":$duration,\"pic\": \"$pic\"}"
val musics = meta.optJSONArray("data")
if (musics != null) {
val playlist = mutableListOf<Map<String, Any>>()
for (i in 0 until musics.length()) {
val item = musics.optJSONObject(i) ?: continue
val mid = item.optString("mid", "")
val name = item.optString("name", "")
val artist = item.optString("artist", "")
val album = item.optString("album", "")
val duration = item.optInt("duration", 0)
val pic = item.optLong("pic", 0)
playlist.add(
mapOf(
"mid" to mid,
"name" to name,
"artist" to artist,
"album" to album,
"duration" to duration,
"pic" to pic
)
)
}
if (playlist.isNotEmpty()) {
processQQMusicPlayList(playlist)
} else {
Log.w(TAG, "qqmusic_searchandplay_songs 中没有有效的音乐条目")
}
} else {
Log.w(TAG, "qqmusic_searchandplay_songs.musics 不是一个数组")
}
}else if (meta.has("card_navigation")) { //导航服务
val cardNavigation = meta.getJSONObject("card_navigation") val cardNavigation = meta.getJSONObject("card_navigation")
val start = cardNavigation.optString("start", "") val start = cardNavigation.optString("start", "")
val end = cardNavigation.optString("end", "") val end = cardNavigation.optString("end", "")
processNavigation(start, end); processNavigation(start, end);
} }
return mapOf("meta" to metaStr, "broadcast" to broadcast, "notreply" to notreply); return mapOf("meta" to metaStr, "broadcast" to broadcast, "notreply" to notreply,"nocard" to nocard);
} }
} catch (e: Exception) { } catch (e: Exception) {
// 捕获其他类型的异常(可选) // 捕获其他类型的异常(可选)
println("为解析到卡片数据!") println("为解析到卡片数据!")
} }
return mapOf("meta" to "", "broadcast" to true, "notreply" to false); return mapOf("meta" to "", "broadcast" to true, "notreply" to false,"nocard" to false);
} }
/** /**
@ -1892,7 +1943,39 @@ object AgentService : CoroutineScope {
Log.e(TAG, "音乐列表为空,未启动播放服务") Log.e(TAG, "音乐列表为空,未启动播放服务")
} }
} }
/**
* 处理音乐播放列表
* 将ai返回的音乐连接 在后台启动播放
*
* @param musicUrl 图片文件路径
* @param name 歌曲名称,默认为空
* @param singer 歌手,默认为false
* @return 是否成功开始处理
*/
fun processQQMusicPlayList(songs: List<Map<String, Any>>) {
// 如果 playlist 中有有效的歌曲,开始播放
if (songs.isNotEmpty()) {
val qqMusicSingleton = QQMusicSingleton.getInstance()
// 从songs中提取mid字段构建songMids列表
val songMids = songs.mapNotNull { song ->
song["mid"] as? String
}.filter { it.isNotEmpty() }
if (songMids.isNotEmpty()) {
qqMusicSingleton.playSongs(songMids, 0) { result ->
result.onSuccess {
Log.i(TAG, "QQ音乐播放列表启动成功,共${songMids.size}首歌曲")
}.onFailure { exception ->
Log.e(TAG, "QQ音乐播放列表启动失败: ${exception.message}")
}
}
} else {
Log.e(TAG, "音乐列表中没有有效的mid,未启动播放服务")
}
} else {
Log.e(TAG, "音乐列表为空,未启动播放服务")
}
}
/** /**
* 处理导航 * 处理导航
* 将ai返回的音乐连接 在后台启动播放 * 将ai返回的音乐连接 在后台启动播放
@ -1917,33 +2000,80 @@ object AgentService : CoroutineScope {
/* /*
* 发送统计事件 * 发送统计事件 - 安全版本
* */ * */
fun sendStatisticsEvent(actions:Map<String,Int>) { fun sendStatisticsEvent(actions: Map<String, Int>) {
val client = OkHttpClient() // 参数验证
if (actions.isEmpty()) {
Log.w(TAG, "统计事件数据为空,跳过发送")
return
}
// 检查服务器配置
if (serverurl.isEmpty()) {
Log.w(TAG, "服务器地址未配置,跳过统计事件发送")
return
}
if (usertoken.isEmpty()) {
Log.w(TAG, "用户令牌未配置,跳过统计事件发送")
return
}
// 外层 JSON 对象 // 使用协程异步处理,避免阻塞主线程
val jsonObject = JSONObject() launch(Dispatchers.IO) {
val actionsObject = JSONObject() try {
// 配置带超时的 OkHttpClient
val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
// 构建 JSON 数据
val jsonObject = JSONObject()
val actionsObject = JSONObject()
for ((key, value) in actions) {
actionsObject.put(key, value)
}
for ((key, value) in actions) { jsonObject.put("actions", actionsObject)
actionsObject.put(key, value)
} val json = jsonObject.toString()
val mediaType = "application/json".toMediaType()
val body = json.toRequestBody(mediaType)
// 构建请求
val request = Request.Builder()
.url("$serverurl/api/home/user_pushaction")
.addHeader("Authorization", usertoken)
.addHeader("Content-Type", "application/json")
.post(body)
.build()
// 使用异步调用
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.w(TAG, "发送统计事件失败: ${e.message}")
// 不抛出异常,只记录日志
}
jsonObject.put("actions", actionsObject) override fun onResponse(call: Call, response: Response) {
response.use {
val json = jsonObject.toString() if (response.isSuccessful) {
val mediaType = "application/json".toMediaType() Log.d(TAG, "统计事件发送成功")
val body = json.toRequestBody(mediaType) } else {
Log.w(TAG, "统计事件发送失败,HTTP状态码: ${response.code}")
val request = Request.Builder() }
.url("$serverurl/api/home/user_pushaction") }
.addHeader("Authorization", usertoken) // 添加 Token }
.post(body) })
.build()
Thread { } catch (e: Exception) {
val response = client.newCall(request).execute() // 捕获所有异常,防止崩溃
// println("sendStatisticsEvent Response: ${response.body?.string()}") Log.e(TAG, "发送统计事件时发生异常: ${e.message}", e)
}.start() }
}
} }
} }

5
local_plugins/agent_service/ios/agent_service/Package.swift

@ -16,8 +16,8 @@ let package = Package(
.package(path: "../../../chat_storage/ios/chat_storage"), .package(path: "../../../chat_storage/ios/chat_storage"),
.package(path: "../../../location_service/ios/location_service"), .package(path: "../../../location_service/ios/location_service"),
.package(path: "../../../music_service/ios/music_service"), .package(path: "../../../music_service/ios/music_service"),
.package(path: "../../../music_service/ios/user_portrait") .package(path: "../../../music_service/ios/user_portrait"),
.package(path: "../../../qq_music/ios/qq_music"),
], ],
targets: [ targets: [
.target( .target(
@ -32,6 +32,7 @@ let package = Package(
.product(name: "location-service", package: "location_service"), .product(name: "location-service", package: "location_service"),
.product(name: "music-service", package: "music_service"), .product(name: "music-service", package: "music_service"),
.product(name: "user-portrait", package: "user_portrait"), .product(name: "user-portrait", package: "user_portrait"),
.product(name: "qq-music", package: "qq_music"),
], ],
path: "Sources/agent_service", path: "Sources/agent_service",
resources: [ resources: [

36
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -10,6 +10,7 @@ import chat_api
import chat_storage import chat_storage
import music_service import music_service
import user_portrait import user_portrait
import qq_music
protocol AgentServiceListener: AnyObject { protocol AgentServiceListener: AnyObject {
func onEvent(eventName: String, data: [String: Any]) func onEvent(eventName: String, data: [String: Any])
@ -544,6 +545,10 @@ audioStream.saveAudioDataTo(data: audioData)
var info = UserPortrait.shared.getAll() var info = UserPortrait.shared.getAll()
info["language"] = self.language info["language"] = self.language
// Add current time (formatted string)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
info["current_time"] = dateFormatter.string(from: Date())
// 添加调试日志 // 添加调试日志
print("系统提示词模板: \(systemPrompt)") print("系统提示词模板: \(systemPrompt)")
print("用户画像数据: \(info)") print("用户画像数据: \(info)")
@ -777,6 +782,8 @@ private func handleJSONDictionary(_ jsonDict: [String: Any], toolName: String) -
processMusicListCard(musics) processMusicListCard(musics)
} else if let cardNavigation = jsonDict["card_navigation"] as? [String: Any] { } else if let cardNavigation = jsonDict["card_navigation"] as? [String: Any] {
processNavigationCard(cardNavigation) processNavigationCard(cardNavigation)
} else if let cardQQMusiclist = jsonDict["data"] as? [[String: Any]], toolName == "qqmusic_searchandplay_songs" {
processQQMusicPlayList(cardQQMusiclist)
} }
let metaStr: String let metaStr: String
@ -824,7 +831,31 @@ private func jsonToString(_ json: [String: Any]) -> String? {
return nil return nil
} }
} }
private func processQQMusicPlayList(_ musics: [[String: Any]]){
// 如果 playlist 中有有效的歌曲,开始播放
if !musics.isEmpty {
// 从songs中提取mid字段构建songMids列表
let songMids = musics.compactMap { song in
song["mid"] as? String
}.filter { !$0.isEmpty }
if !songMids.isEmpty {
// 调用QPlayAutoManager的playSongs方法
QPlayAutoManager.shared.playSongs(mids: songMids, index: 0) { success, errorMessage in
if success {
print("QQ音乐播放列表启动成功,共\(songMids.count)首歌曲")
} else {
print("QQ音乐播放列表启动失败: \(errorMessage ?? "未知错误")")
}
}
} else {
print("音乐列表中没有有效的mid,未启动播放服务")
}
} else {
print("音乐列表为空,未启动播放服务")
}
}
// 处理音乐卡片 // 处理音乐卡片
private func processMusicCard(_ cardMusic: [String: Any]) { private func processMusicCard(_ cardMusic: [String: Any]) {
let id = cardMusic["id"] as? Int ?? 0 let id = cardMusic["id"] as? Int ?? 0
@ -878,6 +909,7 @@ private func jsonToString(_ json: [String: Any]) -> String? {
// 实际处理导航逻辑 // 实际处理导航逻辑
print("启动导航: 从 \(start) 到 \(end)") print("启动导航: 从 \(start) 到 \(end)")
} }
// 后台播放单曲 // 后台播放单曲
private func processMusicPlay(_ song: [String: Any]) { private func processMusicPlay(_ song: [String: Any]) {
sendEvent(name: "music_play", data: song) sendEvent(name: "music_play", data: song)
@ -890,6 +922,8 @@ private func jsonToString(_ json: [String: Any]) -> String? {
MusicService.shared.playPlaylist(songs: songs) MusicService.shared.playPlaylist(songs: songs)
} }
// 添加定位监听 // 添加定位监听
// private func addlocationmonitor(){ // private func addlocationmonitor(){
// // 通常放在启动后或模块初始化时 // // 通常放在启动后或模块初始化时

187
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt

@ -49,9 +49,10 @@ class IntegratedSpeechTranslationService(
private const val INITIAL_SILENCE_TIMEOUT = "200" private const val INITIAL_SILENCE_TIMEOUT = "200"
} }
// 协程上下文 // 改为可变的协程作用域
private val job = SupervisorJob() private var job = SupervisorJob()
override val coroutineContext: CoroutineContext = Dispatchers.Main + job override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
// Azure服务组件 // Azure服务组件
private var speechConfig: SpeechConfig? = null private var speechConfig: SpeechConfig? = null
@ -153,6 +154,18 @@ class IntegratedSpeechTranslationService(
val confidence: Float = 0f val confidence: Float = 0f
) )
/**
* 重置协程作用域
* 用于重新初始化时清理旧的协程
*/
private fun resetCoroutineScope() {
// 取消所有正在运行的协程
job.cancel()
// 创建新的协程作用域
job = SupervisorJob()
Log.d(TAG, "协程作用域已重置")
}
/** /**
* 初始化整合服务 * 初始化整合服务
*/ */
@ -163,6 +176,14 @@ class IntegratedSpeechTranslationService(
callback: ServiceEventCallback callback: ServiceEventCallback
): Boolean = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
try { try {
Log.d(TAG, "开始初始化服务")
// 重新初始化前先清理
if (serviceState.isInitialized.get()) {
Log.d(TAG, "检测到服务已初始化,先进行清理")
dispose()
}
this@IntegratedSpeechTranslationService.eventCallback = callback this@IntegratedSpeechTranslationService.eventCallback = callback
serviceConfig?.let { this@IntegratedSpeechTranslationService.serviceConfig = it } serviceConfig?.let { this@IntegratedSpeechTranslationService.serviceConfig = it }
@ -309,6 +330,7 @@ class IntegratedSpeechTranslationService(
Log.d(TAG, "识别完成事件: ${event.result.text},serviceConfig.sourceLanguage:${serviceConfig.sourceLanguage}") Log.d(TAG, "识别完成事件: ${event.result.text},serviceConfig.sourceLanguage:${serviceConfig.sourceLanguage}")
// 触发翻译流程 // 触发翻译流程
launch { launch {
Log.d(TAG, "触发翻译流程: ${event.result.text}")
processTranslationAndSynthesis(event.result.text) processTranslationAndSynthesis(event.result.text)
} }
} }
@ -407,7 +429,9 @@ class IntegratedSpeechTranslationService(
/** /**
* 开始连续语音翻译 * 开始连续语音翻译
*/ */
fun startContinuousTranslation(): Boolean { fun startContinuousTranslation(): Boolean {
Log.d(TAG, "尝试启动连续翻译,当前状态:初始化=${serviceState.isInitialized.get()}, 识别中=${serviceState.isRecognizing.get()}")
if (!serviceState.isInitialized.get()) { if (!serviceState.isInitialized.get()) {
eventCallback?.onError("Service", "服务未初始化") eventCallback?.onError("Service", "服务未初始化")
return false return false
@ -419,15 +443,16 @@ class IntegratedSpeechTranslationService(
} }
return try { return try {
// 启动音频处理器
audioProcessor?.startRecording() audioProcessor?.startRecording()
if (serviceConfig.enableContinuousRecognition) { if (serviceConfig.enableContinuousRecognition) {
Log.d(TAG, "startContinuousTranslation") Log.d(TAG, "启动连续识别")
recognizer?.startContinuousRecognitionAsync() recognizer?.startContinuousRecognitionAsync()
serviceState.isRecognizing.set(true)
} }
Log.d(TAG, "开始连续语音翻译") Log.d(TAG, "连续语音翻译启动成功")
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "启动连续翻译失败", e) Log.e(TAG, "启动连续翻译失败", e)
@ -469,6 +494,7 @@ class IntegratedSpeechTranslationService(
* 处理翻译和合成流程 * 处理翻译和合成流程
*/ */
private suspend fun processTranslationAndSynthesis(text: String) { private suspend fun processTranslationAndSynthesis(text: String) {
Log.d(TAG, " 翻译状态:${serviceState.isTranslating.get()}")
if (serviceState.isTranslating.get()) { if (serviceState.isTranslating.get()) {
Log.w(TAG, "翻译正在进行中,跳过当前请求") Log.w(TAG, "翻译正在进行中,跳过当前请求")
return return
@ -477,7 +503,7 @@ class IntegratedSpeechTranslationService(
serviceState.isTranslating.set(true) serviceState.isTranslating.set(true)
eventCallback?.onTranslationStarted(text) eventCallback?.onTranslationStarted(text)
eventCallback?.onStateChanged("Translation", true) eventCallback?.onStateChanged("Translation", true)
Log.d(TAG, "翻译开始${text}")
try { try {
val translationResult = withTimeout(serviceConfig.translationTimeout) { val translationResult = withTimeout(serviceConfig.translationTimeout) {
translationService?.translateText( translationService?.translateText(
@ -491,7 +517,9 @@ class IntegratedSpeechTranslationService(
eventCallback?.onStateChanged("Translation", false) eventCallback?.onStateChanged("Translation", false)
when { when {
translationResult?.success == true && !translationResult.translatedText.isNullOrEmpty() -> { translationResult?.success == true && !translationResult.translatedText.isNullOrEmpty() -> {
Log.d(TAG, "翻译成功${translationResult.translatedText}")
eventCallback?.onTranslated( eventCallback?.onTranslated(
text, text,
translationResult.translatedText, translationResult.translatedText,
@ -949,126 +977,71 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) {
) )
} }
/**
* 完善的资源释放方法
*/
fun dispose() { fun dispose() {
try { try {
Log.d(TAG, "开始释放服务资源...") Log.d(TAG, "开始清理服务资源")
// 1. 设置释放标志,防止新的操作
serviceState.isInitialized.set(false)
// 2. 停止所有活动(使用runBlocking确保同步完成) // 停止连续识别
runBlocking { if (serviceState.isRecognizing.get()) {
try { stopContinuousTranslation()
withTimeout(5000) { // 5秒超时
stopContinuousTranslation()
}
} catch (e: TimeoutCancellationException) {
Log.w(TAG, "停止识别超时,强制继续释放资源")
}
}
// 3. 等待当前处理完成(带超时)
val maxWaitTime = 3000L // 3秒
val startTime = System.currentTimeMillis()
while ((serviceState.isTranslating.get() || serviceState.isSynthesizing.get() || serviceState.isRecognizing.get())
&& (System.currentTimeMillis() - startTime) < maxWaitTime) {
Thread.sleep(100)
}
// 4. 强制停止识别器
try {
recognizer?.stopContinuousRecognitionAsync()?.get(2, TimeUnit.SECONDS)
} catch (e: Exception) {
Log.w(TAG, "停止识别器超时,强制关闭")
}
// 5. 释放Azure资源(按顺序释放)
try {
recognizer?.close()
recognizer = null
Log.d(TAG, "语音识别器已释放")
} catch (e: Exception) {
Log.e(TAG, "释放识别器失败", e)
} }
try { // 添加协程清理
synthesizer?.close() resetCoroutineScope()
synthesizer = null
Log.d(TAG, "语音合成器已释放")
} catch (e: Exception) {
Log.e(TAG, "释放合成器失败", e)
}
try { // 清理识别器
audioConfig?.close() recognizer?.let {
audioConfig = null try {
Log.d(TAG, "音频配置已释放") it.stopContinuousRecognitionAsync()
} catch (e: Exception) { it.close()
Log.e(TAG, "释放音频配置失败", e) } catch (e: Exception) {
Log.w(TAG, "清理识别器失败", e)
}
} }
recognizer = null
try { // 清理合成器
speechConfig?.close() synthesizer?.let {
speechConfig = null try {
Log.d(TAG, "语音配置已释放") it.close()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "释放语音配置失败", e) Log.w(TAG, "清理合成器失败", e)
}
} }
synthesizer = null
// 6. 释放音频处理器 // 清理音频处理器
try { audioProcessor?.dispose()
audioProcessor?.dispose() audioProcessor = null
audioProcessor = null
Log.d(TAG, "音频处理器已释放")
} catch (e: Exception) {
Log.e(TAG, "释放音频处理器失败", e)
}
// 7. 释放录音文件 // 清理音频配置
try { audioConfig?.close()
recordfile1?.closeFile(true) audioConfig = null
recordfile1 = null
Log.d(TAG, "录音文件已释放")
} catch (e: Exception) {
Log.e(TAG, "释放录音文件失败", e)
}
// 8. 释放翻译服务 // 清理语音配置
try { speechConfig?.close()
translationService?.dispose() speechConfig = null
translationService = null
Log.d(TAG, "翻译服务已释放")
} catch (e: Exception) {
Log.e(TAG, "释放翻译服务失败", e)
}
// 9. 取消协程(使用cancelAndJoin确保完全停止) // 清理翻译服务
try { translationService?.dispose()
runBlocking { translationService = null
job.cancelAndJoin()
}
Log.d(TAG, "协程已取消")
} catch (e: Exception) {
Log.e(TAG, "取消协程失败", e)
}
// 10. 清理缓存和回调 // 清理录音文件
voiceCache.clear() recordfile1?.closeFile(true)
eventCallback = null recordfile1 = null
filePath = null
// 11. 重置状态 // 重置状态
serviceState.isInitialized.set(false)
serviceState.isRecognizing.set(false) serviceState.isRecognizing.set(false)
serviceState.isSynthesizing.set(false) serviceState.isSynthesizing.set(false)
serviceState.isTranslating.set(false) serviceState.isTranslating.set(false)
Log.d(TAG, "服务资源释放完成") // 清理缓存
voiceCache.clear()
Log.d(TAG, "服务资源清理完成")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "释放资源过程中发生异常", e) Log.e(TAG, "清理服务资源失败", e)
} }
} }

1
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -1035,6 +1035,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
override fun onAudioDataReceived1(data: ByteArray) { override fun onAudioDataReceived1(data: ByteArray) {
// FileLogger.d(tag, "onAudioDataReceived1: ${data.size}")
azureAstHelper.pushAudioData(data) azureAstHelper.pushAudioData(data)
// 可选:处理音频数据 // 可选:处理音频数据

9
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/SimpleAudioReceiver.kt

@ -129,7 +129,7 @@ class SimpleAudioReceiver(private val context: Context) {
// 情况2:不在录制但队列有数据 -> 从队列获取 // 情况2:不在录制但队列有数据 -> 从队列获取
else if (writeQueue.isNotEmpty()) { else if (writeQueue.isNotEmpty()) {
data = writeQueue.poll() data = writeQueue.poll()
Log.d("tag", "写入数据: ${data?.size}") //Log.d("tag", "写入数据: ${data?.size}")
bytesToWrite = data?.size ?: 0 bytesToWrite = data?.size ?: 0
} }
// 确保有有效数据再写入 // 确保有有效数据再写入
@ -139,7 +139,7 @@ class SimpleAudioReceiver(private val context: Context) {
if (bytesToWrite < data.size) data.copyOf(bytesToWrite) else data if (bytesToWrite < data.size) data.copyOf(bytesToWrite) else data
try { try {
Log.d(TAG, "写入数据大小: ${finalData.size}") // Log.d(TAG, "写入数据大小: ${finalData.size}")
if(audioDataCallback!=null){ if(audioDataCallback!=null){
audioDataCallback?.onAudio(finalData) audioDataCallback?.onAudio(finalData)
} }
@ -237,7 +237,6 @@ class SimpleAudioReceiver(private val context: Context) {
try { try {
// 首先设置通话音频模式 // 首先设置通话音频模式
setupCommunicationAudioMode() setupCommunicationAudioMode()
val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100) val supportedSampleRates = intArrayOf(16000, 8000, 11025, 22050, 44100)
val sampleRate = supportedSampleRates.firstOrNull { rate -> val sampleRate = supportedSampleRates.firstOrNull { rate ->
val bufferSize = AudioRecord.getMinBufferSize(rate, channelConfig, audioFormat) val bufferSize = AudioRecord.getMinBufferSize(rate, channelConfig, audioFormat)
@ -309,7 +308,7 @@ class SimpleAudioReceiver(private val context: Context) {
try { try {
isWriting.set(true) isWriting.set(true)
// 首先设置通话音频模式 // 首先设置通话音频模式
setupCommunicationAudioMode() //setupCommunicationAudioMode()
// 停止录音 // 停止录音
audioRecord?.startRecording() audioRecord?.startRecording()
@ -329,7 +328,7 @@ class SimpleAudioReceiver(private val context: Context) {
// 停止录音 // 停止录音
audioRecord?.stop() audioRecord?.stop()
// 恢复音频模式 // 恢复音频模式 这里会导致关闭麦克风卡住待优化
restoreCommunicationAudioMode() restoreCommunicationAudioMode()
} catch (e: Exception) { } catch (e: Exception) {

6
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -293,9 +293,9 @@ import os.log
case "pauseRecord": case "pauseRecord":
azureAsrHelper.pauseRecord() azureAsrHelper.pauseRecord()
result(true) result(true)
case "resumeRecord": // case "resumeRecord":
azureAsrHelper.resumeRecord() // azureAsrHelper.resumeRecord()
result(true) // result(true)
case "stopRecord": case "stopRecord":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],

933
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift

@ -0,0 +1,933 @@
import Foundation
import MicrosoftCognitiveServicesSpeech
import AVFoundation
import os.log
/**
* 整合的语音翻译服务
* 集成ASR语音识别、翻译服务和TTS语音合成
* 实现音频输入 -> 语音识别 -> 翻译 -> 语音合成 -> 音频输出的完整流程
*/
@objc public class IntegratedSpeechTranslationService: NSObject {
// MARK: - Constants
private static let TAG = "IntegratedSpeechService"
// 音频源配置
public enum AudioSourceType {
/** 使用设备麦克风 */
case microphone
/** 使用外部提供的音频数据 */
case external
}
// 音频配置常量
private static let SAMPLE_RATE: UInt32 = 16000
private static let CHANNELS: UInt32 = 1
private static let BITS_PER_SAMPLE: UInt32 = 16
private static let BUFFER_SIZE = 4096
// 超时配置
private static let END_SILENCE_TIMEOUT = "300"
private static let SEGMENTATION_SILENCE_TIMEOUT = "300"
private static let INITIAL_SILENCE_TIMEOUT = "200"
// MARK: - Properties
private let log = OSLog(subsystem: "com.azure.speech", category: "IntegratedSpeechService")
// Azure服务组件
private var speechConfig: SPXSpeechConfiguration?
private var recognizer: SPXSpeechRecognizer?
private var synthesizer: SPXSpeechSynthesizer?
// 翻译服务
private var translationService: TranslationServiceInterface?
// 音频处理
private var audioProcessor: SimpleAudioReceiver?
private var audioConfig: SPXAudioConfiguration?
// 录音文件处理
private var recordFile: RecordFile?
private var filePath: String?
// 配置管理
private var serviceConfig = ServiceConfiguration()
// 状态管理
private let serviceState = ServiceState()
// 事件回调
private weak var eventCallback: ServiceEventCallback?
// 语音映射缓存
private var voiceCache: [String: String] = [:]
// 处理队列
private let processingQueue = DispatchQueue(label: "com.azure.speech.processing", qos: .userInitiated)
// MARK: - Configuration Structures
/**
* 服务配置类
*/
public struct ServiceConfiguration {
var sourceLanguage: String = "zh-CN"
var targetLanguage: String = "en-US"
var currentVoice: String = "en-US-AriaNeural"
var speechRate: String = "0%"
var speechPitch: String = "0%"
var speechVolume: String = "100%"
var enableContinuousRecognition: Bool = true
var enableAutoLanguageDetection: Bool = false
var maxRetryAttempts: Int = 3
var translationTimeout: TimeInterval = 10.0
// 新增:控制是否播放合成的音频
var enableAudioPlayback: Bool = false
public init(
sourceLanguage: String = "zh-CN",
targetLanguage: String = "en-US",
currentVoice: String = "en-US-AriaNeural",
speechRate: String = "0%",
speechPitch: String = "0%",
speechVolume: String = "100%",
enableContinuousRecognition: Bool = true,
enableAutoLanguageDetection: Bool = false,
maxRetryAttempts: Int = 3,
translationTimeout: TimeInterval = 10.0,
enableAudioPlayback: Bool = false
) {
self.sourceLanguage = sourceLanguage
self.targetLanguage = targetLanguage
self.currentVoice = currentVoice
self.speechRate = speechRate
self.speechPitch = speechPitch
self.speechVolume = speechVolume
self.enableContinuousRecognition = enableContinuousRecognition
self.enableAutoLanguageDetection = enableAutoLanguageDetection
self.maxRetryAttempts = maxRetryAttempts
self.translationTimeout = translationTimeout
self.enableAudioPlayback = enableAudioPlayback
}
}
/**
* 服务状态类
*/
public class ServiceState {
private let queue = DispatchQueue(label: "com.azure.speech.state")
private var _isInitialized = false
private var _isRecognizing = false
private var _isSynthesizing = false
private var _isTranslating = false
var isInitialized: Bool {
get { queue.sync { _isInitialized } }
set { queue.sync { _isInitialized = newValue } }
}
var isRecognizing: Bool {
get { queue.sync { _isRecognizing } }
set { queue.sync { _isRecognizing = newValue } }
}
var isSynthesizing: Bool {
get { queue.sync { _isSynthesizing } }
set { queue.sync { _isSynthesizing = newValue } }
}
var isTranslating: Bool {
get { queue.sync { _isTranslating } }
set { queue.sync { _isTranslating = newValue } }
}
}
// MARK: - Protocols
/**
* 服务事件回调接口
*/
public protocol ServiceEventCallback: AnyObject {
func onServiceInitialized()
func onRecognizing(text: String, language: String, confidence: Float)
func onRecognized(text: String, language: String, confidence: Float)
func onTranslated(originalText: String, translatedText: String, targetLanguage: String)
func onTranslationStarted(text: String)
func onTranslationFailed(text: String, error: String)
func onSynthesisStarted(text: String)
func onSynthesisCompleted(text: String)
func onSynthesisFailed(text: String, error: String)
func onSynthesisProgress(text: String, progress: Float)
func onRecognitionStarted()
func onRecognitionStopped()
func onStateChanged(component: String, isActive: Bool)
func onError(component: String, error: String)
// 新增:返回合成的音频数据
func onSynthesisAudioGenerated(text: String, audioData: Data)
}
/**
* 翻译服务接口
*/
public protocol TranslationServiceInterface {
func initialize(config: [String: String]) async -> Bool
func translateText(
text: String,
sourceLanguage: String,
targetLanguage: String
) async -> TranslationResult
func dispose()
}
/**
* 翻译结果类
*/
public struct TranslationResult {
let success: Bool
let translatedText: String?
let error: String?
let confidence: Float
public init(success: Bool, translatedText: String? = nil, error: String? = nil, confidence: Float = 0.0) {
self.success = success
self.translatedText = translatedText
self.error = error
self.confidence = confidence
}
}
// MARK: - Initialization
public override init() {
super.init()
}
/**
* 初始化整合服务
*/
public func initialize(
azureConfig: AzureConfiguration,
translationConfig: TranslationConfiguration,
serviceConfig: ServiceConfiguration? = nil,
callback: ServiceEventCallback
) async -> Bool {
do {
self.eventCallback = callback
if let config = serviceConfig {
self.serviceConfig = config
}
os_log("开始初始化整合服务", log: log, type: .info)
// 初始化Azure语音服务
if !initializeAzureServices(azureConfig: azureConfig) {
await MainActor.run {
callback.onError(component: "Initialization", error: "Azure服务初始化失败")
}
return false
}
// 初始化翻译服务
if !(await initializeTranslationService(translationConfig: translationConfig)) {
await MainActor.run {
callback.onError(component: "Initialization", error: "翻译服务初始化失败")
}
return false
}
// 初始化音频处理器
initializeAudioProcessor()
// 设置语音识别器
setupSpeechRecognizer()
// 设置语音合成器
setupSpeechSynthesizer()
serviceState.isInitialized = true
os_log("整合服务初始化成功", log: log, type: .info)
await MainActor.run {
callback.onServiceInitialized()
}
recordFile = RecordFile()
return true
} catch {
os_log("初始化失败: %@", log: log, type: .error, error.localizedDescription)
await MainActor.run {
callback.onError(
component: "Initialization",
error: "初始化失败: \(error.localizedDescription)"
)
}
return false
}
}
/**
* 初始化Azure服务
*/
private func initializeAzureServices(azureConfig: AzureConfiguration) -> Bool {
do {
os_log("开始初始化Azure服务", log: log, type: .info)
speechConfig = try SPXSpeechConfiguration(subscription: azureConfig.subscriptionKey, region: azureConfig.region)
guard let config = speechConfig else {
return false
}
config.speechRecognitionLanguage = serviceConfig.sourceLanguage
config.speechSynthesisVoiceName = getVoiceForLanguage(serviceConfig.targetLanguage)
config.setSpeechSynthesisOutputFormat(.riff16Khz16BitMonoPcm)
// 优化配置
config.setPropertyTo(Self.END_SILENCE_TIMEOUT, byName: "SpeechServiceConnection_EndSilenceTimeoutMs")
config.setPropertyTo(Self.SEGMENTATION_SILENCE_TIMEOUT, byName: "Speech_SegmentationSilenceTimeoutMs")
config.setPropertyTo(Self.INITIAL_SILENCE_TIMEOUT, byName: "SpeechServiceConnection_InitialSilenceTimeoutMs")
config.setPropertyTo("INTERACTIVE", byName: "SpeechServiceConnection_RecoMode")
// 启用详细结果
config.setPropertyTo("true", byName: "SpeechServiceResponse_RequestDetailedResultTrueFalse")
// 自动语言检测
if serviceConfig.enableAutoLanguageDetection {
config.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode")
}
os_log("Azure语音服务配置完成", log: log, type: .info)
return true
} catch {
os_log("Azure服务初始化失败: %@", log: log, type: .error, error.localizedDescription)
return false
}
}
/**
* 初始化翻译服务
*/
private func initializeTranslationService(translationConfig: TranslationConfiguration) async -> Bool {
do {
translationService = VolcanoTranslationServiceImpl()
let success = await translationService?.initialize(config: translationConfig.toConfigMap()) ?? false
if success {
os_log("翻译服务初始化完成", log: log, type: .info)
}
return success
} catch {
os_log("翻译服务初始化失败: %@", log: log, type: .error, error.localizedDescription)
return false
}
}
/**
* 初始化音频处理器
*/
private func initializeAudioProcessor() {
audioProcessor = SimpleAudioReceiver(parentHelper: self as! AzureAsrHelper)
audioProcessor?.initAudioRecord()
// 检查音频配置是否已存在
if audioConfig == nil, let pushStream = audioProcessor?.pushAudioStream {
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("设置麦克风流完成", log: log, type: .info)
}
os_log("音频处理器初始化完成", log: log, type: .info)
}
/**
* 设置语音识别器
*/
private func setupSpeechRecognizer() {
guard let speechConfig = speechConfig, let audioConfig = audioConfig else {
return
}
do {
recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig, audioConfiguration: audioConfig)
// 识别中事件
recognizer?.addRecognizingEventHandler { [weak self] (_, event) in
guard let self = self else { return }
let result = event.result
guard let text = result.text, !text.isEmpty else { return }
let confidence = self.extractConfidence(from: result)
os_log("识别中事件: %@", log: self.log, type: .debug, text)
DispatchQueue.main.async {
self.eventCallback?.onRecognizing(
text: text,
language: self.serviceConfig.sourceLanguage,
confidence: confidence
)
}
}
// 识别完成事件
recognizer?.addRecognizedEventHandler { [weak self] (_, event) in
guard let self = self else { return }
let result = event.result
switch result.reason {
case .recognizedSpeech:
if let text = result.text, !text.isEmpty {
let confidence = self.extractConfidence(from: result)
DispatchQueue.main.async {
self.eventCallback?.onRecognized(
text: text,
language: self.serviceConfig.sourceLanguage,
confidence: confidence
)
}
os_log("识别完成事件: %@", log: self.log, type: .info, text)
// 触发翻译流程
Task {
await self.processTranslationAndSynthesis(text: text)
}
}
case .noMatch:
os_log("未识别到语音", log: self.log, type: .debug)
default:
os_log("识别结果: %@", log: self.log, type: .info, String(describing: result.reason))
}
}
// 会话开始事件
recognizer?.addSessionStartedEventHandler { [weak self] (recognizer, event) in
guard let self = self else { return }
os_log("会话开始事件", log: self.log, type: .info)
self.serviceState.isRecognizing = true
DispatchQueue.main.async {
self.eventCallback?.onRecognitionStarted()
self.eventCallback?.onStateChanged(component: "Recognition", isActive: true)
}
}
// 会话停止事件
recognizer?.addSessionStoppedEventHandler { [weak self] (recognizer, event) in
guard let self = self else { return }
os_log("会话停止事件", log: self.log, type: .info)
self.serviceState.isRecognizing = false
DispatchQueue.main.async {
self.eventCallback?.onRecognitionStopped()
self.eventCallback?.onStateChanged(component: "Recognition", isActive: false)
}
}
// 取消事件
recognizer?.addCanceledEventHandler { [weak self] (recognizer, event) in
guard let self = self else { return }
os_log("取消事件", log: self.log, type: .info)
self.serviceState.isRecognizing = false
let errorDetails = event.errorDetails ?? "未知错误"
DispatchQueue.main.async {
self.eventCallback?.onError(component: "Recognition", error: "识别被取消: \(errorDetails)")
self.eventCallback?.onStateChanged(component: "Recognition", isActive: false)
}
}
os_log("语音识别器设置完成", log: log, type: .info)
} catch {
os_log("语音识别器设置失败: %@", log: log, type: .error, error.localizedDescription)
}
}
/**
* 设置语音合成器
*/
private func setupSpeechSynthesizer() {
guard let speechConfig = speechConfig else {
return
}
do {
// 根据配置决定音频输出方式
let audioConfig: SPXAudioConfiguration?
if serviceConfig.enableAudioPlayback {
audioConfig = SPXAudioConfiguration()
} else {
// 不输出到扬声器,只生成音频数据
audioConfig = nil
}
synthesizer = try SPXSpeechSynthesizer(speechConfiguration: speechConfig, audioConfiguration: audioConfig)
// 合成开始事件
synthesizer?.addSynthesisStartedEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return }
os_log("合成开始事件", log: self.log, type: .info)
self.serviceState.isSynthesizing = true
DispatchQueue.main.async {
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: true)
}
}
// 合成进行中事件
synthesizer?.addSynthesizingEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return }
os_log("合成进行中事件", log: self.log, type: .debug)
// 计算进度(简化版)
let progress: Float = 0.5
DispatchQueue.main.async {
self.eventCallback?.onSynthesisProgress(text: "", progress: progress)
}
}
// 合成完成事件
synthesizer?.addSynthesisCompletedEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return }
os_log("合成完成事件", log: self.log, type: .info)
DispatchQueue.main.async {
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false)
}
}
// 合成取消事件
synthesizer?.addSynthesisCanceledEventHandler { [weak self] (synthesizer, event) in
guard let self = self else { return }
os_log("合成取消事件", log: self.log, type: .info)
self.serviceState.isSynthesizing = false
// 修复:移除可选链,因为event.result不是可选类型
let reason = event.result.reason
DispatchQueue.main.async {
self.eventCallback?.onError(component: "Synthesis", error: "语音合成取消: \(reason)")
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false)
}
}
os_log("语音合成器设置完成,播放模式: %@", log: log, type: .info, serviceConfig.enableAudioPlayback ? "启用" : "禁用")
} catch {
os_log("语音合成器设置失败: %@", log: log, type: .error, error.localizedDescription)
}
}
// MARK: - Public Methods
/**
* 开始连续语音翻译
*/
public func startContinuousTranslation() -> Bool {
os_log("尝试启动连续翻译,当前状态:初始化=%@, 识别中=%@", log: log, type: .info,
serviceState.isInitialized ? "true" : "false",
serviceState.isRecognizing ? "true" : "false")
guard serviceState.isInitialized else {
eventCallback?.onError(component: "Service", error: "服务未初始化")
return false
}
if serviceState.isRecognizing {
os_log("语音识别已在进行中", log: log, type: .info)
return true
}
do {
// 启动音频处理器
audioProcessor?.startAudioRecord( audioSourceType: .external,
audioDataCallback: nil)
if serviceConfig.enableContinuousRecognition {
os_log("启动连续识别", log: log, type: .info)
try recognizer?.startContinuousRecognition()
serviceState.isRecognizing = true
}
os_log("连续语音翻译启动成功", log: log, type: .info)
return true
} catch {
os_log("启动连续翻译失败: %@", log: log, type: .error, error.localizedDescription)
eventCallback?.onError(component: "Service", error: "启动失败: \(error.localizedDescription)")
return false
}
}
/**
* 启用录音
*/
public func enableRecord(filePath: String) {
os_log("开启录音: %@", log: log, type: .info, filePath)
recordFile?.closeFile(isSave: true)
recordFile?.creatingFiles(atPath: filePath)
}
/**
* 停止连续语音翻译
*/
public func stopContinuousTranslation() {
do {
try recognizer?.stopContinuousRecognition()
audioProcessor?.stopMicrophoneCapture()
recordFile?.closeFile(isSave: true)
// 等待当前处理完成
while serviceState.isTranslating || serviceState.isSynthesizing {
Thread.sleep(forTimeInterval: 0.1)
}
os_log("停止连续语音翻译", log: log, type: .info)
} catch {
os_log("停止翻译失败: %@", log: log, type: .error, error.localizedDescription)
eventCallback?.onError(component: "Service", error: "停止失败: \(error.localizedDescription)")
}
}
/**
* 处理翻译和合成流程
*/
private func processTranslationAndSynthesis(text: String) async {
os_log("翻译状态: %@", log: log, type: .debug, serviceState.isTranslating ? "进行中" : "空闲")
if serviceState.isTranslating {
os_log("翻译正在进行中,跳过当前请求", log: log, type: .info)
return
}
serviceState.isTranslating = true
await MainActor.run {
eventCallback?.onTranslationStarted(text: text)
eventCallback?.onStateChanged(component: "Translation", isActive: true)
}
os_log("翻译开始: %@", log: log, type: .info, text)
do {
let translationResult = try await withTimeout(self.serviceConfig.translationTimeout) {
// 修复:在闭包中显式使用self
return await self.translationService?.translateText(
text: text,
sourceLanguage: self.serviceConfig.sourceLanguage,
targetLanguage: self.serviceConfig.targetLanguage
)
}
serviceState.isTranslating = false
await MainActor.run {
eventCallback?.onStateChanged(component: "Translation", isActive: false)
}
if let result = translationResult, result.success, let translatedText = result.translatedText, !translatedText.isEmpty {
os_log("翻译成功: %@", log: log, type: .info, translatedText)
await MainActor.run {
eventCallback?.onTranslated(
originalText: text,
translatedText: translatedText,
targetLanguage: serviceConfig.targetLanguage
)
}
// 进行语音合成
if let audioData = synthesizeText(translatedText) {
await MainActor.run {
eventCallback?.onSynthesisAudioGenerated(text: translatedText, audioData: audioData)
}
}
} else {
let errorMessage = translationResult?.error ?? "翻译失败"
os_log("翻译失败: %@", log: log, type: .error, errorMessage)
await MainActor.run {
eventCallback?.onTranslationFailed(text: text, error: errorMessage)
}
}
} catch {
serviceState.isTranslating = false
await MainActor.run {
eventCallback?.onStateChanged(component: "Translation", isActive: false)
eventCallback?.onTranslationFailed(text: text, error: "翻译超时或异常: \(error.localizedDescription)")
}
os_log("翻译异常: %@", log: log, type: .error, error.localizedDescription)
}
}
/**
* 语音合成
* @param text 要合成的文本
* @return 返回合成的音频数据,如果合成失败则返回nil
*/
/**
* 合成文本为音频数据
*/
private func synthesizeText(_ text: String) -> Data? {
guard let synthesizer = synthesizer else {
os_log("语音合成器未初始化", log: log, type: .error)
return nil
}
do {
let ssml = buildSSML(text: text)
os_log("开始合成语音: %@", log: log, type: .info, text)
serviceState.isSynthesizing = true
DispatchQueue.main.async {
self.eventCallback?.onSynthesisStarted(text: text)
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: true)
}
let result = try synthesizer.speakSsml(ssml)
serviceState.isSynthesizing = false
DispatchQueue.main.async {
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false)
}
if result.reason == .synthesizingAudioCompleted {
os_log("语音合成成功", log: log, type: .info)
DispatchQueue.main.async {
self.eventCallback?.onSynthesisCompleted(text: text)
}
return result.audioData
} else {
let errorMessage = "合成失败: \(result.reason)"
os_log("%@", log: log, type: .error, errorMessage)
DispatchQueue.main.async {
self.eventCallback?.onSynthesisFailed(text: text, error: errorMessage)
}
return nil
}
} catch {
serviceState.isSynthesizing = false
let errorMessage = "合成异常: \(error.localizedDescription)"
os_log("%@", log: log, type: .error, errorMessage)
DispatchQueue.main.async {
self.eventCallback?.onSynthesisFailed(text: text, error: errorMessage)
self.eventCallback?.onStateChanged(component: "Synthesis", isActive: false)
}
return nil
}
}
/**
* 构建SSML
*/
private func buildSSML(text: String) -> String {
let voice = getVoiceForLanguage(serviceConfig.targetLanguage)
return """
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="\(serviceConfig.targetLanguage)">
<voice name="\(voice)">
<prosody rate="\(serviceConfig.speechRate)" pitch="\(serviceConfig.speechPitch)" volume="\(serviceConfig.speechVolume)">
\(text)
</prosody>
</voice>
</speak>
"""
}
/**
* 根据语言获取对应的语音
*/
private func getVoiceForLanguage(_ language: String) -> String {
if let cachedVoice = voiceCache[language] {
return cachedVoice
}
let voice: String
switch language {
case "zh-CN":
voice = "zh-CN-XiaoxiaoNeural"
case "en-US":
voice = "en-US-AriaNeural"
case "ja-JP":
voice = "ja-JP-NanamiNeural"
case "ko-KR":
voice = "ko-KR-SunHiNeural"
case "fr-FR":
voice = "fr-FR-DeniseNeural"
case "de-DE":
voice = "de-DE-KatjaNeural"
case "es-ES":
voice = "es-ES-ElviraNeural"
default:
voice = "en-US-AriaNeural"
}
voiceCache[language] = voice
return voice
}
/**
* 提取置信度
*/
private func extractConfidence(from result: SPXSpeechRecognitionResult) -> Float {
// 从结果中提取置信度,这里简化处理
return 0.9
}
/**
* 更新配置
*/
public func updateConfiguration(newConfig: ServiceConfiguration) {
serviceConfig = newConfig
// 更新语音配置
speechConfig?.speechRecognitionLanguage = newConfig.sourceLanguage
speechConfig?.speechSynthesisVoiceName = getVoiceForLanguage(newConfig.targetLanguage)
os_log("服务配置已更新", log: log, type: .info)
}
/**
* 切换语言
*/
public func switchLanguages() {
let newConfig = ServiceConfiguration(
sourceLanguage: serviceConfig.targetLanguage,
targetLanguage: serviceConfig.sourceLanguage,
currentVoice: getVoiceForLanguage(serviceConfig.sourceLanguage),
speechRate: serviceConfig.speechRate,
speechPitch: serviceConfig.speechPitch,
speechVolume: serviceConfig.speechVolume,
enableContinuousRecognition: serviceConfig.enableContinuousRecognition,
enableAutoLanguageDetection: serviceConfig.enableAutoLanguageDetection,
maxRetryAttempts: serviceConfig.maxRetryAttempts,
translationTimeout: serviceConfig.translationTimeout,
enableAudioPlayback: serviceConfig.enableAudioPlayback
)
updateConfiguration(newConfig: newConfig)
os_log("语言已切换: %@ <-> %@", log: log, type: .info, newConfig.sourceLanguage, newConfig.targetLanguage)
}
/**
* 推送外部音频数据
*/
public func pushAudioData(audioData: Data) {
audioProcessor?.saveAudioDataTo(data:audioData)
}
/**
* 获取服务状态
*/
public func getServiceStatus() -> [String: Bool] {
return [
"isInitialized": serviceState.isInitialized,
"isRecognizing": serviceState.isRecognizing,
"isTranslating": serviceState.isTranslating,
"isSynthesizing": serviceState.isSynthesizing
]
}
/**
* 释放资源
*/
public func dispose() {
os_log("开始释放服务资源", log: log, type: .info)
// 停止所有操作
stopContinuousTranslation()
// 释放Azure组件
recognizer = nil
synthesizer = nil
speechConfig = nil
audioConfig = nil
// 释放其他组件
audioProcessor?.releaseAudioResources()
audioProcessor = nil
translationService?.dispose()
translationService = nil
recordFile?.closeFile(isSave: false)
recordFile = nil
// 清理缓存
voiceCache.removeAll()
// 重置状态
serviceState.isInitialized = false
serviceState.isRecognizing = false
serviceState.isTranslating = false
serviceState.isSynthesizing = false
// 清理回调
eventCallback = nil
os_log("服务资源释放完成", log: log, type: .info)
}
}
// MARK: - Helper Extensions
/**
* 超时错误
*/
struct TimeoutError: Error {
let message: String
}
/**
* 带超时的异步操作
*/
func withTimeout<T>(_ timeout: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
return try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw TimeoutError(message: "操作超时")
}
guard let result = try await group.next() else {
throw TimeoutError(message: "操作被取消")
}
group.cancelAll()
return result
}
}

510
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/VolcanoTranslationServiceImpl.swift

@ -0,0 +1,510 @@
import Foundation
import CryptoKit
import os.log
/**
* 火山翻译服务实现
*/
public class VolcanoTranslationServiceImpl: IntegratedSpeechTranslationService.TranslationServiceInterface {
// MARK: - Constants
private let tag = "VolcanoTranslationService"
private let log = OSLog(subsystem: "com.azure.speech", category: "VolcanoTranslationService")
private let baseURL = "https://translate.volcengineapi.com"
private let endpoint = "/"
private let service = "translate"
private let version = "2020-06-01"
private let action = "TranslateText"
private let algorithm = "HMAC-SHA256"
// MARK: - Properties
private var isInitialized = false
private var accessKey = ""
private var secretKey = ""
private var region = "cn-north-1"
private var maxRetryAttempts = 3
private var timeout: TimeInterval = 10.0
// HTTP客户端
private lazy var urlSession: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10.0
config.timeoutIntervalForResource = 30.0
return URLSession(configuration: config)
}()
// 语言代码映射
private let languageCodeMap: [String: String] = [
"zh-CN": "zh",
"zh-TW": "zh-Hant",
"en-US": "en",
"en-GB": "en",
"ja-JP": "ja",
"ko-KR": "ko",
"fr-FR": "fr",
"de-DE": "de",
"es-ES": "es",
"ru-RU": "ru",
"ar-SA": "ar",
"pt-BR": "pt",
"it-IT": "it",
"th-TH": "th",
"vi-VN": "vi",
"hi-IN": "hi"
]
// MARK: - TranslationServiceInterface Implementation
/**
* 初始化翻译服务
*/
public func initialize(config: [String: String]) async -> Bool {
guard let accessKey = config["accessKey"],
let secretKey = config["secretKey"] else {
os_log("翻译服务配置缺失", log: log, type: .error)
return false
}
self.accessKey = accessKey
self.secretKey = secretKey
self.region = config["region"] ?? "cn-north-1"
if let maxRetryStr = config["maxRetryAttempts"], let maxRetry = Int(maxRetryStr) {
self.maxRetryAttempts = maxRetry
}
if let timeoutStr = config["timeout"], let timeoutValue = Double(timeoutStr) {
self.timeout = timeoutValue
}
isInitialized = true
os_log("火山翻译服务初始化成功", log: log, type: .info)
return true
}
/**
* 翻译文本
*/
public func translateText(
text: String,
sourceLanguage: String,
targetLanguage: String
) async -> IntegratedSpeechTranslationService.TranslationResult {
guard isInitialized else {
return IntegratedSpeechTranslationService.TranslationResult(
success: false,
error: "翻译服务未初始化"
)
}
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return IntegratedSpeechTranslationService.TranslationResult(
success: false,
error: "翻译文本为空"
)
}
os_log("开始翻译: %@ -> %@, 文本: %@", log: log, type: .info, sourceLanguage, targetLanguage, text)
return await performTranslationWithRetry(
text: text,
sourceLanguage: sourceLanguage,
targetLanguage: targetLanguage
)
}
/**
* 释放资源
*/
public func dispose() {
isInitialized = false
urlSession.invalidateAndCancel()
os_log("火山翻译服务已释放", log: log, type: .info)
}
// MARK: - Private Methods
/**
* 带重试的翻译执行
*/
private func performTranslationWithRetry(
text: String,
sourceLanguage: String,
targetLanguage: String
) async -> IntegratedSpeechTranslationService.TranslationResult {
var lastError: String = "未知错误"
for attempt in 1...maxRetryAttempts {
os_log("翻译尝试 %d/%d", log: log, type: .debug, attempt, maxRetryAttempts)
let result = await performTranslation(
text: text,
sourceLanguage: sourceLanguage,
targetLanguage: targetLanguage
)
if result.success {
os_log("翻译成功,尝试次数: %d", log: log, type: .info, attempt)
return result
}
lastError = result.error ?? "翻译失败"
os_log("翻译尝试 %d 失败: %@", log: log, type: .default, attempt, lastError)
// 最后一次尝试不需要等待
if attempt < maxRetryAttempts {
let delay = TimeInterval(attempt) // 递增延迟
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
os_log("翻译最终失败,已尝试 %d 次", log: log, type: .error, maxRetryAttempts)
return IntegratedSpeechTranslationService.TranslationResult(
success: false,
error: "翻译失败,已重试 \(maxRetryAttempts) 次。最后错误: \(lastError)"
)
}
/**
* 执行翻译
*/
private func performTranslation(
text: String,
sourceLanguage: String,
targetLanguage: String
) async -> IntegratedSpeechTranslationService.TranslationResult {
do {
// 转换语言代码
let sourceCode = getLanguageCode(languageCode: sourceLanguage)
let targetCode = getLanguageCode(languageCode: targetLanguage)
// 构建请求体
let requestBody: [String: Any] = [
"TargetLanguage": targetCode,
"TextList": [text]
]
// 如果源语言不是自动检测,则添加源语言
var finalRequestBody = requestBody
if sourceCode != "auto" {
finalRequestBody["SourceLanguage"] = sourceCode
}
let jsonData = try JSONSerialization.data(withJSONObject: finalRequestBody)
// 构建查询参数
let queryParams: [String: String] = [
"Action": action,
"Version": version
]
// 生成签名
let headers = try generateSignature(
method: "POST",
requestBody: finalRequestBody,
queryParams: queryParams
)
// 构建URL
var urlComponents = URLComponents(string: baseURL + endpoint)!
urlComponents.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
guard let url = urlComponents.url else {
throw TranslationError.invalidURL
}
// 创建请求
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = jsonData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// 添加签名头
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
os_log("发送翻译请求到: %@", log: log, type: .debug, url.absoluteString)
// 发送请求
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TranslationError.invalidResponse
}
os_log("翻译响应状态码: %d", log: log, type: .debug, httpResponse.statusCode)
if httpResponse.statusCode == 200 {
// 解析响应
guard let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw TranslationError.invalidJSON
}
if let translatedText = extractTranslation(jsonResponse: jsonObject) {
os_log("翻译成功: %@", log: log, type: .info, translatedText)
return IntegratedSpeechTranslationService.TranslationResult(
success: true,
translatedText: translatedText,
confidence: 0.9
)
} else if let error = extractError(jsonResponse: jsonObject) {
os_log("翻译API返回错误: %@", log: log, type: .error, error)
return IntegratedSpeechTranslationService.TranslationResult(
success: false,
error: error
)
} else {
throw TranslationError.noTranslationFound
}
} else {
let errorMessage = "HTTP错误: \(httpResponse.statusCode)"
if let errorData = String(data: data, encoding: .utf8) {
os_log("HTTP错误响应: %@", log: log, type: .error, errorData)
}
return IntegratedSpeechTranslationService.TranslationResult(
success: false,
error: errorMessage
)
}
} catch {
os_log("翻译请求异常: %@", log: log, type: .error, error.localizedDescription)
return IntegratedSpeechTranslationService.TranslationResult(
success: false,
error: "翻译请求异常: \(error.localizedDescription)"
)
}
}
/**
* 获取语言代码
*/
private func getLanguageCode(languageCode: String) -> String {
return languageCodeMap[languageCode] ?? languageCode
}
/**
* 提取翻译结果
*/
private func extractTranslation(jsonResponse: [String: Any]) -> String? {
guard let responseMetadata = jsonResponse["ResponseMetadata"] as? [String: Any],
let error = responseMetadata["Error"] as? [String: Any] else {
// 没有错误,尝试提取翻译结果
if let translationList = jsonResponse["TranslationList"] as? [[String: Any]],
let firstTranslation = translationList.first,
let translation = firstTranslation["Translation"] as? String {
return translation
}
return nil
}
// 有错误信息
return nil
}
/**
* 提取错误信息
*/
private func extractError(jsonResponse: [String: Any]) -> String? {
if let responseMetadata = jsonResponse["ResponseMetadata"] as? [String: Any],
let error = responseMetadata["Error"] as? [String: Any] {
let code = error["Code"] as? String ?? "UnknownError"
let message = error["Message"] as? String ?? "未知错误"
return "\(code): \(message)"
}
return nil
}
/**
* 生成签名
*/
private func generateSignature(
method: String,
requestBody: [String: Any],
queryParams: [String: String]
) throws -> [String: String] {
let now = Date()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
let timestamp = dateFormatter.string(from: now)
let shortDateFormatter = DateFormatter()
shortDateFormatter.dateFormat = "yyyyMMdd"
shortDateFormatter.timeZone = TimeZone(abbreviation: "UTC")
let shortDate = shortDateFormatter.string(from: now)
// 构建规范请求
let httpRequestMethod = method
let canonicalURI = endpoint
// 构建规范查询字符串
let sortedQueryParams = queryParams.sorted { $0.key < $1.key }
let canonicalQueryString = sortedQueryParams
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
.joined(separator: "&")
// 构建规范头部
let host = URL(string: baseURL)!.host!
let canonicalHeaders = "host:\(host)\nx-date:\(timestamp)\n"
let signedHeaders = "host;x-date"
// 计算请求体哈希
let requestBodyData = try JSONSerialization.data(withJSONObject: requestBody)
let hashedRequestPayload = sha256(data: requestBodyData)
// 构建规范请求
let canonicalRequest = "\(httpRequestMethod)\n\(canonicalURI)\n\(canonicalQueryString)\n\(canonicalHeaders)\n\(signedHeaders)\n\(hashedRequestPayload)"
// 创建待签字符串
let credentialScope = "\(shortDate)/\(region)/\(service)/request"
let stringToSign = "\(algorithm)\n\(timestamp)\n\(credentialScope)\n\(sha256(string: canonicalRequest))"
// 计算签名
let kSecret = Data(secretKey.utf8)
let kDate = try hmacSha256(key: kSecret, data: shortDate)
let kRegion = try hmacSha256(key: kDate, data: region)
let kService = try hmacSha256(key: kRegion, data: service)
let kSigning = try hmacSha256(key: kService, data: "request")
let signature = try hmacSha256(key: kSigning, data: stringToSign)
let signatureHex = signature.map { String(format: "%02x", $0) }.joined()
// 构建授权头
let authorization = "\(algorithm) Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaders), Signature=\(signatureHex)"
return [
"Content-Type": "application/json",
"X-Date": timestamp,
"Authorization": authorization,
"Host": host
]
}
/**
* SHA256哈希函数(字符串输入)
*/
private func sha256(string: String) -> String {
let data = Data(string.utf8)
return sha256(data: data)
}
/**
* SHA256哈希函数(数据输入)
*/
private func sha256(data: Data) -> String {
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
/**
* HMAC-SHA256函数
*/
private func hmacSha256(key: Data, data: String) throws -> Data {
let dataToSign = Data(data.utf8)
let symmetricKey = SymmetricKey(data: key)
let signature = HMAC<SHA256>.authenticationCode(for: dataToSign, using: symmetricKey)
return Data(signature)
}
}
/**
* 翻译错误枚举
*/
enum TranslationError: Error {
case invalidURL
case invalidResponse
case invalidJSON
case noTranslationFound
case networkError(String)
case authenticationError
case rateLimitExceeded
case serviceUnavailable
var localizedDescription: String {
switch self {
case .invalidURL:
return "无效的URL"
case .invalidResponse:
return "无效的响应"
case .invalidJSON:
return "无效的JSON格式"
case .noTranslationFound:
return "未找到翻译结果"
case .networkError(let message):
return "网络错误: \(message)"
case .authenticationError:
return "认证失败"
case .rateLimitExceeded:
return "请求频率超限"
case .serviceUnavailable:
return "服务不可用"
}
}
}
/**
* 配置扩展
*/
extension TranslationConfiguration {
func toConfigMap() -> [String: String] {
var config: [String: String] = [
"accessKey": accessKey,
"secretKey": secretKey,
"region": region
]
if let maxRetry = maxRetryAttempts {
config["maxRetryAttempts"] = String(maxRetry)
}
if let timeoutValue = timeout {
config["timeout"] = String(timeoutValue)
}
return config
}
}
/**
* 翻译配置类
*/
public struct TranslationConfiguration {
let accessKey: String
let secretKey: String
let region: String
let maxRetryAttempts: Int?
let timeout: TimeInterval?
public init(
accessKey: String,
secretKey: String,
region: String = "cn-north-1",
maxRetryAttempts: Int? = nil,
timeout: TimeInterval? = nil
) {
self.accessKey = accessKey
self.secretKey = secretKey
self.region = region
self.maxRetryAttempts = maxRetryAttempts
self.timeout = timeout
}
}
/**
* Azure配置类
*/
public struct AzureConfiguration {
let subscriptionKey: String
let region: String
public init(subscriptionKey: String, region: String) {
self.subscriptionKey = subscriptionKey
self.region = region
}
}

4
local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -138,11 +138,11 @@ public class SimpleAudioReceiver: NSObject {
while self.isRunning { while self.isRunning {
if !self._isWriting { if !self._isWriting {
print("startWriteThread 111") // print("startWriteThread 111")
usleep(10_000) usleep(10_000)
continue continue
} }
print("startWriteThread 222") // print("startWriteThread 222")
guard let dataToWrite = self.writeQueue.take() else { continue } guard let dataToWrite = self.writeQueue.take() else { continue }
do { do {

23
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -153,8 +153,8 @@ object BleService {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val timeDiff = currentTime - lastStatisticsTime val timeDiff = currentTime - lastStatisticsTime
if (timeDiff >= 1000) { // 每秒统计一次 if (timeDiff >= 1000) { // 每秒统计一次
Log.i(TAG, "每秒接收音频数据: $bytesReceivedInCurrentSecond 字节") // Log.i(TAG, "每秒接收音频数据: $bytesReceivedInCurrentSecond 字节")
Log.i(TAG, "每秒发送音频数据: $bytesSentInCurrentSecond 字节") //Log.i(TAG, "每秒发送音频数据: $bytesSentInCurrentSecond 字节")
bytesReceivedInCurrentSecond = 0 bytesReceivedInCurrentSecond = 0
bytesSentInCurrentSecond = 0 bytesSentInCurrentSecond = 0
lastStatisticsTime = currentTime lastStatisticsTime = currentTime
@ -1061,6 +1061,7 @@ startBytesStatistics()
recordfile!!.closeFile() recordfile!!.closeFile()
recordfile1!!.closeFile() recordfile1!!.closeFile()
} }
Log.d(TAG, "收到设备响应,channelMode: $channelMode")
// 声道模式描述 // 声道模式描述
val channelDesc = when (channelMode) { val channelDesc = when (channelMode) {
BleConst.AUDIO_CHANNEL_LEFT -> "左声道" BleConst.AUDIO_CHANNEL_LEFT -> "左声道"
@ -1073,8 +1074,23 @@ startBytesStatistics()
resultMap["channelMode"] = channelMode resultMap["channelMode"] = channelMode
resultMap["statusDesc"] = statusDesc resultMap["statusDesc"] = statusDesc
resultMap["channelDesc"] = channelDesc resultMap["channelDesc"] = channelDesc
Log.i(TAG, "编解码控制响应: $statusDesc, $channelDesc") Log.i(TAG, "编解码控制响应: $statusDesc, $channelDesc")
}else if (responseData.isNotEmpty() && responseData.size == 1) {
val codecStatus = responseData[0].toInt() and 0xFF
if(codecStatus==0x00){
Log.d(
"BleService",
"收到设备响应:失败,codecStatus: $codecStatus"
)
val resultMap = mutableMapOf<String, Any>(
"success" to true,
"command" to "Error",
"rawData" to responseData
)
}
} }
notifyDeviceInfoReceived(cmdType, resultMap) notifyDeviceInfoReceived(cmdType, resultMap)
@ -2082,6 +2098,7 @@ class RecordingFile(private val context: Context) {
// 追加音频数据到文件 // 追加音频数据到文件
fos = FileOutputStream(currentAudioFile, true) fos = FileOutputStream(currentAudioFile, true)
startWriteThread() // 新增:启动写入线程 startWriteThread() // 新增:启动写入线程
Log.d("TAG", "创建文件: ${currentAudioFile?.absolutePath}")
} }
// 新增:启动写入线程 // 新增:启动写入线程

305
local_plugins/ble_service/ios/Classes/BleAccessorySetup.swift

@ -1,305 +0,0 @@
import Foundation
import CoreBluetooth
import os.log
import AVFoundation
import AccessorySetupKit
import UIKit
class BleAccessorySetup: NSObject {
// MARK: - 属性
static let shared = BleAccessorySetup()
private var bleService: BleService?
private var session: ASAccessorySession?
private var isSessionActivated = false
private var isInitializing = false
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup")
// 杰理蓝牙设备的公司标识符
private let deviceCompanyIdentifier: UInt16 = 0xEFAB
private let deviceDisplayName = "语音助理耳机"
// 用于存储配对设备UUID的UserDefaults键
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid"
typealias DeviceDiscoveredCallback = (UUID) -> Void
private var onDeviceDiscovered: DeviceDiscoveredCallback?
// MARK: - 初始化
private override init() { super.init() }
// MARK: - 公共方法
func setBleService(_ service: Any) {
self.bleService = service as? BleService
}
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) {
self.onDeviceDiscovered = callback
}
// 处理发现的设备并连接
func connectToAccessory(uuid: UUID) {
guard let service = bleService else {
return
}
service.connectToAccessory(uuid: uuid)
}
func addToReconnectQueue(uuid: UUID) {
guard let service = bleService else {
return
}
service.addToReconnectQueue(uuid: uuid)
}
// 从视图控制器显示设备选择器 - 主要入口点
func showDevicePickerFromViewController(_ viewController: UIViewController) {
initializeSession()
}
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权)
func resetBluetoothState() {
if let service = bleService {
service.disconnect()
}
}
// MARK: - 会话管理
func initializeSession() {
if isInitializing {
os_log("会话正在初始化中,跳过", log: logger, type: .info)
return
}
// 如果会话已激活,检查是否有配对设备
if session != nil && isSessionActivated {
os_log("会话已激活,检查配对设备", log: logger, type: .info)
// 会话已激活,检查是否有配对设备
if let accessories = session?.accessories, !accessories.isEmpty {
os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count)
// 有配对设备,尝试连接
if let service = bleService {
let _ = service.getCentralManager()
for accessory in accessories {
if let uuid = accessory.bluetoothIdentifier {
onDeviceDiscovered?(uuid)
connectToAccessory(uuid: uuid)
}
}
}
} else {
os_log("没有已配对设备,显示配对框", log: logger, type: .info)
// 没有配对设备,直接显示配对框
showDevicePicker()
}
return
}
os_log("开始初始化新会话", log: logger, type: .info)
isInitializing = true
do {
session = try ASAccessorySession()
session?.activate(on: .main) { [weak self] event in
guard let self = self else { return }
self.handleSessionEvent(event)
}
} catch {
os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription)
handleSessionInitError(error)
}
}
func resetSession() {
session?.invalidate()
session = nil
isSessionActivated = false
isInitializing = false
}
// MARK: - 设备选择器显示
private func showDevicePicker() {
guard let session = session, isSessionActivated else {
os_log("无法显示配对框:会话未激活", log: logger, type: .error)
return
}
os_log("准备显示设备配对框", log: logger, type: .info)
let descriptor = ASDiscoveryDescriptor()
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier)
let deviceImage = UIImage(systemName: "headphones") ?? UIImage()
let displayItem = ASPickerDisplayItem(
name: deviceDisplayName,
productImage: deviceImage,
descriptor: descriptor
)
DispatchQueue.main.async {
os_log("正在显示设备配对框", log: self.logger, type: .info)
session.showPicker(for: [displayItem]) { [weak self] error in
guard let self = self else { return }
if let error = error {
let nsError = error as NSError
os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription)
} else {
os_log("设备选择器完成", log: self.logger, type: .info)
}
}
}
}
// MARK: - 私有辅助方法
private func handleSessionEvent(_ event: ASAccessoryEvent) {
switch event.eventType {
case .activated:
os_log("会话已激活", log: logger, type: .info)
handleSessionActivated()
case .accessoryAdded:
os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown")
handleAccessoryAdded(event.accessory)
case .accessoryRemoved:
os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown")
handleAccessoryRemoved(event.accessory)
case .accessoryChanged:
os_log("设备状态已改变", log: logger, type: .info)
break
case .pickerDidPresent:
os_log("配对框已显示", log: logger, type: .info)
break
case .pickerDidDismiss:
os_log("配对框已关闭", log: logger, type: .info)
break
case .pickerSetupFailed:
os_log("选择器设置失败: %{public}@", log: logger, type: .error,
event.error?.localizedDescription ?? "未知错误")
default:
os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue)
break
}
}
private func handleSessionActivated() {
isSessionActivated = true
isInitializing = false
if let accessories = session?.accessories, !accessories.isEmpty {
if let service = bleService {
let _ = service.getCentralManager()
for accessory in accessories {
if let uuid = accessory.bluetoothIdentifier {
onDeviceDiscovered?(uuid)
connectToAccessory(uuid: uuid)
}
}
}
} else {
showDevicePicker()
}
}
private func handleAccessoryAdded(_ accessory: ASAccessory?) {
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
return
}
// 保存配对设备的UUID
savePairedMacAddress(uuid)
onDeviceDiscovered?(uuid)
connectToAccessory(uuid: uuid)
}
private func handleAccessoryRemoved(_ accessory: ASAccessory?) {
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
return
}
if let service = bleService {
service.disconnect()
}
}
private func handleSessionInitError(_ error: Error) {
isInitializing = false
session = nil
}
// MARK: - 蓝牙状态检查
func isBluetoothAvailable() -> Bool {
do {
let audioSession = AVAudioSession.sharedInstance()
let outputs = audioSession.currentRoute.outputs
for output in outputs {
if output.portType == .bluetoothA2DP ||
output.portType == .bluetoothHFP ||
output.portType == .bluetoothLE {
return true
}
}
} catch {
os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription)
}
return true
}
// MARK: - 清除配对关系
func clearAssociations() {
resetBluetoothState()
// 清除保存的配对设备UUID
clearPairedMacAddress()
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
resetSession()
initializeSession()
}
#endif
}
// MARK: - 配对设备UUID管理
/// 获取已配对设备的UUID字符串
/// 返回存储的配对设备UUID,如果没有则返回nil
func getPairedMacAddress() -> String? {
let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey)
if let uuidString = uuidString, !uuidString.isEmpty {
os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString)
return uuidString
}
os_log("未找到已配对设备UUID", log: logger, type: .info)
return nil
}
/// 保存配对设备的UUID
/// - Parameter uuid: 要保存的设备UUID
func savePairedMacAddress(_ uuid: UUID) {
let uuidString = uuid.uuidString
UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey)
UserDefaults.standard.synchronize()
os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString)
}
/// 清除已保存的配对设备UUID
private func clearPairedMacAddress() {
UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey)
UserDefaults.standard.synchronize()
os_log("已清除配对设备UUID", log: logger, type: .info)
}
}

93
local_plugins/ble_service/ios/Classes/BleConst.swift

@ -1,93 +0,0 @@
import Foundation
import CoreBluetooth
/**
* BLE服务常量
*/
class BleConst {
// ================== 协议相关UUID ==================
/** 主服务UUID - 文档中定义为0000ABC0-0000-1111-2222-123456789ABC */
static let PRIMARY_SERVICE_UUID = CBUUID(string: "0000abc0-0000-1111-2222-123456789abc")
/** 音频服务UUID - 文档中定义为00001801-0000-1000-8000-00805f9b34fb */
static let AUDIO_SERVICE_UUID = CBUUID(string: "0000ae00-0000-1000-8000-00805f9b34fb")
/** 接收音频特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */
static let RECEIVE_AUDIO_CHAR_UUID = CBUUID(string: "0000ae02-0000-1000-8000-00805f9b34fb")
/** 写入特征UUID - 文档中定义为0000ABC1-0000-1111-2222-123456789ABC */
static let WRITE_CHAR_UUID = CBUUID(string: "0000abc1-0000-1111-2222-123456789abc")
/** 通知特征UUID - 文档中定义为0000ABC2-0000-1111-2222-123456789ABC */
static let NOTIFY_CHAR_UUID = CBUUID(string: "0000abc2-0000-1111-2222-123456789abc")
/** 客户端特征配置描述符UUID */
static let CLIENT_CHAR_CONFIG_UUID = CBUUID(string: "00002902-0000-1000-8000-00805f9b34fb")
/** BLE扫描Action */
static let ACTION_SCAN_RESULT = "com.yunqiinnovation.ble_service.ACTION_BLE_SCAN"
// ================== 连接状态 ==================
static let STATE_DISCONNECTED = 0
static let STATE_CONNECTING = 1
static let STATE_CONNECTED = 2
static let STATE_DISCONNECTING = 3
static let STATE_ERROR = 4
// ================== 广播厂商信息 ==================
/** 厂商ID - 文档中定义为0xABEF */
static let MANUFACTURER_ID: UInt16 = 0xABEF
/** 客户ID - 文档中定义为0xA001 */
static let CUSTOMER_ID: UInt16 = 0xA001
/** 产品ID - 文档中定义为0xA001 */
static let PRODUCT_ID: UInt16 = 0xA001
/** 蓝牙状态-未连接 */
static let BT_STATE_DISCONNECTED: UInt8 = 0x00
/** 蓝牙状态-已连接 */
static let BT_STATE_CONNECTED: UInt8 = 0x0C
// ================== 协议格式 ==================
/** APP请求帧头 */
static let APP_REQUEST_HEADER: UInt8 = 0xAA
/** 设备响应帧头 */
static let DEVICE_RESPONSE_HEADER: UInt8 = 0xBB
/** 设备主动上报帧头 */
static let DEVICE_NOTIFY_HEADER: UInt8 = 0xCC
// ================== 协议指令 (CMD) ==================
/** 获取耳机版本号 */
static let CMD_GET_VERSION: UInt8 = 0x01
/** 获取耳机产品ID */
static let CMD_GET_PRODUCT_ID: UInt8 = 0x02
/** 获取耳机颜色ID */
static let CMD_GET_COLOR_ID: UInt8 = 0x03
/** 获取耳机电量ID */
static let CMD_GET_BATTERY_INFO: UInt8 = 0x04
/** APP控制编解码指令 */
static let CMD_CONTROL_CODEC: UInt8 = 0x05
/** 音量增大 */
static let CMD_VOLUME_UP: UInt8 = 0x06
/** 音量减小 */
static let CMD_VOLUME_DOWN: UInt8 = 0x07
/** A2DP播放 */
static let CMD_PLAY_A2DP: UInt8 = 0x08
/** 唤醒指令 */
static let CMD_WAKE_UP: UInt8 = 0x11
// ================== 编解码控制值 ==================
/** 关闭编解码指令 */
static let CODEC_CONTROL_CLOSE: UInt8 = 0x00
/** 音乐或者通话远端声音 */
static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1
/** mic和dac(音乐或者通话远端)声音 */
static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2
/** 打开编码指令 */
static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1
/** 左声道 */
static let AUDIO_CHANNEL_LEFT: UInt8 = 0x01
/** 右声道 */
static let AUDIO_CHANNEL_RIGHT: UInt8 = 0x02
/** 立体声 */
static let AUDIO_CHANNEL_STEREO: UInt8 = 0x03
}

1266
local_plugins/ble_service/ios/Classes/BleService.swift

File diff suppressed because it is too large

4
local_plugins/ble_service/ios/Classes/BleServicePlugin.h

@ -1,4 +0,0 @@
#import <Flutter/Flutter.h>
@interface BleServicePlugin : NSObject<FlutterPlugin>
@end

12
local_plugins/ble_service/ios/Classes/BleServicePlugin.m

@ -1,12 +0,0 @@
#import "BleServicePlugin.h"
#if __has_include(<ble_service/ble_service-Swift.h>)
#import <ble_service/ble_service-Swift.h>
#else
#import "ble_service-Swift.h"
#endif
@implementation BleServicePlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
[SwiftBleServicePlugin registerWithRegistrar:registrar];
}
@end

374
local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift

@ -1,374 +0,0 @@
import Flutter
import UIKit
import CoreBluetooth
import os
@available(iOS 13.0, *)
public class SwiftBleServicePlugin: NSObject, FlutterPlugin {
// MARK: - 属性
private var methodChannel: FlutterMethodChannel
internal var dataEventSink: FlutterEventSink?
internal var statusEventSink: FlutterEventSink?
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleServicePlugin")
// 标记是否已初始化所有服务
private var servicesInitialized = false
// MARK: - 初始化方法
init(_ channel: FlutterMethodChannel) {
self.methodChannel = channel
super.init()
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
// iOS 18环境下,只设置回调但不主动初始化会话
let bleAccessorySetup = BleAccessorySetup.shared
// 在accessoryAdded回调中延迟设置BleService
bleAccessorySetup.setDeviceDiscoveredCallback { [weak self] uuid in
// 确保服务已初始化
self?.ensureServicesInitialized()
// 然后处理发现的设备
self?.handleDiscoveredDevice(uuid: uuid)
}
// 注释掉自动初始化,改为等待用户主动调用startDeviceAssociation
// bleAccessorySetup.initializeSession()
os_log("iOS 18环境:已设置设备发现回调,等待用户主动调用关联", log: logger, type: .info)
} else {
// 低于iOS 18,直接初始化所有服务
initializeAllServices()
}
#else
// 非AccessorySetupKit环境,直接初始化所有服务
initializeAllServices()
#endif
}
// 确保所有服务都已初始化
private func ensureServicesInitialized() {
if !servicesInitialized {
// 设置BLE服务回调
BleService.shared.addCallback(self)
// 设置BleService到AccessorySetup
BleAccessorySetup.shared.setBleService(BleService.shared)
servicesInitialized = true
}
}
// 初始化所有服务(iOS 17及以下版本的标准行为)
private func initializeAllServices() {
// 设置BLE服务回调
BleService.shared.addCallback(self)
// 初始化AccessorySetup,但不自动开始会话
BleAccessorySetup.shared.setBleService(BleService.shared)
BleAccessorySetup.shared.setDeviceDiscoveredCallback { [weak self] uuid in
self?.handleDiscoveredDevice(uuid: uuid)
}
// 移除自动初始化,统一由startDeviceAssociation方法触发
// BleAccessorySetup.shared.initializeSession()
os_log("非iOS 18环境:已设置BLE服务但不自动初始化会话", log: logger, type: .info)
servicesInitialized = true
}
// MARK: - FlutterPlugin Protocol
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.ble_service", binaryMessenger: registrar.messenger())
let instance = SwiftBleServicePlugin(channel)
registrar.addMethodCallDelegate(instance, channel: channel)
// 注册事件通道 - 为每个通道创建独立的handler
let dataEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/data_events", binaryMessenger: registrar.messenger())
let statusEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/status_events", binaryMessenger: registrar.messenger())
// 创建独立的StreamHandler
let dataHandler = BleDataStreamHandler(instance)
let statusHandler = BleStatusStreamHandler(instance)
dataEventChannel.setStreamHandler(dataHandler)
statusEventChannel.setStreamHandler(statusHandler)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
// 检查是否需要初始化服务
checkAndInitializeServices()
switch call.method {
case "isConnected":
result(BleService.shared.isConnected())
case "getBatteryLevel":
// 获取电池信息,通过回调返回结果
if BleService.shared.getBatteryInfo() {
result(0) // 通过回调返回实际值
} else {
result(FlutterError(code: "UNAVAILABLE",
message: "无法获取电池信息",
details: nil))
}
case "isCompanionSupported":
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
result(true)
} else {
result(false)
}
#else
result(false)
#endif
case "getPairedMacAddress":
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
let pairedUUID = BleAccessorySetup.shared.getPairedMacAddress()
result(pairedUUID)
} else {
result(nil)
}
#else
result(nil)
#endif
case "isBleConnected":
result(BleService.shared.isConnected())
case "registerBackgroundScan", "unregisterBackgroundScan":
// iOS上禁用,直接返回成功以避免Flutter端报错
result(true)
case "getDeviceVersion":
result(BleService.shared.getDeviceVersion())
case "getProductId":
result(BleService.shared.getProductId())
case "getBatteryInfo":
result(BleService.shared.getBatteryInfo())
case "openEncoder":
result(BleService.shared.openEncoder())
case "openDecoder":
result(BleService.shared.openDecoder())
case "openA2DPDecoder":
result(BleService.shared.openA2DPDecoder())
case "closeCodec":
result(BleService.shared.closeCodec())
case "playA2DP":
result(BleService.shared.playA2DP())
// iOS 18+ AccessorySetupKit相关方法
case "clearAssociations":
// 清除设备配对关系
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
os_log("清除设备配对关系", log: logger, type: .info)
BleAccessorySetup.shared.clearAssociations()
result(true)
} else {
result(true)
}
#else
result(true)
#endif
case "startDeviceAssociation":
// 用户主动触发设备关联流程
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
os_log("用户主动触发设备关联流程", log: logger, type: .info)
// 确保服务已初始化
ensureServicesInitialized()
// 重新初始化会话
BleAccessorySetup.shared.initializeSession()
// 返回成功,实际配对结果会通过回调通知
result(true)
} else {
os_log("当前iOS版本不支持AccessorySetupKit", log: logger, type: .info)
result(false)
}
#else
os_log("当前环境不支持AccessorySetupKit", log: logger, type: .info)
result(false)
#endif
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - 私有方法
// 检查并按需初始化服务
private func checkAndInitializeServices() {
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
ensureServicesInitialized()
}
#endif
}
// AccessorySetupKit发现设备的处理
private func handleDiscoveredDevice(uuid: UUID) {
// BleAccessorySetup内部已进行连接,此处不再重复调用
// os_log("收到设备发现回调: %{public}@", log: logger, type: .info, uuid.uuidString)
// 可以在这里添加设备记录或其他操作
}
private func sendEvent<T>(_ eventSink: FlutterEventSink?, data: T, errorPrefix: String) {
if let eventSink = eventSink {
DispatchQueue.main.async {
eventSink(data)
}
}
}
// 获取顶层视图控制器的辅助方法
private func getTopViewController(_ viewController: UIViewController) -> UIViewController {
if let presented = viewController.presentedViewController {
return getTopViewController(presented)
}
if let tabBarController = viewController as? UITabBarController,
let selected = tabBarController.selectedViewController {
return getTopViewController(selected)
}
if let navigationController = viewController as? UINavigationController,
let visibleViewController = navigationController.visibleViewController {
return getTopViewController(visibleViewController)
}
return viewController
}
}
// MARK: - BleService.Callback
@available(iOS 13.0, *)
extension SwiftBleServicePlugin: BleService.Callback {
public func onScanResult(devices: [[String: Any]]) {
if let eventSink = statusEventSink {
DispatchQueue.main.async {
eventSink([
"type": "scanResult",
"devices": devices
])
}
}
}
public func onConnectionStateChanged(state: Int) {
if let eventSink = statusEventSink {
var stateStr = "Unknown"
switch state {
case BleConst.STATE_DISCONNECTED:
stateStr = "Disconnected"
case BleConst.STATE_CONNECTING:
stateStr = "Connecting"
case BleConst.STATE_CONNECTED:
stateStr = "Connected"
case BleConst.STATE_DISCONNECTING:
stateStr = "Disconnecting"
case BleConst.STATE_ERROR:
stateStr = "Connection Error"
default:
stateStr = "Unknown State(\(state))"
}
DispatchQueue.main.async {
eventSink([
"type": "connectionStateChanged",
"state": state,
"stateStr": stateStr
])
}
}
}
public func onAudioDataReceived(data: Data) {
if let eventSink = dataEventSink {
DispatchQueue.main.async {
eventSink([
"type": "audioData",
"data": FlutterStandardTypedData(bytes: data)
])
}
}
}
public func onWakeupSignalReceived() {
if let eventSink = statusEventSink {
DispatchQueue.main.async {
eventSink([
"type": "wakeup"
])
}
}
}
public func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) {
if let eventSink = statusEventSink {
var resultMap = infoData
resultMap["type"] = "deviceInfo"
resultMap["result"] = infoType
DispatchQueue.main.async {
eventSink(resultMap)
}
}
}
}
// MARK: - 独立的StreamHandler类
@available(iOS 13.0, *)
class BleDataStreamHandler: NSObject, FlutterStreamHandler {
private weak var plugin: SwiftBleServicePlugin?
init(_ plugin: SwiftBleServicePlugin) {
self.plugin = plugin
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
plugin?.dataEventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
plugin?.dataEventSink = nil
return nil
}
}
@available(iOS 13.0, *)
class BleStatusStreamHandler: NSObject, FlutterStreamHandler {
private weak var plugin: SwiftBleServicePlugin?
init(_ plugin: SwiftBleServicePlugin) {
self.plugin = plugin
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
plugin?.statusEventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
plugin?.statusEventSink = nil
return nil
}
}

239
local_plugins/ble_service/ios/Classes/SwiftOpusAudioProcessor.swift

@ -1,239 +0,0 @@
import Foundation
import AVFoundation
import YbridOpus
@available(iOS 13.0, *)
class SwiftOpusAudioProcessor: NSObject {
protocol AudioDataCallback: AnyObject {
func onAudioDataReceived(data: Data)
}
private var isDecodingActive = false
private weak var callback: AudioDataCallback?
// Opus 解码器相关 - 使用 YbridOpus C API
private var decoder: OpaquePointer?
private var sampleRate: Int32 = 16000
private var channels: Int32 = 1
private let maxFrameSize: Int32 = 5760 // 120 ms @48 kHz 的最大值
override init() {
super.init()
}
deinit {
cleanup()
}
private func initializeOpusDecoder() -> Bool {
if decoder != nil {
return true
}
var error: Int32 = 0
decoder = opus_decoder_create(sampleRate, channels, &error)
guard error == OPUS_OK, decoder != nil else {
return false
}
return true
}
private func destroyOpusDecoder() {
if let decoder = decoder {
opus_decoder_destroy(decoder)
self.decoder = nil
}
}
func setCallback(_ callback: AudioDataCallback?) {
self.callback = callback
}
func startDecoding(sampleRate: Int32 = 16000, channels: Int32 = 1, frameSize: Int32 = 640) -> Bool {
if isDecodingActive {
return true
}
self.sampleRate = sampleRate
self.channels = channels
guard initializeOpusDecoder() else {
return false
}
isDecodingActive = true
return true
}
func stopDecoding() -> Bool {
if !isDecodingActive {
return false
}
isDecodingActive = false
destroyOpusDecoder()
return true
}
func processAudioData(_ data: Data) {
guard isDecodingActive, let decoder = decoder, data.count > 0 else {
return
}
// 增强的数据包验证
guard data.count <= 1275 else {
print("音频数据包太大: \(data.count) bytes,跳过处理")
return
}
// 检查最小数据包大小(Opus最小包通常至少几个字节)
guard data.count >= 3 else {
print("音频数据包太小: \(data.count) bytes,跳过处理")
return
}
// 简单的 Opus 头部验证(检查是否是有效的 Opus 数据包格式)
let firstByte = data[0]
// Opus packets start with a TOC (Table of Contents) byte
// 检查TOC字节是否在合理范围内
if firstByte == 0x00 || firstByte == 0xFF {
print("可疑的Opus数据包头部: 0x\(String(format: "%02X", firstByte)),跳过处理")
return
}
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.decodeOpusPacket(data)
}
}
// 简化的 Opus 解码方法,重点是容错处理
private func decodeOpusPacket(_ packet: Data) {
guard let decoder = decoder else { return }
let maxSamples = Int(maxFrameSize)
var pcmBuffer = [opus_int16](repeating: 0, count: maxSamples * Int(channels))
let decodedSamples = packet.withUnsafeBytes { packetPtr in
let uint8Ptr = packetPtr.bindMemory(to: UInt8.self).baseAddress
return opus_decode(
decoder,
uint8Ptr,
opus_int32(packet.count),
&pcmBuffer,
Int32(maxSamples),
0
)
}
// 增强的错误处理:检查各种错误情况
if decodedSamples <= 0 {
let errorCode = decodedSamples
print("Opus解码失败,错误代码: \(errorCode),重新初始化解码器")
// 根据错误类型进行不同的处理
if errorCode == -1 { // OPUS_BAD_ARG
print("Opus解码参数错误,执行完全重置")
performCompleteReset()
} else if errorCode == -2 { // OPUS_BUFFER_TOO_SMALL
print("Opus缓冲区太小")
resetDecoder()
} else if errorCode == -3 { // OPUS_INTERNAL_ERROR
print("Opus内部错误,执行完全重置")
performCompleteReset()
} else {
print("Opus其他错误,尝试简单重置")
resetDecoder()
}
return
}
// 验证解码结果的合理性
guard decodedSamples <= maxFrameSize else {
print("Opus解码样本数超出预期: \(decodedSamples) > \(maxFrameSize)")
resetDecoder()
return
}
// 提取解码的音频数据
let samplesPerChannel = Int(decodedSamples)
let totalSamples = samplesPerChannel * Int(channels)
// 验证总样本数的合理性
guard totalSamples <= pcmBuffer.count else {
print("计算的总样本数超出缓冲区: \(totalSamples) > \(pcmBuffer.count)")
resetDecoder()
return
}
let outputPCM = Array(pcmBuffer[0..<totalSamples])
if !outputPCM.isEmpty {
processPCMData(outputPCM)
}
}
// 重置解码器的简单方法
private func resetDecoder() {
print("执行简单解码器重置")
destroyOpusDecoder()
_ = initializeOpusDecoder()
}
// 执行完全重置(包括停止和重启解码)
private func performCompleteReset() {
print("执行完全解码器重置")
let wasActive = isDecodingActive
let currentSampleRate = self.sampleRate
let currentChannels = self.channels
// 完全停止解码
if wasActive {
isDecodingActive = false
destroyOpusDecoder()
}
// 重新初始化所有状态
self.sampleRate = currentSampleRate
self.channels = currentChannels
// 如果之前是活跃状态,重新启动
if wasActive {
guard initializeOpusDecoder() else {
print("完全重置后重新初始化失败")
return
}
isDecodingActive = true
print("完全重置成功,解码器已重新启动")
}
}
private func processPCMData(_ pcmData: [opus_int16]) {
// 转换为 Data
let data = Data(bytes: pcmData, count: pcmData.count * MemoryLayout<opus_int16>.size)
// 回调通知
DispatchQueue.main.async { [weak self] in
self?.callback?.onAudioDataReceived(data: data)
}
}
func isDecoding() -> Bool {
return isDecodingActive
}
func cleanup() {
stopDecoding()
}
// MARK: - 公共接口
/// 获取当前配置信息
func getCurrentConfig() -> (sampleRate: Int32, channels: Int32) {
return (sampleRate, channels)
}
}

1
local_plugins/ble_service/ios/Classes/XCFrameworkExample.swift

@ -1 +0,0 @@

12
local_plugins/chat_api/analysis_options.yaml

@ -0,0 +1,12 @@
include: ../../analysis_options.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "build/**"
- ".dart_tool/**"
language:
strict-casts: false
strict-inference: false
strict-raw-types: false

1
local_plugins/chat_api/android/build.gradle.kts

@ -60,4 +60,5 @@ dependencies {
add("compileOnly", project(":location_service")) add("compileOnly", project(":location_service"))
add("compileOnly", project(":qq_music"))
} }

30
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt

@ -364,7 +364,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
toolCalls.clear() toolCalls.clear()
// 检查是否包含图片,决定使用哪个模型 // 检查是否包含图片,决定使用哪个模型
val currentModel = if (containsImage(messages)) visionModel else model val currentModel = if (containsImage(messages)) visionModel else model
// Log.d(TAG, "liwei-------------------------开始AI 对话 0") Log.d(TAG, "liwei-------------------------开始AI 对话 0")
currentStreamJob = launch { currentStreamJob = launch {
try { try {
// 转换消息格式 // 转换消息格式
@ -374,22 +374,22 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
Log.e("ChatApiService", "转换消息格式失败: ${e.message}", e) Log.e("ChatApiService", "转换消息格式失败: ${e.message}", e)
throw e throw e
} }
// Log.d(TAG, "liwei-------------------------开始AI 对话 1") Log.d(TAG, "liwei-------------------------开始AI 对话 1")
// 直接获取工具列表 // 直接获取工具列表
val tools = getOpenAiTools() val tools = getOpenAiTools()
// Log.d(TAG, "liwei-------------------------开始AI 对话 2") Log.d(TAG, "liwei-------------------------开始AI 对话 2")
// 构建请求 // 构建请求
if (currentModel.isEmpty()) { if (currentModel.isEmpty()) {
Log.e("ChatApiService", "模型名称为空") Log.e("ChatApiService", "模型名称为空")
throw IllegalArgumentException("模型名称不能为空") throw IllegalArgumentException("模型名称不能为空")
} }
// Log.d(TAG, "liwei-------------------------开始AI 对话 3") Log.d(TAG, "liwei-------------------------开始AI 对话 3")
if (chatMessages.isEmpty()) { if (chatMessages.isEmpty()) {
Log.e("ChatApiService", "消息列表为空") Log.e("ChatApiService", "消息列表为空")
throw IllegalArgumentException("消息列表不能为空") throw IllegalArgumentException("消息列表不能为空")
} }
// Log.e("ChatApiService", "AI解析工具列表: ${tools}") // Log.e("ChatApiService", "AI解析工具列表: ${tools}")
// Log.d(TAG, "liwei-------------------------开始AI 对话 4") Log.d(TAG, "liwei-------------------------开始AI 对话 4")
val chatsFlow = try { val chatsFlow = try {
val chatCompletionRequest = ChatCompletionRequest( val chatCompletionRequest = ChatCompletionRequest(
model = ModelId(currentModel), model = ModelId(currentModel),
@ -398,14 +398,14 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
temperature = 0.7, temperature = 0.7,
tools = if (tools.isNotEmpty()) tools else null tools = if (tools.isNotEmpty()) tools else null
) )
// Log.d(TAG, "liwei-------------------------开始AI 对话 5") Log.d(TAG, "liwei-------------------------开始AI 对话 5")
if (openAI == null) { if (openAI == null) {
Log.e("ChatApiService", "openAI对象为null") Log.e("ChatApiService", "openAI对象为null")
throw IllegalStateException("OpenAI客户端未初始化") throw IllegalStateException("OpenAI客户端未初始化")
} }
Log.e("ChatApiService", "liwei---------,发送AI请求 model:${currentModel} messages:${chatMessages}") Log.e("ChatApiService", "liwei---------,发送AI请求 model:${currentModel} messages:${chatMessages}")
val flow = openAI!!.chatCompletions(chatCompletionRequest) val flow = openAI!!.chatCompletions(chatCompletionRequest)
// Log.d(TAG, "liwei-------------------------开始AI 对话 6") Log.d(TAG, "liwei-------------------------开始AI 对话 6")
flow flow
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatApiService", "创建ChatCompletionRequest或调用chatCompletions失败: ${e.message}", e) Log.e("ChatApiService", "创建ChatCompletionRequest或调用chatCompletions失败: ${e.message}", e)
@ -413,7 +413,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
} }
var replyContext = ""; var replyContext = "";
// Log.d(TAG, "liwei-------------------------开始AI 对话 7") Log.d(TAG, "liwei-------------------------开始AI 对话 7")
chatsFlow.collect { result -> chatsFlow.collect { result ->
// val usage = result.usage // val usage = result.usage
// if (usage != null){ //发现流式不返回这歌数据 // if (usage != null){ //发现流式不返回这歌数据
@ -424,19 +424,19 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
// throw CancellationException("Session cancelled") // throw CancellationException("Session cancelled")
return@collect return@collect
} }
// Log.d(TAG, "liwei-------------------------开始AI 对话 7-1") Log.d(TAG, "liwei-------------------------开始AI 对话 7-1")
val choice = result.choices.firstOrNull() val choice = result.choices.firstOrNull()
if (choice == null) { if (choice == null) {
Log.w("ChatApiService", "[Session: $sessionid] choice为null") Log.w("ChatApiService", "[Session: $sessionid] choice为null")
return@collect return@collect
} }
// Log.d(TAG, "liwei-------------------------开始AI 对话 7-2") Log.d(TAG, "liwei-------------------------开始AI 对话 7-2")
val delta = choice.delta val delta = choice.delta
if (delta == null) { if (delta == null) {
Log.w("ChatApiService", "[Session: $sessionid] delta为null") Log.w("ChatApiService", "[Session: $sessionid] delta为null")
return@collect return@collect
} }
// Log.d(TAG, "liwei-------------------------开始AI 对话 7-3") Log.d(TAG, "liwei-------------------------开始AI 对话 7-3")
// 处理普通文本内容 // 处理普通文本内容
delta.content?.let { content -> delta.content?.let { content ->
try { try {
@ -447,7 +447,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
// 不重新抛出异常,继续处理后续内容 // 不重新抛出异常,继续处理后续内容
} }
} }
// Log.d(TAG, "liwei-------------------------开始AI 对话 7-4") Log.d(TAG, "liwei-------------------------开始AI 对话 7-4")
// 收集工具调用信息 // 收集工具调用信息
delta.toolCalls?.forEach { toolCall -> delta.toolCalls?.forEach { toolCall ->
try { try {
@ -499,10 +499,10 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
val promptTokens = estimatePromptTokens(chatMessages); //计算上传token val promptTokens = estimatePromptTokens(chatMessages); //计算上传token
val completionTokens = roughTokenCount(replyContext); //计算上传token val completionTokens = roughTokenCount(replyContext); //计算上传token
getSessionCallback(sessionid)?.onUsage(sessionid,promptTokens,completionTokens,promptTokens+completionTokens) getSessionCallback(sessionid)?.onUsage(sessionid,promptTokens,completionTokens,promptTokens+completionTokens)
// Log.d(TAG, "liwei-------------------------开始AI 对话 8") Log.d(TAG, "liwei-------------------------开始AI 对话 8")
// 完成处理 // 完成处理
if (sessionid == currSessionId) { if (sessionid == currSessionId) {
// Log.d(TAG, "liwei-------------------------开始AI 对话 9") Log.d(TAG, "liwei-------------------------开始AI 对话 9")
// 检查是否有工具调用需要处理 // 检查是否有工具调用需要处理
val hasToolCalls = processToolCalls(sessionid) val hasToolCalls = processToolCalls(sessionid)
if (!hasToolCalls) { if (!hasToolCalls) {
@ -514,7 +514,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
clearCurrentSession() clearCurrentSession()
} }
} }
// Log.d(TAG, "liwei-------------------------开始AI 对话 10") Log.d(TAG, "liwei-------------------------开始AI 对话 10")
} catch (e: CancellationException) { } catch (e: CancellationException) {
// 会话被取消,不需要处理 // 会话被取消,不需要处理
Log.d(TAG, "Session $sessionid cancelled") Log.d(TAG, "Session $sessionid cancelled")

14
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt

@ -54,6 +54,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable {
init { init {
initializeSystemFunctions() initializeSystemFunctions()
initializeQQMusiceFunctions()
} }
/** /**
@ -68,6 +69,18 @@ class MCPClient(private val context: Context? = null) : AutoCloseable {
} }
} }
/**
* 初始化音乐函数
*/
private fun initializeQQMusiceFunctions() {
try {
val handler = QQMusicSystemFunctionHandler(context)
handler.registerAllFunctions(this)
} catch (e: Exception) {
Log.w(TAG, "Failed to initialize system functions", e)
}
}
/** /**
* 连接到SSE服务器 * 连接到SSE服务器
* 直接接收完整的JSON配置字符串 * 直接接收完整的JSON配置字符串
@ -381,5 +394,4 @@ class MCPClient(private val context: Context? = null) : AutoCloseable {
return list return list
} }
} }

2
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt

@ -460,6 +460,7 @@ class MCPSubClient(
} }
} }
} }
/** /**
* 处理连接错误并尝试重连 * 处理连接错误并尝试重连
*/ */
@ -480,6 +481,7 @@ class MCPSubClient(
// 可以在这里添加通知或回调,告知上层连接彻底失败 // 可以在这里添加通知或回调,告知上层连接彻底失败
} }
} }
/** /**
* 关闭连接 * 关闭连接
*/ */

504
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/QQMusicSystemFunctionHandler.kt

@ -0,0 +1,504 @@
package com.yunqiinnovation.chat_api
import android.content.Context
import android.util.Log
import kotlinx.coroutines.delay
import com.yunqiinnovation.qq_music.QQMusicSingleton
/**
* QQ音乐系统功能处理器
* 负责注册QQ音乐相关的MCP函数
*/
class QQMusicSystemFunctionHandler(private val context: Context? = null) {
companion object {
const val TAG = "QQMusicSystemFunctionHandler"
}
/**
* 注册所有QQ音乐相关函数到MCPClient
*/
fun registerAllFunctions(client: MCPClient) {
try {
// Only register QQ Music search function
client.registerLocalFunction(
"qqmusic_searchandplay_songs",
"在QQ音乐中搜索并播放歌曲,支持歌曲、专辑、歌手等搜索类型",
mapOf(
"type" to "object",
"properties" to mapOf(
"keyword" to mapOf(
"type" to "string",
"description" to "搜索关键词,歌曲名、专辑名或歌手名"
),
"searchType" to mapOf(
"type" to "integer",
"description" to "搜索类型:0-歌名搜索,1-歌手搜索,2-歌曲类型搜索",
"default" to 0
)
),
"required" to listOf("keyword")
),
QQMusicSearchHandler(context)
)
// QQ音乐播放歌曲
// client.registerLocalFunction(
// "qqmusic_play_songs",
// "播放QQ音乐歌曲列表",
// mapOf(
// "type" to "object",
// "properties" to mapOf(
// "songMids" to mapOf(
// "type" to "array",
// "description" to "歌曲ID列表",
// "items" to mapOf("type" to "string")
// ),
// "index" to mapOf(
// "type" to "integer",
// "description" to "开始播放的歌曲索引,默认为0"
// )
// ),
// "required" to listOf("songMids")
// ),
// QQMusicPlaySongsHandler(context)
// )
// QQ音乐播放控制
// client.registerLocalFunction(
// "qqmusic_play",
// "播放QQ音乐",
// mapOf(
// "type" to "object",
// "properties" to emptyMap<String, Any>(),
// "required" to emptyList<String>()
// ),
// QQMusicPlayHandler(context)
// )
client.registerLocalFunction(
"qqmusic_pause",
"暂停QQ音乐",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicPauseHandler(context)
)
client.registerLocalFunction(
"qqmusic_resume",
"恢复播放QQ音乐",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicResumeHandler(context)
)
client.registerLocalFunction(
"qqmusic_stop",
"停止QQ音乐播放",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicStopHandler(context)
)
client.registerLocalFunction(
"qqmusic_next",
"QQ音乐下一首",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicNextHandler(context)
)
client.registerLocalFunction(
"qqmusic_previous",
"QQ音乐上一首",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicPreviousHandler(context)
)
// QQ音乐状态查询
client.registerLocalFunction(
"qqmusic_get_playback_state",
"获取QQ音乐播放状态",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicGetPlaybackStateHandler(context)
)
client.registerLocalFunction(
"qqmusic_get_current_song",
"获取QQ音乐当前播放歌曲信息",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicGetCurrentSongHandler(context)
)
client.registerLocalFunction(
"qqmusic_get_play_time",
"获取QQ音乐播放时间信息",
mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
QQMusicGetPlayTimeHandler(context)
)
Log.d(TAG, "QQ音乐所有功能注册完成")
} catch (e: Exception) {
Log.e(TAG, "注册QQ音乐功能失败: ${e.message}", e)
}
}
}
private class QQMusicLoginHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.login { loginResult ->
result = if (loginResult.isSuccess) {
val loginData = loginResult.getOrNull()
"{\"success\": true, \"message\": \"登录成功\", \"data\": {\"openId\": \"${loginData?.openId}\", \"openToken\": \"${loginData?.openToken}\", \"expireTime\": \"${loginData?.expireTime}\"}}"
} else {
val error = loginResult.exceptionOrNull()
"{\"success\": false, \"message\": \"登录失败: ${error?.message}\"}"
}
}
// 等待回调完成
delay(3000)
result.ifEmpty { "{\"success\": false, \"message\": \"登录超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicLoginHandler", "QQ音乐登录失败", e)
"{\"success\": false, \"message\": \"登录异常: ${e.message}\"}"
}
}
}
private class QQMusicSearchHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val keyword = arguments["keyword"] as? String ?: return "{\"success\": false, \"message\": \"缺少搜索关键词\"}"
val searchType = arguments["searchType"] as? Int ?: 0 // 默认搜索歌曲
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
// 直接调用搜索,内部会自动处理登录检查
qqMusicSingleton.searchSongs(keyword) { searchResult ->
result = if (searchResult.isSuccess) {
val songs = searchResult.getOrNull() ?: emptyList()
// 根据搜索类型处理结果
val processedSongs = when (searchType) {
0 -> { // 歌曲搜索,只返回第一个结果
// if (songs.isNotEmpty()) listOf(songs.first()) else emptyList()
songs
}
else -> { // 专辑、歌手等其他类型,返回所有结果
songs
}
}
val songsJson = processedSongs.joinToString(",") { song ->
val songMap = song as Map<String, Any?>
// 添加调试日志
Log.d("QQMusicSearchHandler", "原始数据: $songMap")
// 修正字段名映射
val mid = songMap["mid"] ?: "unknown"
val name = songMap["name"] ?: "unknown"
val artist = songMap["singer"] ?: "unknown"
val album = songMap["album"] ?: ""
val pic = songMap["pic"] ?: ""
val duration = songMap["duration"] ?: 0
"{\"mid\": \"$mid\", \"name\": \"$name\", \"artist\": \"$artist\", \"album\": \"$album\", \"duration\":$duration,\"pic\": \"$pic\"}"
}
"{\"success\": true, \"broadcast\": false, \"message\": \"搜索成功,增在播放\", \"data\": [$songsJson]}"
} else {
val error = searchResult.exceptionOrNull()
"{\"success\": false, \"message\": \"搜索失败: ${error?.message}\"}"
}
}
// 等待回调完成
delay(5000)
result.ifEmpty { "{\"success\": false, \"message\": \"搜索超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicSearchHandler", "QQ音乐搜索失败", e)
"{\"success\": false, \"message\": \"搜索异常: ${e.message}\"}"
}
}
}
private class QQMusicPlaySongsHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
@Suppress("UNCHECKED_CAST")
val songMids = arguments["songMids"] as? List<String> ?: return "{\"success\": false, \"message\": \"缺少歌曲ID列表\"}"
val index = (arguments["index"] as? Number)?.toInt() ?: 0
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.playSongs(songMids, index) { playResult ->
result = if (playResult.isSuccess) {
"{\"nocard\": true, \"success\": true, \"message\": \"开始播放歌曲列表\"}"
} else {
val error = playResult.exceptionOrNull()
"{\"nocard\": true, \"success\": false, \"message\": \"播放失败: ${error?.message}\"}"
}
}
// 等待回调完成
delay(3000)
result.ifEmpty { "{\"nocard\": true, \"success\": false, \"message\": \"播放超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicPlaySongsHandler", "QQ音乐播放歌曲失败", e)
"{\"nocard\": true, \"success\": false, \"message\": \"播放异常: ${e.message}\"}"
}
}
}
private class QQMusicPlayHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.playMusic { playResult ->
result = if (playResult.isSuccess) {
"{\"success\": true, \"message\": \"开始播放\"}"
} else {
val error = playResult.exceptionOrNull()
"{\"success\": false, \"message\": \"播放失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"播放超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicPlayHandler", "QQ音乐播放失败", e)
"{\"success\": false, \"message\": \"播放异常: ${e.message}\"}"
}
}
}
private class QQMusicPauseHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.pauseMusic { pauseResult ->
result = if (pauseResult.isSuccess) {
"{\"notreply\": true, \"success\": true, \"message\": \"暂停播放\"}"
} else {
val error = pauseResult.exceptionOrNull()
"{\"success\": false, \"message\": \"暂停失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"暂停超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicPauseHandler", "QQ音乐暂停失败", e)
"{\"success\": false, \"message\": \"暂停异常: ${e.message}\"}"
}
}
}
private class QQMusicResumeHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.resumeMusic { resumeResult ->
result = if (resumeResult.isSuccess) {
"{\"notreply\": true, \"success\": true, \"message\": \"恢复播放\"}"
} else {
val error = resumeResult.exceptionOrNull()
"{\"success\": false, \"message\": \"恢复播放失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"恢复播放超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicResumeHandler", "QQ音乐恢复播放失败", e)
"{\"success\": false, \"message\": \"恢复播放异常: ${e.message}\"}"
}
}
}
private class QQMusicStopHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.stopMusic { stopResult ->
result = if (stopResult.isSuccess) {
"{\"notreply\": true, \"success\": true, \"message\": \"停止播放\"}"
} else {
val error = stopResult.exceptionOrNull()
"{\"success\": false, \"message\": \"停止失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"停止超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicStopHandler", "QQ音乐停止失败", e)
"{\"success\": false, \"message\": \"停止异常: ${e.message}\"}"
}
}
}
private class QQMusicNextHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.skipToNext { nextResult ->
result = if (nextResult.isSuccess) {
"{\"notreply\": true, \"success\": true, \"message\": \"切换到下一首\"}"
} else {
val error = nextResult.exceptionOrNull()
"{\"success\": false, \"message\": \"切换失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"切换超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicNextHandler", "QQ音乐下一首失败", e)
"{\"success\": false, \"message\": \"切换异常: ${e.message}\"}"
}
}
}
private class QQMusicPreviousHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.skipToPrevious { previousResult ->
result = if (previousResult.isSuccess) {
"{\"notreply\": true, \"success\": true, \"message\": \"切换到上一首\"}"
} else {
val error = previousResult.exceptionOrNull()
"{\"success\": false, \"message\": \"切换失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"切换超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicPreviousHandler", "QQ音乐上一首失败", e)
"{\"success\": false, \"message\": \"切换异常: ${e.message}\"}"
}
}
}
private class QQMusicGetPlaybackStateHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.getPlaybackState { stateResult ->
result = if (stateResult.isSuccess) {
val state = stateResult.getOrNull() ?: emptyMap()
val stateJson = state.entries.joinToString(",") { (key, value) ->
"\"$key\": \"$value\""
}
"{\"notreply\": true, \"success\": true, \"message\": \"获取播放状态成功\", \"data\": {$stateJson}}"
} else {
val error = stateResult.exceptionOrNull()
"{\"success\": false, \"message\": \"获取播放状态失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"获取播放状态超时\"}" }
} catch (e: Exception) {
Log.e("CahtApi", "获取QQ音乐播放状态失败", e)
"{\"success\": false, \"message\": \"获取播放状态异常: ${e.message}\"}"
}
}
}
private class QQMusicGetCurrentSongHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.getCurrentSong { songResult ->
result = if (songResult.isSuccess) {
val song = songResult.getOrNull() ?: emptyMap()
val songJson = song.entries.joinToString(",") { (key, value) ->
"\"$key\": \"$value\""
}
"{\"success\": true, \"message\": \"获取当前歌曲成功\", \"data\": {$songJson}}"
} else {
val error = songResult.exceptionOrNull()
"{\"success\": false, \"message\": \"获取当前歌曲失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"获取当前歌曲超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicLoginHandler", "获取QQ音乐当前歌曲失败", e)
"{\"success\": false, \"message\": \"获取当前歌曲异常: ${e.message}\"}"
}
}
}
private class QQMusicGetPlayTimeHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
return try {
val qqMusicSingleton = QQMusicSingleton.getInstance()
var result = ""
qqMusicSingleton.getPlayTime { timeResult ->
result = if (timeResult.isSuccess) {
val timeInfo = timeResult.getOrNull()
"{\"success\": true, \"message\": \"获取播放时间成功\", \"data\": {\"totalTime\": ${timeInfo?.totalTime}, \"currentTime\": ${timeInfo?.currentTime}}}"
} else {
val error = timeResult.exceptionOrNull()
"{\"success\": false, \"message\": \"获取播放时间失败: ${error?.message}\"}"
}
}
delay(2000)
result.ifEmpty { "{\"success\": false, \"message\": \"获取播放时间超时\"}" }
} catch (e: Exception) {
Log.e("QQMusicLoginHandler", "获取QQ音乐播放时间失败", e)
"{\"success\": false, \"message\": \"获取播放时间异常: ${e.message}\"}"
}
}
}

110
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt

@ -152,63 +152,63 @@ class SystemFunctionHandler(private val context: Context? = null) {
handler = GetCurrentLocationHandler(context) handler = GetCurrentLocationHandler(context)
) )
// 注册媒体播放功能 // // 注册媒体播放功能
client.registerLocalFunction( // client.registerLocalFunction(
name = "media_play", // name = "media_play",
description = "播放媒体", // description = "播放媒体",
parameters = mapOf( // parameters = mapOf(
"type" to "object", // "type" to "object",
"properties" to emptyMap<String, Any>(), // "properties" to emptyMap<String, Any>(),
"required" to emptyList<String>() // "required" to emptyList<String>()
), // ),
handler = MediaPlayHandler(context) // handler = MediaPlayHandler(context)
) // )
// 注册媒体暂停功能 // // 注册媒体暂停功能
client.registerLocalFunction( // client.registerLocalFunction(
name = "media_pause", // name = "media_pause",
description = "暂停媒体播放", // description = "暂停媒体播放",
parameters = mapOf( // parameters = mapOf(
"type" to "object", // "type" to "object",
"properties" to emptyMap<String, Any>(), // "properties" to emptyMap<String, Any>(),
"required" to emptyList<String>() // "required" to emptyList<String>()
), // ),
handler = MediaPauseHandler(context) // handler = MediaPauseHandler(context)
) // )
// 注册媒体暂停功能 // // 注册媒体暂停功能
client.registerLocalFunction( // client.registerLocalFunction(
name = "media_close", // name = "media_close",
description = "关闭媒体播放", // description = "关闭媒体播放",
parameters = mapOf( // parameters = mapOf(
"type" to "object", // "type" to "object",
"properties" to emptyMap<String, Any>(), // "properties" to emptyMap<String, Any>(),
"required" to emptyList<String>() // "required" to emptyList<String>()
), // ),
handler = MediaCloseHandler(context) // handler = MediaCloseHandler(context)
) // )
// 注册媒体上一首功能 // // 注册媒体上一首功能
client.registerLocalFunction( // client.registerLocalFunction(
name = "media_previous", // name = "media_previous",
description = "播放上一首", // description = "播放上一首",
parameters = mapOf( // parameters = mapOf(
"type" to "object", // "type" to "object",
"properties" to emptyMap<String, Any>(), // "properties" to emptyMap<String, Any>(),
"required" to emptyList<String>() // "required" to emptyList<String>()
), // ),
handler = MediaPreviousHandler(context) // handler = MediaPreviousHandler(context)
) // )
// 注册媒体下一首功能 // // 注册媒体下一首功能
client.registerLocalFunction( // client.registerLocalFunction(
name = "media_next", // name = "media_next",
description = "播放下一首", // description = "播放下一首",
parameters = mapOf( // parameters = mapOf(
"type" to "object", // "type" to "object",
"properties" to emptyMap<String, Any>(), // "properties" to emptyMap<String, Any>(),
"required" to emptyList<String>() // "required" to emptyList<String>()
), // ),
handler = MediaNextHandler(context) // handler = MediaNextHandler(context)
) // )
// 注册打开录音机功能 // 注册打开录音机功能
client.registerLocalFunction( client.registerLocalFunction(

4
local_plugins/chat_api/ios/chat_api/Package.swift

@ -16,6 +16,9 @@ let package = Package(
.package(url: "https://github.com/MacPaw/OpenAI.git", from: "0.4.3"), .package(url: "https://github.com/MacPaw/OpenAI.git", from: "0.4.3"),
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"),
.package(path: "../../../location_service/ios/location_service"), .package(path: "../../../location_service/ios/location_service"),
.package(path: "../../../qq_music/ios/qq_music"),
], ],
targets: [ targets: [
.target( .target(
@ -23,6 +26,7 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "MCP", package: "swift-sdk"), .product(name: "MCP", package: "swift-sdk"),
.product(name: "OpenAI", package: "OpenAI"), .product(name: "OpenAI", package: "OpenAI"),
.product(name: "qq-music", package: "qq_music"),
.product(name: "location-service", package: "location_service") .product(name: "location-service", package: "location_service")
], ],
exclude: [] exclude: []

6
local_plugins/chat_api/ios/chat_api/Sources/chat_api/MCPClient.swift

@ -434,7 +434,11 @@ public class MCPClient {
do { do {
let handler = SystemFunctionHandler() let handler = SystemFunctionHandler()
handler.registerAllFunctions(client: self) handler.registerAllFunctions(client: self)
} catch {
// 初始化QQ音乐功能
let qqMusicHandler = QQMusicSystemFunctionHandler()
qqMusicHandler.registerAllFunctions(client: self)
} catch {
// 静默处理错误 // 静默处理错误
} }
} }

283
local_plugins/chat_api/ios/chat_api/Sources/chat_api/QQMusicSystemFunctionHandler.swift

@ -0,0 +1,283 @@
import Foundation
import qq_music
class QQMusicSystemFunctionHandler {
// 添加 registerAllFunctions 方法
func registerAllFunctions(client: MCPClient) {
// 注册QQ音乐搜索和播放功能
client.registerLocalFunction(
name: "qqmusic_searchandplay_songs",
description: "搜索并播放QQ音乐歌曲",
parameters: [
"type": "object",
"properties": [
"keyword": [
"type": "string",
"description": "搜索关键词"
]
],
"required": ["keyword"]
],
handler: QQMusicSearchHandler()
)
// 注册暂停功能
client.registerLocalFunction(
name: "qqmusic_pause",
description: "暂停QQ音乐播放",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicPauseHandler()
)
// 注册恢复播放功能
client.registerLocalFunction(
name: "qqmusic_resume",
description: "恢复QQ音乐播放",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicResumeHandler()
)
// 注册停止功能
client.registerLocalFunction(
name: "qqmusic_stop",
description: "停止QQ音乐播放",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicStopHandler()
)
// 注册下一首功能
client.registerLocalFunction(
name: "qqmusic_next",
description: "播放下一首歌曲",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicNextHandler()
)
// 注册上一首功能
client.registerLocalFunction(
name: "qqmusic_previous",
description: "播放上一首歌曲",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicPreviousHandler()
)
// 注册获取播放状态功能
client.registerLocalFunction(
name: "qqmusic_get_playback_state",
description: "获取QQ音乐播放状态",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicGetPlaybackStateHandler()
)
// 注册获取当前歌曲功能
client.registerLocalFunction(
name: "qqmusic_get_current_song",
description: "获取当前播放的歌曲信息",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicGetCurrentSongHandler()
)
// 注册获取播放时间功能
client.registerLocalFunction(
name: "qqmusic_get_play_time",
description: "获取播放时间信息",
parameters: [
"type": "object",
"properties": [:] as [String: Any],
"required": [] as [String]
],
handler: QQMusicGetPlayTimeHandler()
)
}
}
// MARK: - Private Handler Classes
private class QQMusicSearchHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
guard let keyword = arguments["keyword"] as? String else {
let result: [String: Any] = ["success": false, "error": "Missing keyword parameter"]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
return await withCheckedContinuation { continuation in
// 直接调用QPlayAutoManager的搜索接口
QPlayAutoManager.shared.search(keyword: keyword, searchType: .song) { items in
var result: [String: Any]
if let items = items, !items.isEmpty {
let songs = items.map { item -> [String: Any] in
return [
"mid": item.mid ?? "",
"name": item.name ?? "",
"artist": item.singer?.name ?? "",
"album": item.album ?? "",
"duration": item.duration,
"pic": item.coverUri ?? ""
]
}
result = [
"success": true,
"message": "Found \(items.count) songs and started playing",
"broadcast":false,
"data": songs
]
} else {
result = ["success": false, "error": "No songs found"]
}
do {
let jsonData = try JSONSerialization.data(withJSONObject: result)
let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
continuation.resume(returning: jsonString)
} catch {
continuation.resume(returning: "{\"success\": false, \"error\": \"JSON serialization failed\"}")
}
}
}
}
}
private class QQMusicPauseHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
QPlayAutoManager.shared.pause()
let result: [String: Any] = ["success": true, "message": "Music paused"]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}
private class QQMusicResumeHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
QPlayAutoManager.shared.play()
let result: [String: Any] = ["success": true, "message": "Music resumed"]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}
private class QQMusicStopHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
QPlayAutoManager.shared.stop()
let result: [String: Any] = ["success": true, "message": "Music stopped"]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}
private class QQMusicNextHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
QPlayAutoManager.shared.next()
let result: [String: Any] = ["success": true, "message": "Skipped to next song"]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}
private class QQMusicPreviousHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
QPlayAutoManager.shared.previous()
let result: [String: Any] = ["success": true, "message": "Skipped to previous song"]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}
private class QQMusicGetPlaybackStateHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
let playState = QPlayAutoManager.shared.currentPlayState
let isConnected = QPlayAutoManager.shared.isConnected
var state = "unknown"
switch playState {
case .playing:
state = "playing"
case .pause:
state = "paused"
case .stop:
state = "stopped"
default:
state = "unknown"
}
let result: [String: Any] = [
"success": true,
"state": state,
"connected": isConnected
]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}
private class QQMusicGetCurrentSongHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
var result: [String: Any]
if let currentSong = QPlayAutoManager.shared.currentSong {
result = [
"success": true,
"song": [
"id": currentSong.id ?? "",
"name": currentSong.name ?? "",
"artist": currentSong.singer?.name ?? "",
"album": currentSong.album ?? "",
"duration": currentSong.duration
]
]
} else {
result = ["success": false, "error": "No current song"]
}
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}
private class QQMusicGetPlayTimeHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
let currentProgress = QPlayAutoManager.shared.currentProgress
let currentSong = QPlayAutoManager.shared.currentSong
let totalDuration = currentSong?.duration ?? 0
let result: [String: Any] = [
"success": true,
"current_time": currentProgress,
"total_time": totalDuration
]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{}"
}
}

33
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/

33
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'

3
local_plugins/qq_music/CHANGELOG.md

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

1
local_plugins/qq_music/LICENSE

@ -0,0 +1 @@
TODO: Add your license here.

15
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.

4
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

9
local_plugins/qq_music/android/.gitignore

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

79
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')
}
}

BIN
local_plugins/qq_music/android/libs/qqmusic-innovation-aidl-api-sdk-1.0.26.aar

Binary file not shown.

1
local_plugins/qq_music/android/settings.gradle

@ -0,0 +1 @@
rootProject.name = 'qq_music'

9
local_plugins/qq_music/android/src/main/AndroidManifest.xml

@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yunqiinnovation.qq_music">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<!-- 移除了 LoginExampleActivity 的声明,因为该类已被删除 -->
</application>
</manifest>

15
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);
}

8
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);
}

361
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<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("midList", ids)
api?.executeAsync("playSongMid", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
private fun playSongMidAtIndex(api: IQQMusicApi?, songList: List<Data.Song>, index: Int, block: (() -> Unit)) {
val midList = ArrayList<String>()
for (i in songList.indices) {
midList.add(songList[i].mid)
}
val params = Bundle()
params.putStringArrayList("midList", midList)
params.putInt("index", index)
Log.i(TAG, "[playSongMidAtIndex] 播放歌曲列表... $index,${songList.size}")
api?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
fun playSongLocalPath(api: IQQMusicApi?, pathList: ArrayList<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("pathList", pathList)
api?.executeAsync("playSongLocalPath", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
fun playMusic(api: IQQMusicApi?): Int {
val ret = api?.execute("playMusic", null)
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0
}
fun stopMusic(api: IQQMusicApi?): Int {
val ret = api?.execute("stopMusic", null)
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0
}
fun pauseMusic(api: IQQMusicApi?): Int {
val ret = api?.execute("pauseMusic", null)
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0
}
fun resumeMusic(api: IQQMusicApi?): Int {
val ret = api?.execute("resumeMusic", null)
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0
}
fun skipToNext(api: IQQMusicApi?): Int {
val ret = api?.execute("skipToNext", null)
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0
}
fun skipToPrevious(api: IQQMusicApi?): Int {
val ret = api?.execute("skipToPrevious", null)
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0
}
fun getPlaybackState(api: IQQMusicApi?): Int {
val ret = api?.execute("getPlaybackState", null)
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0
}
@Nullable
fun getCurrentSong(api: IQQMusicApi?, gson: Gson): Data.Song? {
val ret = api?.execute("getCurrentSong", null)
val curSongJson = ret?.getString(Keys.API_RETURN_KEY_DATA)
return gson.fromJson(curSongJson, Data.Song::class.java)
}
fun addToFavourite(api: IQQMusicApi?, midList: ArrayList<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("midList", midList)
api?.executeAsync("addToFavourite", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
fun removeFromFavourite(api: IQQMusicApi?, midList: ArrayList<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("midList", midList)
api?.executeAsync("removeFromFavourite", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
fun addLocalPathToFavourite(api: IQQMusicApi?, localPathList: ArrayList<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("localPathList", localPathList)
api?.executeAsync("addLocalPathToFavourite", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
fun removeLocalPathFromFavourite(api: IQQMusicApi?, localPathList: ArrayList<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("localPathList", localPathList)
api?.executeAsync("removeLocalPathFromFavourite", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
fun isFavouriteMid(api: IQQMusicApi?, midList: ArrayList<String>, block: ((ret: BooleanArray?) -> Unit)) {
val params = Bundle()
params.putStringArrayList("midList", midList)
api?.executeAsync("isFavouriteMid", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA)
block(ret)
}
})
}
fun isFavouriteLocalPath(api: IQQMusicApi?, localPathList: ArrayList<String>, block: ((ret: BooleanArray?) -> Unit)) {
val params = Bundle()
params.putStringArrayList("localPathList", localPathList)
api?.executeAsync("isFavouriteLocalPath", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA)
block(ret)
}
})
}
fun registerEventListener(api: IQQMusicApi?, events: ArrayList<String>, block: (() -> Unit)) {
api?.registerEventListener(events, object : IQQMusicApiEventListener.Stub() {
override fun onEvent(event: String, extra: Bundle) {
block()
}
})
}
fun unregisterEventListener(api: IQQMusicApi?, events: ArrayList<String>, block: (() -> Unit)) {
api?.unregisterEventListener(events, object : IQQMusicApiEventListener.Stub() {
override fun onEvent(event: String, extra: Bundle) {
block()
}
})
}
fun playFromChorus(api: IQQMusicApi?, fromChorus: Boolean) {
val params = Bundle()
params.putBoolean("fromChorus", fromChorus)
api?.execute("playFromChorus", params)
}
@Deprecated("Use playSongMid instead.")
fun playSongId(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("songIdList", ids)
api?.executeAsync("playSongId", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
@Deprecated("Use playSongMidAtIndex instead.")
fun playSongIdAtIndex(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) {
val params = Bundle()
params.putStringArrayList("songIdList", ids)
api?.executeAsync("playSongIdAtIndex", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
block()
}
})
}
fun getTotalTime(api: IQQMusicApi?): Long {
val result = api?.execute("getTotalTime", null)
return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000
}
fun getCurrTime(api: IQQMusicApi?): Long {
val result = api?.execute("getCurrTime", null)
return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000
}
fun getPlayList(api: IQQMusicApi?, ids: ArrayList<String>, block: ((json: String?) -> Unit)) {
val params = Bundle()
params.putStringArrayList("songIdList", ids)
api?.executeAsync("getPlayList", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle) {
val json = result.getString(Keys.API_RETURN_KEY_DATA)
block(json)
}
})
}
fun getFolderList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) {
val params = Bundle().apply {
putString("folderId", folderId)
putInt("folderType", folderType)
putInt("page", page)
}
api?.executeAsync("getFolderList", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(p0: Bundle?) {
val json = p0?.getString(Keys.API_RETURN_KEY_DATA)
block(json)
}
})
}
fun getSongList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) {
val params = Bundle().apply {
putString("folderId", folderId)
putInt("folderType", folderType)
putInt("page", page)
}
api?.executeAsync("getSongList", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(p0: Bundle?) {
val json = p0?.getString(Keys.API_RETURN_KEY_DATA)
block(json)
}
})
}
fun getUserFolderList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) {
val params = Bundle().apply {
putString("openId", openId)
putString("openToken", openToken)
putString("folderId", folderId)
putInt("folderType", folderType)
putInt("page", page)
}
api?.executeAsync("getUserFolderList", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(p0: Bundle?) {
val json = p0?.getString(Keys.API_RETURN_KEY_DATA)
block(json)
}
})
}
fun getUserSongList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) {
val params = Bundle().apply {
putString("openId", openId)
putString("openToken", openToken)
putString("folderId", folderId)
putInt("folderType", folderType)
putInt("page", page)
}
api?.executeAsync("getUserSongList", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(p0: Bundle?) {
val json = p0?.getString(Keys.API_RETURN_KEY_DATA)
block(json)
}
})
}
fun search(api: IQQMusicApi?, block: () -> Unit) {
val params = Bundle().apply {
putString("keyword", "周杰伦")
putInt("searchType", 0)
putBoolean("firstPage", true)
}
api?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(p0: Bundle?) {
block()
}
})
}
fun voiceShortcut(api: IQQMusicApi?, intent: String, block: (code: Int?) -> Unit) {
val bundle = Bundle()
bundle.apply {
putString("intent", intent)
}
api?.executeAsync("voiceShortcut", bundle, object : IQQMusicApiCallback.Stub() {
override fun onReturn(p0: Bundle?) {
val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED
block(code)
}
})
}
fun voicePlay(api: IQQMusicApi?, query: String, slotList: ArrayList<String>, block: (code: Int, json: String?) -> Unit) {
val bundle = Bundle()
bundle.apply {
putString("query", query)
putStringArrayList("slotList", slotList)
}
api?.executeAsync("voicePlay", bundle, object : IQQMusicApiCallback.Stub() {
override fun onReturn(p0: Bundle?) {
val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED
val json = p0?.getString(Keys.API_RETURN_KEY_DATA)
block(code, json)
}
})
}
}

53
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;
}

240
local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicLoginManager.kt

@ -0,0 +1,240 @@
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 = "voitrans://"
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("APP_NOT_INSTALLED", "请先安装QQ音乐App才能使用音乐功能")
}
}
}
/**
* 检查授权状态
*/
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, "已授权,直接获取登录信息")
callback.onSuccess("","","")
// 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)
}
}
}

645
local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicManager.kt

@ -0,0 +1,645 @@
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.content.pm.PackageManager
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonParser
import com.tencent.qqmusic.third.api.contract.*
import android.os.Handler
import android.os.Looper
class QQMusicManager(private val activity: Activity) {
companion object {
const val TAG = "QQMusicManager"
}
private var qqmusicApi: IQQMusicApi? = null
private var isBindQQMusicService = false
private val gson = Gson()
// 回调接口
interface SearchCallback {
fun onSuccess(songs: List<Map<String, Any?>>)
fun onError(errorCode: Int, errorMessage: String)
}
interface PlayCallback {
fun onSuccess()
fun onError(errorCode: Int, errorMessage: String)
}
interface InfoCallback {
fun onSuccess(data: Map<String, Any?>)
fun onError(errorCode: Int, errorMessage: String)
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "QQ音乐服务连接成功")
qqmusicApi = IQQMusicApi.Stub.asInterface(service)
isBindQQMusicService = true
// 初始化SDK
CommonCmd.init(Config.BIND_PLATFORM)
// 延迟发送hi消息,确保Binder连接稳定
Handler(Looper.getMainLooper()).postDelayed({
sayHi()
}, 500) // 延迟500ms
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d(TAG, "QQ音乐服务连接断开")
qqmusicApi = null
isBindQQMusicService = false
}
}
init {
bindQQMusicApiService()
}
private fun sayHi() {
if (!isBindQQMusicService || qqmusicApi == null) {
Log.w(TAG, "QQ音乐服务未连接,无法发送hi消息")
return
}
try {
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))
} catch (e: Exception) {
Log.e(TAG, "发送hi消息失败", e)
// 如果是DeadObjectException,尝试重新绑定服务
if (e is android.os.DeadObjectException) {
Log.w(TAG, "检测到DeadObjectException,重新绑定服务")
qqmusicApi = null
isBindQQMusicService = false
// 延迟重新绑定
Handler(Looper.getMainLooper()).postDelayed({
bindQQMusicApiService()
}, 1000)
}
}
}
fun bindQQMusicApiService() {
Log.d(TAG, "开始绑定QQ音乐服务,平台类型: ${Config.BIND_PLATFORM}")
val intent = when (Config.BIND_PLATFORM) {
CommonCmd.AIDL_PLATFORM_TYPE_PHONE -> {
Intent("com.tencent.qqmusic.third.api.QQMusicApiService").apply {
`package` = "com.tencent.qqmusic"
}
}
CommonCmd.AIDL_PLATFORM_TYPE_CAR -> {
Intent("com.tencent.qqmusiccar.third.api.QQMusicApiService").apply {
`package` = "com.tencent.qqmusiccar"
}
}
CommonCmd.AIDL_PLATFORM_TYPE_TV -> {
Intent("com.tencent.qqmusictv.third.api.QQMusicApiService").apply {
`package` = "com.tencent.qqmusictv"
}
}
else -> {
Log.e(TAG, "不支持的平台类型: ${Config.BIND_PLATFORM}")
return
}
}
Log.d(TAG, "绑定Intent: action=${intent.action}, package=${intent.`package`}")
try {
val bindResult = activity.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
Log.d(TAG, "bindService结果: $bindResult")
if (!bindResult) {
Log.e(TAG, "绑定QQ音乐服务失败,尝试启动QQ音乐进程")
// 尝试启动QQ音乐进程
CommonCmd.startQQMusicProcess(activity, activity.packageName)
// 延迟重试绑定
Handler(Looper.getMainLooper()).postDelayed({
Log.d(TAG, "重试绑定QQ音乐服务")
activity.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}, 2000)
}
} catch (e: Exception) {
Log.e(TAG, "绑定QQ音乐服务异常", e)
}
}
/**
* 搜索歌曲
* @param keyword 搜索关键词
* @param searchType 搜索类型,参考Data.SearchType
* @param callback 回调
*/
fun searchSongs(
keyword: String,
searchType: Int = Data.SearchType.SEARCH_TYPE_SONG,
callback: SearchCallback
) {
Log.d(TAG, "searchSongs: keyword=$keyword, searchType=$searchType")
if (!isBindQQMusicService || qqmusicApi == null) {
Log.e(TAG, "QQ音乐服务未连接")
callback.onError(-1, "QQ音乐服务未连接")
return
}
// 先测试服务连接状态
try {
val testBundle = Bundle()
testBundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION)
testBundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, Config.BIND_PLATFORM)
val testResult = qqmusicApi?.execute("hi", testBundle)
val testCode = testResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
Log.d(TAG, "服务连接测试结果: $testCode")
if (testCode != ErrorCodes.ERROR_OK) {
Log.e(TAG, "QQ音乐服务连接异常: $testCode")
val testErrorMessage = when (testCode) {
ErrorCodes.ERROR_API_NO_PERMISSION -> "没有权限访问QQ音乐服务"
ErrorCodes.ERROR_API_NOT_INITIALIZED -> "QQ音乐API未初始化"
else -> "QQ音乐服务连接异常 (错误码: $testCode)"
}
callback.onError(testCode, testErrorMessage)
return
}
} catch (e: Exception) {
Log.e(TAG, "测试服务连接失败", e)
callback.onError(-1, "测试服务连接失败: ${e.message}")
return
}
val params = Bundle().apply {
putInt("searchType", searchType)
putString("keyword", keyword)
putBoolean("firstPage", true)
}
Log.d(TAG, "调用QQ音乐搜索API,参数: searchType=$searchType, keyword=$keyword")
try {
// 直接调用executeAsync,不接收返回值
qqmusicApi?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle?) {
Log.d(TAG, "搜索API返回结果")
if (result == null) {
Log.e(TAG, "搜索返回结果为null")
callback.onError(-1, "搜索返回结果为null")
return
}
val code = result.getInt(Keys.API_RETURN_KEY_CODE, -1)
Log.d(TAG, "搜索返回码: $code")
if (code == ErrorCodes.ERROR_OK) {
try {
val dataJson = result.getString(Keys.API_RETURN_KEY_DATA)
Log.d(TAG, "搜索数据: $dataJson")
if (dataJson != null && dataJson.isNotEmpty()) {
val array = JsonParser().parse(dataJson).asJsonArray
val songs = mutableListOf<Map<String, Any?>>()
for (elem in array) {
val song = gson.fromJson(elem, Data.Song::class.java)
songs.add(mapOf(
"mid" to song.mid,
"id" to song.id,
"name" to song.title,
"singer" to song.singer?.title,
"album" to song.album?.title,
"duration" to song.duration,
"pic" to song.album?.coverUri
))
}
Log.d(TAG, "解析完成,共 ${songs.size} 首歌曲")
callback.onSuccess(songs)
} else {
Log.e(TAG, "搜索结果数据为空")
callback.onError(code, "搜索结果数据为空")
}
} catch (e: Exception) {
Log.e(TAG, "解析搜索结果异常", e)
callback.onError(-1, "解析搜索结果失败: ${e.message}")
}
} else {
val error = result.getString(Keys.API_RETURN_KEY_ERROR) ?: "搜索失败"
Log.e(TAG, "搜索失败: code=$code, error=$error")
// 根据实际可用的错误码提供具体的错误信息
val errorMessage = when (code) {
ErrorCodes.ERROR_API_NO_PERMISSION -> "没有搜索权限,请检查应用授权"
ErrorCodes.ERROR_API_NOT_INITIALIZED -> "QQ音乐API未初始化,请重新登录"
else -> "搜索失败: $error (错误码: $code)"
}
callback.onError(code, errorMessage)
}
}
})
Log.d(TAG, "executeAsync调用完成,等待回调")
} catch (e: Exception) {
Log.e(TAG, "调用搜索API异常", e)
callback.onError(-1, "调用搜索API异常: ${e.message}")
}
}
/**
* 播放歌曲列表
* @param songMids 歌曲MID列表
* @param index 开始播放的索引
* @param callback 回调
*/
fun playSongs(
songMids: List<String>,
index: Int = 0,
callback: PlayCallback
) {
if (!isBindQQMusicService || qqmusicApi == null) {
callback.onError(-1, "QQ音乐服务未连接")
return
}
val params = Bundle().apply {
putStringArrayList("midList", ArrayList(songMids))
putInt("index", index)
}
// 直接调用executeAsync,不接收返回值
qqmusicApi?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() {
override fun onReturn(result: Bundle?) {
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
if (code == ErrorCodes.ERROR_OK) {
callback.onSuccess()
} else {
val error = result?.getString(Keys.API_RETURN_KEY_ERROR) ?: "播放失败"
callback.onError(code, error)
}
}
})
}
/**
* 播放音乐
*/
fun playMusic(callback: PlayCallback) {
if (!isBindQQMusicService || qqmusicApi == null) {
callback.onError(-1, "QQ音乐服务未连接")
return
}
val result = qqmusicApi?.execute("playMusic", null)
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
if (code == ErrorCodes.ERROR_OK) {
callback.onSuccess()
} else {
callback.onError(code, "播放失败")
}
}
/**
* 暂停音乐
*/
fun pauseMusic(callback: PlayCallback) {
if (!isBindQQMusicService || qqmusicApi == null) {
callback.onError(-1, "QQ音乐服务未连接")
return
}
val result = qqmusicApi?.execute("pauseMusic", null)
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
if (code == ErrorCodes.ERROR_OK) {
callback.onSuccess()
} else {
callback.onError(code, "暂停失败")
}
}
/**
* 恢复播放
*/
fun resumeMusic(callback: PlayCallback) {
if (!isBindQQMusicService || qqmusicApi == null) {
callback.onError(-1, "QQ音乐服务未连接")
return
}
val result = qqmusicApi?.execute("resumeMusic", null)
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
if (code == ErrorCodes.ERROR_OK) {
callback.onSuccess()
} else {
callback.onError(code, "恢复播放失败")
}
}
/**
* 停止音乐
*/
fun stopMusic(callback: PlayCallback) {
if (!isBindQQMusicService || qqmusicApi == null) {
callback.onError(-1, "QQ音乐服务未连接")
return
}
val result = qqmusicApi?.execute("stopMusic", null)
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
if (code == ErrorCodes.ERROR_OK) {
callback.onSuccess()
} else {
callback.onError(code, "停止失败")
}
}
/**
* 下一首
*/
fun skipToNext(callback: PlayCallback) {
if (!isBindQQMusicService || qqmusicApi == null) {
callback.onError(-1, "QQ音乐服务未连接")
return
}
val result = qqmusicApi?.execute("skipToNext", null)
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
if (code == ErrorCodes.ERROR_OK) {
callback.onSuccess()
} else {
callback.onError(code, "切换下一首失败")
}
}
/**
* 上一首
*/
fun skipToPrevious(callback: PlayCallback) {
if (!isBindQQMusicService || qqmusicApi == null) {
callback.onError(-1, "QQ音乐服务未连接")
return
}
val result = qqmusicApi?.execute("skipToPrevious", null)
val code = result?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
if (code == ErrorCodes.ERROR_OK) {
callback.onSuccess()
} else {
callback.onError(code, "切换上一首失败")
}
}
/**
* 同步当前播放信息 - 一次性获取所有播放状态
*/
fun syncCurrentPlayInfo(callback: InfoCallback) {
if (!isBindQQMusicService || qqmusicApi == null) {
Log.w(TAG, "QQ音乐服务未连接,无法同步播放信息")
callback.onError(-1, "QQ音乐服务未连接")
return
}
try {
Log.d(TAG, "开始同步当前播放信息")
// 获取当前歌曲信息
val songResult = qqmusicApi?.execute("getCurrentSong", null)
val songCode = songResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
// 获取播放状态
val stateResult = qqmusicApi?.execute("getPlaybackState", null)
val stateCode = stateResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
// 获取当前播放时间
val currTimeResult = qqmusicApi?.execute("getCurrTime", null)
val currTimeCode = currTimeResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
// 获取总时长
val totalTimeResult = qqmusicApi?.execute("getTotalTime", null)
val totalTimeCode = totalTimeResult?.getInt(Keys.API_RETURN_KEY_CODE) ?: -1
Log.d(TAG, "API调用结果 - song: $songCode, state: $stateCode, currTime: $currTimeCode, totalTime: $totalTimeCode")
// 构建返回数据
val syncData = mutableMapOf<String, Any?>()
// 处理歌曲信息
if (songCode == ErrorCodes.ERROR_OK) {
val songJson = songResult?.getString(Keys.API_RETURN_KEY_DATA)
if (!songJson.isNullOrEmpty()) {
try {
val jsonElement = JsonParser.parseString(songJson)
if (jsonElement.isJsonObject) {
val songData = jsonElement.asJsonObject
syncData["currentSong"] = mapOf(
"id" to (songData.get("id")?.asString ?: ""),
"mid" to (songData.get("mid")?.asString ?: ""),
"name" to (songData.get("title")?.asString ?: ""),
"singer" to (songData.get("singer")?.asJsonObject?.get("title")?.asString ?: ""),
"album" to (songData.get("album")?.asJsonObject?.get("title")?.asString ?: ""),
"duration" to (songData.get("duration")?.asLong ?: 0L),
"coverUri" to (songData.get("album")?.asJsonObject?.get("coverUri")?.asString ?: "")
)
Log.d(TAG, "歌曲信息同步成功: ${songData.get("title")?.asString}")
} else {
Log.w(TAG, "返回的JSON不是有效的对象格式: $songJson")
syncData["currentSong"] = null
}
} catch (e: Exception) {
Log.e(TAG, "解析歌曲信息失败", e)
syncData["currentSong"] = null
}
} else {
syncData["currentSong"] = null
}
} else {
syncData["currentSong"] = null
}
// 处理播放状态 - 使用正确的常量
if (stateCode == ErrorCodes.ERROR_OK) {
val playState = stateResult?.getInt(Keys.API_RETURN_KEY_DATA) ?: 0
val isPlaying = when (playState) {
PlayState.STARTED -> true
PlayState.PREPARED -> true
PlayState.BUFFERING -> true
PlayState.PAUSED -> false
PlayState.STOPPED -> false
PlayState.ERROR -> false
else -> false
}
syncData["isPlaying"] = isPlaying
syncData["playState"] = playState
Log.d(TAG, "播放状态同步成功: $playState (isPlaying: $isPlaying)")
} else {
syncData["isPlaying"] = false
syncData["playState"] = PlayState.STOPPED
}
// 处理播放时间
if (currTimeCode == ErrorCodes.ERROR_OK && totalTimeCode == ErrorCodes.ERROR_OK) {
val currentTime = (currTimeResult?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000
val totalTime = (totalTimeResult?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000
syncData["currentTime"] = currentTime
syncData["totalTime"] = totalTime
Log.d(TAG, "播放时间同步成功: ${currentTime}s / ${totalTime}s")
} else {
syncData["currentTime"] = 0L
syncData["totalTime"] = 0L
}
// 添加同步时间戳
syncData["syncTimestamp"] = System.currentTimeMillis()
Log.d(TAG, "播放信息同步完成")
callback.onSuccess(syncData)
} catch (e: Exception) {
Log.e(TAG, "同步播放信息异常", e)
callback.onError(-1, "同步播放信息异常: ${e.message}")
}
}
/**
* 获取播放状态
*/
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
Log.d(TAG, "QQ音乐服务已解绑")
}
} catch (e: Exception) {
Log.e(TAG, "解绑QQ音乐服务失败", e)
}
}
/**
* 获取当前平台对应的QQ音乐包名
*/
private fun getQQMusicPackageName(): String {
return when (Config.BIND_PLATFORM) {
CommonCmd.AIDL_PLATFORM_TYPE_PHONE -> "com.tencent.qqmusic"
CommonCmd.AIDL_PLATFORM_TYPE_CAR -> "com.tencent.qqmusiccar"
CommonCmd.AIDL_PLATFORM_TYPE_TV -> "com.tencent.qqmusictv"
else -> "com.tencent.qqmusic"
}
}
}

666
local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QQMusicSingleton.kt

@ -0,0 +1,666 @@
package com.yunqiinnovation.qq_music
import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.tencent.qqmusic.third.api.contract.*
import java.text.SimpleDateFormat
import java.util.*
/**
* QQ音乐单例管理器
* 提供统一的音乐播放接口,可以被Plugin和MCP Client共同使用
*/
class QQMusicSingleton private constructor() {
companion object {
private const val TAG = "QQMusicSingleton"
private const val PREF_NAME = "qq_music_login"
private const val KEY_OPEN_ID = "open_id"
private const val KEY_OPEN_TOKEN = "open_token"
private const val KEY_EXPIRE_TIME = "expire_time"
private const val LOGIN_WAIT_DELAY_MS = 30000L
// 添加搜索重试相关常量
private const val SEARCH_TIMEOUT_MS = 50000L
private const val MAX_RETRY_COUNT = 3
private const val RETRY_DELAY_MS = 1000L
@Volatile
private var INSTANCE: QQMusicSingleton? = null
fun getInstance(): QQMusicSingleton {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: QQMusicSingleton().also { INSTANCE = it }
}
}
}
private var activity: Activity? = null
private var loginManager: QQMusicLoginManager? = null
private var musicManager: QQMusicManager? = null
private val handler = Handler(Looper.getMainLooper())
private var sharedPreferences: SharedPreferences? = null
private var currentLoginResult: LoginResult? = null
// 添加搜索超时跟踪
private val searchTimeouts = mutableMapOf<String, Runnable>()
/**
* 初始化单例,设置Activity上下文
*/
fun initialize(activity: Activity) {
this.activity = activity
this.sharedPreferences = activity.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
loadSavedLoginInfo()
Log.d(TAG, "QQ音乐单例初始化完成")
}
/**
* 检查QQ音乐App是否已安装
*/
private fun isQQMusicInstalled(): Boolean {
val packageName = when (Config.BIND_PLATFORM) {
CommonCmd.AIDL_PLATFORM_TYPE_PHONE -> "com.tencent.qqmusic"
CommonCmd.AIDL_PLATFORM_TYPE_CAR -> "com.tencent.qqmusiccar"
CommonCmd.AIDL_PLATFORM_TYPE_TV -> "com.tencent.qqmusictv"
else -> {
Log.e(TAG, "不支持的平台类型: ${Config.BIND_PLATFORM}")
return false
}
}
return try {
this.activity?.packageManager?.getPackageInfo(packageName, 0)
Log.d(TAG, "QQ音乐app已安装: $packageName")
true
} catch (e: PackageManager.NameNotFoundException) {
Log.w(TAG, "QQ音乐app未安装: $packageName")
false
} catch (e: Exception) {
Log.e(TAG, "检查QQ音乐app安装状态失败", e)
false
}
}
/**
* 检查是否已登录且未过期
*/
fun isLoggedIn(): Boolean {
if (currentLoginResult == null){
loadSavedLoginInfo()
if (currentLoginResult == null){
return false
}
return true
}else{
return true
}
}
/**
* 获取当前登录信息
*/
fun getCurrentLoginInfo(): LoginResult? {
return if (isLoggedIn()) currentLoginResult else null
}
/**
* 登录接口
*/
fun login(callback: (Result<LoginResult>) -> Unit) {
if (!isQQMusicInstalled()){
callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件")))
return
}
val currentActivity = activity
// 先检查是否已经登录且未过期
if (isLoggedIn()) {
Log.d(TAG, "用户已登录且未过期,直接返回登录信息")
ensureMusicManager(currentActivity!!)
callback(Result.success(currentLoginResult!!))
return
}
if (currentActivity == null) {
callback(Result.failure(Exception("Activity not available")))
return
}
Log.d(TAG, "开始新的登录流程")
loginManager = QQMusicLoginManager(currentActivity)
loginManager?.startLogin(object : QQMusicLoginManager.LoginCallback {
override fun onSuccess(openId: String, openToken: String, expireTime: String) {
handler.post {
ensureMusicManager(currentActivity!!)
val result = LoginResult(openId, openToken, expireTime)
currentLoginResult = result
saveLoginInfo(result)
Log.d(TAG, "登录成功并已保存: $result")
callback(Result.success(result))
}
}
override fun onError(errorCode: String, errorMessage: String) {
handler.post {
Log.e(TAG, "登录失败: $errorCode - $errorMessage")
callback(Result.failure(Exception("$errorCode: $errorMessage")))
}
}
})
}
/**
* 登录接口
*/
fun autologin(callback: (Result<LoginResult>) -> Unit) {
if (!isQQMusicInstalled()){
callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件")))
return
}
val currentActivity = activity
// 先检查是否已经登录且未过期
if (isLoggedIn()) {
Log.d(TAG, "用户已登录且未过期,直接返回登录信息")
ensureMusicManager(currentActivity!!)
callback(Result.success(currentLoginResult!!))
return
}else{
callback(Result.failure(Exception("no logined")))
return
}
}
/**
* 登出
*/
fun logout() {
currentLoginResult = null
clearSavedLoginInfo()
Log.d(TAG, "用户已登出,清除本地登录信息")
}
/**
* 搜索歌曲(带自动登录检查和重试机制)
*/
fun searchSongs(
keyword: String,
searchType: Int = Data.SearchType.SEARCH_TYPE_SONG,
callback: (Result<List<Map<String, Any?>>>) -> Unit
) {
if (!isQQMusicInstalled()){
callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件")))
return
}
searchWithRetry(keyword, searchType, 0, callback)
}
/**
* 带重试的搜索实现
*/
private fun searchWithRetry(
keyword: String,
searchType: Int,
retryCount: Int,
callback: (Result<List<Map<String, Any?>>>) -> Unit
) {
val currentActivity = activity
if (currentActivity == null) {
callback(Result.failure(Exception("Activity not available")))
return
}
// 检查登录状态
if (!isLoggedIn()) {
Log.d(TAG, "搜索前检测到未登录,开始自动登录")
login { loginResult ->
if (loginResult.isSuccess) {
Log.d(TAG, "自动登录成功,等待QQ音乐服务连接...")
handler.postDelayed({
Log.d(TAG, "等待完成,开始搜索")
performSearchWithTimeout(keyword, searchType, retryCount, callback)
}, LOGIN_WAIT_DELAY_MS)
} else {
callback(Result.failure(Exception("自动登录失败: ${loginResult.exceptionOrNull()?.message}")))
}
}
} else {
performSearchWithTimeout(keyword, searchType, retryCount, callback)
}
}
/**
* 执行带超时的搜索操作
*/
private fun performSearchWithTimeout(
keyword: String,
searchType: Int,
retryCount: Int,
callback: (Result<List<Map<String, Any?>>>) -> Unit
) {
val currentActivity = activity ?: return
val searchKey = "${keyword}_${System.currentTimeMillis()}"
var completed = false
Log.d(TAG, "开始搜索: $keyword (第${retryCount + 1}次)")
// 设置超时
val timeoutRunnable = Runnable {
if (!completed) {
completed = true
searchTimeouts.remove(searchKey)
Log.w(TAG, "搜索超时: $keyword")
if (retryCount < MAX_RETRY_COUNT) {
Log.d(TAG, "准备重试: ${retryCount + 1}/$MAX_RETRY_COUNT")
handler.postDelayed({
searchWithRetry(keyword, searchType, retryCount + 1, callback)
}, RETRY_DELAY_MS)
} else {
callback(Result.failure(Exception("搜索超时,已重试${MAX_RETRY_COUNT}次")))
}
}
}
searchTimeouts[searchKey] = timeoutRunnable
handler.postDelayed(timeoutRunnable, SEARCH_TIMEOUT_MS)
ensureMusicManager(currentActivity)
musicManager?.searchSongs(keyword, searchType, object : QQMusicManager.SearchCallback {
override fun onSuccess(songs: List<Map<String, Any?>>) {
handler.post {
if (!completed) {
completed = true
searchTimeouts.remove(searchKey)?.let { handler.removeCallbacks(it) }
Log.d(TAG, "搜索成功: 找到 ${songs.size} 首歌曲")
callback(Result.success(songs))
}
}
}
override fun onError(errorCode: Int, errorMessage: String) {
handler.post {
if (!completed) {
completed = true
searchTimeouts.remove(searchKey)?.let { handler.removeCallbacks(it) }
Log.e(TAG, "搜索失败: code=$errorCode, message=$errorMessage")
// 如果是登录相关错误,清除本地登录信息
if (errorMessage.contains("登录") || errorMessage.contains("授权")) {
Log.d(TAG, "检测到登录相关错误,清除本地登录信息")
logout()
}
// 网络相关错误才重试
if (retryCount < MAX_RETRY_COUNT &&
(errorMessage.contains("网络") || errorMessage.contains("超时") ||
errorMessage.contains("连接") || errorCode == -1)) {
Log.d(TAG, "网络错误,准备重试: ${retryCount + 1}/$MAX_RETRY_COUNT")
handler.postDelayed({
searchWithRetry(keyword, searchType, retryCount + 1, callback)
}, RETRY_DELAY_MS)
} else {
callback(Result.failure(Exception("搜索失败: $errorMessage")))
}
}
}
}
})
}
/**
* 播放歌曲列表(带自动登录检查)
*/
fun playSongs(
songMids: List<String>,
index: Int = 0,
callback: (Result<Unit>) -> Unit
) {
if (!isQQMusicInstalled()){
callback(Result.failure(Exception("未安装QQ音乐软件,请先安装QQ音乐软件")))
return
}
playWithRetry(songMids, index, callback)
}
/**
* 带自动登录检查的播放实现
*/
private fun playWithRetry(
songMids: List<String>,
index: Int,
callback: (Result<Unit>) -> Unit
) {
val currentActivity = activity
if (currentActivity == null) {
callback(Result.failure(Exception("Activity not available")))
return
}
// 检查登录状态
if (!isLoggedIn()) {
Log.d(TAG, "播放前检测到未登录,开始自动登录")
login { loginResult ->
if (loginResult.isSuccess) {
Log.d(TAG, "自动登录成功,等待QQ音乐服务连接...")
handler.postDelayed({
Log.d(TAG, "等待完成,开始播放")
performPlaySongs(songMids, index, callback)
}, LOGIN_WAIT_DELAY_MS)
} else {
callback(Result.failure(Exception("自动登录失败: ${loginResult.exceptionOrNull()?.message}")))
}
}
} else {
performPlaySongs(songMids, index, callback)
}
}
/**
* 执行播放歌曲列表操作
*/
private fun performPlaySongs(
songMids: List<String>,
index: Int,
callback: (Result<Unit>) -> Unit
) {
val currentActivity = activity ?: return
ensureMusicManager(currentActivity)
musicManager?.playSongs(songMids, index, object : QQMusicManager.PlayCallback {
override fun onSuccess() {
handler.post {
Log.d(TAG, "播放歌曲列表成功")
callback(Result.success(Unit))
}
}
override fun onError(errorCode: Int, errorMessage: String) {
handler.post {
Log.e(TAG, "播放失败: $errorCode $errorMessage")
if (errorCode == -1){
musicManager?.bindQQMusicApiService()
callback(Result.failure(Exception("播放失败: $errorMessage")))
} else if (errorCode == 1030) {
callback(Result.success(Unit))
}else{
callback(Result.failure(Exception("播放失败: $errorMessage")))
}
}
}
})
}
/**
* 播放音乐
*/
fun playMusic(callback: (Result<Unit>) -> Unit) {
executePlaybackAction("播放") { musicManager, playCallback ->
musicManager.playMusic(playCallback)
}(callback)
}
/**
* 暂停音乐
*/
fun pauseMusic(callback: (Result<Unit>) -> Unit) {
executePlaybackAction("暂停") { musicManager, playCallback ->
musicManager.pauseMusic(playCallback)
}(callback)
}
/**
* 恢复播放
*/
fun resumeMusic(callback: (Result<Unit>) -> Unit) {
executePlaybackAction("恢复播放") { musicManager, playCallback ->
musicManager.resumeMusic(playCallback)
}(callback)
}
/**
* 停止音乐
*/
fun stopMusic(callback: (Result<Unit>) -> Unit) {
executePlaybackAction("停止") { musicManager, playCallback ->
musicManager.stopMusic(playCallback)
}(callback)
}
/**
* 下一首
*/
fun skipToNext(callback: (Result<Unit>) -> Unit) {
executePlaybackAction("下一首") { musicManager, playCallback ->
musicManager.skipToNext(playCallback)
}(callback)
}
/**
* 上一首
*/
fun skipToPrevious(callback: (Result<Unit>) -> Unit) {
executePlaybackAction("上一首") { musicManager, playCallback ->
musicManager.skipToPrevious(playCallback)
}(callback)
}
/**
* 获取播放状态
*/
fun getPlaybackState(callback: (Result<Map<String, Any?>>) -> Unit) {
executeInfoAction("获取播放状态") { musicManager, infoCallback ->
musicManager.getPlaybackState(infoCallback)
}(callback)
}
/**
* 获取当前歌曲信息
*/
fun getCurrentSong(callback: (Result<Map<String, Any?>>) -> Unit) {
executeInfoAction("获取当前歌曲") { musicManager, infoCallback ->
musicManager.getCurrentSong(infoCallback)
}(callback)
}
/**
* 获取播放时间
*/
fun getPlayTime(callback: (Result<PlayTimeInfo>) -> Unit) {
val currentActivity = activity
if (currentActivity == null) {
callback(Result.failure(Exception("Activity not available")))
return
}
ensureMusicManager(currentActivity)
musicManager?.getPlayTime(object : QQMusicManager.InfoCallback {
override fun onSuccess(data: Map<String, Any?>) {
handler.post {
val totalTime = data["totalTime"] as? Long ?: 0L
val currentTime = data["currentTime"] as? Long ?: 0L
val playTimeInfo = PlayTimeInfo(totalTime, currentTime)
Log.d(TAG, "获取播放时间成功: $playTimeInfo")
callback(Result.success(playTimeInfo))
}
}
override fun onError(errorCode: Int, errorMessage: String) {
handler.post {
Log.e(TAG, "获取播放时间失败: $errorMessage")
callback(Result.failure(Exception("获取播放时间失败: $errorMessage")))
}
}
})
}
/**
* 同步当前播放信息 - 统一接口
*/
fun syncCurrentPlayInfo(callback: (Result<Map<String, Any?>>) -> Unit) {
val currentActivity = activity
if (currentActivity == null) {
callback(Result.failure(Exception("Activity not available")))
return
}
ensureMusicManager(currentActivity)
musicManager?.syncCurrentPlayInfo(object : QQMusicManager.InfoCallback {
override fun onSuccess(data: Map<String, Any?>) {
handler.post {
Log.d(TAG, "同步播放信息成功")
callback(Result.success(data))
}
}
override fun onError(errorCode: Int, errorMessage: String) {
handler.post {
Log.e(TAG, "同步播放信息失败: $errorMessage")
callback(Result.failure(Exception("同步播放信息失败: $errorMessage")))
}
}
})
}
/**
* 清理资源
*/
fun cleanup() {
// 清理搜索超时任务
searchTimeouts.values.forEach { handler.removeCallbacks(it) }
searchTimeouts.clear()
// 清理管理器
loginManager?.cleanup()
loginManager = null
musicManager?.cleanup()
musicManager = null
activity = null
Log.d(TAG, "QQ音乐单例资源清理完成")
}
/**
* 保存登录信息到本地
*/
private fun saveLoginInfo(loginResult: LoginResult) {
sharedPreferences?.edit()?.apply {
putString(KEY_OPEN_ID, loginResult.openId)
putString(KEY_OPEN_TOKEN, loginResult.openToken)
putString(KEY_EXPIRE_TIME, loginResult.expireTime)
apply()
}
Log.d(TAG, "登录信息已保存到本地")
}
/**
* 从本地加载登录信息
*/
private fun loadSavedLoginInfo() {
val prefs = sharedPreferences ?: return
val openId = prefs.getString(KEY_OPEN_ID, null)
val openToken = prefs.getString(KEY_OPEN_TOKEN, null)
val expireTime = prefs.getString(KEY_EXPIRE_TIME, null)
if (openId != null && openToken != null && expireTime != null) {
currentLoginResult = LoginResult(openId, openToken, expireTime)
Log.d(TAG, "从本地加载登录信息成功")
} else {
Log.d(TAG, "本地无保存的登录信息")
}
}
/**
* 清除本地保存的登录信息
*/
private fun clearSavedLoginInfo() {
sharedPreferences?.edit()?.apply {
remove(KEY_OPEN_ID)
remove(KEY_OPEN_TOKEN)
remove(KEY_EXPIRE_TIME)
apply()
}
Log.d(TAG, "本地登录信息已清除")
}
// 私有辅助方法
private fun ensureMusicManager(activity: Activity) {
if (musicManager == null) {
musicManager = QQMusicManager(activity)
}
}
private fun executePlaybackAction(
actionName: String,
action: (QQMusicManager, QQMusicManager.PlayCallback) -> Unit
): (callback: (Result<Unit>) -> Unit) -> Unit {
return lambda@{ callback ->
val currentActivity = activity
if (currentActivity == null) {
callback(Result.failure(Exception("Activity not available")))
return@lambda
}
ensureMusicManager(currentActivity)
action(musicManager!!, object : QQMusicManager.PlayCallback {
override fun onSuccess() {
handler.post {
Log.d(TAG, "${actionName}成功")
callback(Result.success(Unit))
}
}
override fun onError(errorCode: Int, errorMessage: String) {
handler.post {
Log.e(TAG, "${actionName}失败: $errorMessage")
callback(Result.failure(Exception("${actionName}失败: $errorMessage")))
}
}
})
}
}
private fun executeInfoAction(
actionName: String,
action: (QQMusicManager, QQMusicManager.InfoCallback) -> Unit
): (callback: (Result<Map<String, Any?>>) -> Unit) -> Unit {
return lambda@{ callback ->
val currentActivity = activity
if (currentActivity == null) {
callback(Result.failure(Exception("Activity not available")))
return@lambda
}
ensureMusicManager(currentActivity)
action(musicManager!!, object : QQMusicManager.InfoCallback {
override fun onSuccess(data: Map<String, Any?>) {
handler.post {
Log.d(TAG, "${actionName}成功")
callback(Result.success(data))
}
}
override fun onError(errorCode: Int, errorMessage: String) {
handler.post {
Log.e(TAG, "${actionName}失败: $errorMessage")
callback(Result.failure(Exception("${actionName}失败: $errorMessage")))
}
}
})
}
}
// 数据类定义
data class LoginResult(
val openId: String,
val openToken: String,
val expireTime: String
)
data class PlayTimeInfo(
val totalTime: Long,
val currentTime: Long
)
}

245
local_plugins/qq_music/android/src/main/kotlin/com/yunqiinnovation/qq_music/QqMusicPlugin.kt

@ -0,0 +1,245 @@
package com.yunqiinnovation.qq_music
import android.app.Activity
import android.util.Log
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 val qqMusicSingleton = QQMusicSingleton.getInstance()
private var isRegistered = false
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
if (isRegistered) {
Log.w("QqMusicPlugin", "Plugin already registered, skipping")
return
}
Log.d("QqMusicPlugin", "onAttachedToEngine called")
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.qq_music")
channel.setMethodCallHandler(this)
isRegistered = true
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Log.d("QqMusicPlugin", "onDetachedFromEngine called")
if (::channel.isInitialized) {
channel.setMethodCallHandler(null)
}
isRegistered = false
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
Log.d("QqMusicPlugin", "onAttachedToActivity called")
activity = binding.activity
println("QQ音乐 onAttachedToActivity")
qqMusicSingleton.initialize(activity!!)
Log.d("QqMusicPlugin", "qqMusicSingleton.initialize completed")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
Log.d("QqMusicPlugin", "onReattachedToActivityForConfigChanges called")
activity = binding.activity
// qqMusicSingleton.initialize(activity!!)
}
override fun onDetachedFromActivityForConfigChanges() {
Log.d("QqMusicPlugin", "onDetachedFromActivityForConfigChanges called")
activity = null
}
override fun onDetachedFromActivity() {
Log.d("QqMusicPlugin", "onDetachedFromActivity called")
activity = null
qqMusicSingleton.cleanup()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"login" -> {
qqMusicSingleton.autologin { kotlinResult ->
kotlinResult.fold(
onSuccess = { loginResult ->
result.success(mapOf(
"success" to true,
"openId" to loginResult.openId,
"openToken" to loginResult.openToken,
"expireTime" to loginResult.expireTime
))
},
onFailure = { error ->
result.error("LOGIN_FAILED", error.message, null)
}
)
}
}
"searchSongs" -> {
val keyword = call.argument<String>("keyword")
val searchType = call.argument<Int>("searchType") ?: Data.SearchType.SEARCH_TYPE_SONG
if (keyword == null) {
result.error("INVALID_ARGUMENT", "keyword is required", null)
return
}
qqMusicSingleton.searchSongs(keyword, searchType) { kotlinResult ->
kotlinResult.fold(
onSuccess = { songs ->
result.success(mapOf(
"success" to true,
"songs" to songs
))
},
onFailure = { error ->
result.error("SEARCH_FAILED", error.message, null)
}
)
}
}
"playSongs" -> {
val songMids = call.argument<List<String>>("songMids")
val index = call.argument<Int>("index") ?: 0
if (songMids == null || songMids.isEmpty()) {
result.error("INVALID_ARGUMENT", "songMids is required", null)
return
}
qqMusicSingleton.playSongs(songMids, index) { kotlinResult ->
kotlinResult.fold(
onSuccess = {
result.success(mapOf("success" to true))
},
onFailure = { error ->
result.error("PLAY_FAILED", error.message, null)
}
)
}
}
"playMusic" -> {
qqMusicSingleton.playMusic { kotlinResult ->
kotlinResult.fold(
onSuccess = { result.success(mapOf("success" to true)) },
onFailure = { error -> result.error("PLAY_FAILED", error.message, null) }
)
}
}
"pauseMusic" -> {
qqMusicSingleton.pauseMusic { kotlinResult ->
kotlinResult.fold(
onSuccess = { result.success(mapOf("success" to true)) },
onFailure = { error -> result.error("PAUSE_FAILED", error.message, null) }
)
}
}
"resumeMusic" -> {
qqMusicSingleton.resumeMusic { kotlinResult ->
kotlinResult.fold(
onSuccess = { result.success(mapOf("success" to true)) },
onFailure = { error -> result.error("RESUME_FAILED", error.message, null) }
)
}
}
"stopMusic" -> {
qqMusicSingleton.stopMusic { kotlinResult ->
kotlinResult.fold(
onSuccess = { result.success(mapOf("success" to true)) },
onFailure = { error -> result.error("STOP_FAILED", error.message, null) }
)
}
}
"skipToNext" -> {
qqMusicSingleton.skipToNext { kotlinResult ->
kotlinResult.fold(
onSuccess = { result.success(mapOf("success" to true)) },
onFailure = { error -> result.error("SKIP_FAILED", error.message, null) }
)
}
}
"skipToPrevious" -> {
qqMusicSingleton.skipToPrevious { kotlinResult ->
kotlinResult.fold(
onSuccess = { result.success(mapOf("success" to true)) },
onFailure = { error -> result.error("SKIP_FAILED", error.message, null) }
)
}
}
"getPlaybackState" -> {
qqMusicSingleton.getPlaybackState { kotlinResult ->
kotlinResult.fold(
onSuccess = { data ->
result.success(mapOf(
"success" to true,
"state" to data["state"]
))
},
onFailure = { error ->
result.error("GET_STATE_FAILED", error.message, null)
}
)
}
}
"getCurrentSong" -> {
qqMusicSingleton.getCurrentSong { kotlinResult ->
kotlinResult.fold(
onSuccess = { data ->
result.success(mapOf(
"success" to true,
"song" to data
))
},
onFailure = { error ->
result.error("GET_SONG_FAILED", error.message, null)
}
)
}
}
"getPlayTime" -> {
qqMusicSingleton.getPlayTime { kotlinResult ->
kotlinResult.fold(
onSuccess = { playTimeInfo ->
result.success(mapOf(
"success" to true,
"totalTime" to playTimeInfo.totalTime,
"currentTime" to playTimeInfo.currentTime
))
},
onFailure = { error ->
result.error("GET_TIME_FAILED", error.message, null)
}
)
}
}
"syncCurrentPlayInfo" -> {
qqMusicSingleton.syncCurrentPlayInfo { kotlinResult ->
kotlinResult.fold(
onSuccess = { data ->
result.success(mapOf(
"success" to true,
"data" to data
))
},
onFailure = { error ->
result.error("SYNC_FAILED", error.message, null)
}
)
}
}
"getPlatformVersion" -> {
result.success("Android ${android.os.Build.VERSION.RELEASE}")
}
else -> {
result.notImplemented()
}
}
}
}

64
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, "voitrans://");
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;
}
}

314
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<String, Object> genKeyPair() throws Exception {
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM);
keyPairGen.initialize(1024);
KeyPair keyPair = keyPairGen.generateKeyPair();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
Map<String, Object> keyMap = new HashMap<>(2);
keyMap.put(PUBLIC_KEY, publicKey);
keyMap.put(PRIVATE_KEY, privateKey);
return keyMap;
}
/**
* 用私钥对信息生成数字签名
*
* @param data 已加密数据
* @param privateKey 私钥(BASE64编码)
* @return
* @throws Exception
*/
public static String sign(byte[] data, String privateKey) throws Exception {
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT);
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PrivateKey privateK = keyFactory.generatePrivate(pkcs8KeySpec);
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
signature.initSign(privateK);
signature.update(data);
return Base64.encodeToString(signature.sign(), Base64.DEFAULT);
}
/**
* 校验数字签名
*
* @param data 已加密数据
* @param publicKey 公钥(BASE64编码)
* @param sign 数字签名
* @return
* @throws Exception
*/
public static boolean verify(byte[] data, String publicKey, String sign)
throws Exception {
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PublicKey publicK = keyFactory.generatePublic(keySpec);
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
signature.initVerify(publicK);
signature.update(data);
return signature.verify(Base64.decode(sign, Base64.DEFAULT));
}
/**
* 私钥解密
*
* @param encryptedData 已加密数据
* @param privateKey 私钥(BASE64编码)
* @return
* @throws Exception
*/
public static byte[] decryptByPrivateKey(byte[] encryptedData, String privateKey)
throws Exception {
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT);
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec);
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
Cipher cipher = Cipher.getInstance(CIHPER_KEY);
cipher.init(Cipher.DECRYPT_MODE, privateK);
int inputLen = encryptedData.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段解密
while (inputLen - offSet > 0) {
if (inputLen - offSet > MAX_DECRYPT_BLOCK) {
cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK);
} else {
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * MAX_DECRYPT_BLOCK;
}
byte[] decryptedData = out.toByteArray();
out.close();
return decryptedData;
}
public static String testDecryptByJavaPrivKey(String privkey_from_java, String encryptedString) throws Exception {
//加解密类
Cipher cipher = Cipher.getInstance("RSA");//Cipher.getInstance("RSA/ECB/PKCS1Padding");
// G0语言通过java产生的公钥加密得到的加密后的字符串
byte[] enBytes = Base64.decode(encryptedString, Base64.DEFAULT);
//通过密钥字符串得到密钥
PrivateKey privateKey = testGetPrivateKey(privkey_from_java);
//解密
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] deBytes = cipher.doFinal(enBytes);
return new String(deBytes);
}
public static PrivateKey testGetPrivateKey(String key) throws Exception {
byte[] keyBytes;
keyBytes = Base64.decode(key, Base64.DEFAULT);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
/**
* 公钥解密
*
* @param encryptedData 已加密数据
* @param publicKey 公钥(BASE64编码)
* @return
* @throws Exception
*/
public static byte[] decryptByPublicKey(byte[] encryptedData, String publicKey)
throws Exception {
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT);
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key publicK = keyFactory.generatePublic(x509KeySpec);
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
Cipher cipher = Cipher.getInstance(CIHPER_KEY);
cipher.init(Cipher.DECRYPT_MODE, publicK);
int inputLen = encryptedData.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段解密
while (inputLen - offSet > 0) {
if (inputLen - offSet > MAX_DECRYPT_BLOCK) {
cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK);
} else {
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * MAX_DECRYPT_BLOCK;
}
byte[] decryptedData = out.toByteArray();
out.close();
return decryptedData;
}
/**
* 公钥加密
*
* @param data 源数据
* @param publicKey 公钥(BASE64编码)
* @return
* @throws Exception
*/
public static byte[] encryptByPublicKey(byte[] data, String publicKey)
throws Exception {
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT);
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key publicK = keyFactory.generatePublic(x509KeySpec);
// 对数据加密
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
Cipher cipher = Cipher.getInstance(CIHPER_KEY);
cipher.init(Cipher.ENCRYPT_MODE, publicK);
int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段加密
while (inputLen - offSet > 0) {
if (inputLen - offSet > MAX_ENCRYPT_BLOCK) {
cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK);
} else {
cache = cipher.doFinal(data, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * MAX_ENCRYPT_BLOCK;
}
byte[] encryptedData = out.toByteArray();
out.close();
return encryptedData;
}
/**
* 私钥加密
*
* @param data 源数据
* @param privateKey 私钥(BASE64编码)
* @return
* @throws Exception
*/
public static byte[] encryptByPrivateKey(byte[] data, String privateKey)
throws Exception {
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT);
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec);
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
Cipher cipher = Cipher.getInstance(CIHPER_KEY);
cipher.init(Cipher.ENCRYPT_MODE, privateK);
int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段加密
while (inputLen - offSet > 0) {
if (inputLen - offSet > MAX_ENCRYPT_BLOCK) {
cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK);
} else {
cache = cipher.doFinal(data, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * MAX_ENCRYPT_BLOCK;
}
byte[] encryptedData = out.toByteArray();
out.close();
return encryptedData;
}
/**
* 获取私钥
*
* @param keyMap 密钥对
* @return
*/
public static String getPrivateKey(Map<String, Object> keyMap) {
Key key = (Key) keyMap.get(PRIVATE_KEY);
return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT);
}
/**
* 获取公钥
*
* @param keyMap 密钥对
* @return
*/
public static String getPublicKey(Map<String, Object> keyMap) {
Key key = (Key) keyMap.get(PUBLIC_KEY);
return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT);
}
}

253
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)
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save