153 changed files with 15367 additions and 4308 deletions
@ -1,4 +0,0 @@ |
|||||
storePassword=13071376716 |
|
||||
keyPassword=13071376716 |
|
||||
keyAlias=DeepSound |
|
||||
storeFile=sign/DeepSound.keystore |
|
||||
|
After Width: | Height: | Size: 58 KiB |
@ -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 |
||||
|
|
||||
|
``` |
||||
|
[32m[✓][39m Flutter (Channel stable, 3.32.3, on macOS 15.5 24F74 darwin-arm64, locale zh-Hans-CN) [429ms] |
||||
|
[32m•[39m Flutter version 3.32.3 on channel stable at /Users/liwei/flutter |
||||
|
[32m•[39m Upstream repository https://github.com/flutter/flutter.git |
||||
|
[32m•[39m Framework revision 5c1433509f (9 weeks ago), 2025-06-11 13:12:24 -0700 |
||||
|
[32m•[39m Engine revision 31c4875c7a |
||||
|
[32m•[39m Dart version 3.8.1 |
||||
|
[32m•[39m DevTools version 2.45.1 |
||||
|
|
||||
|
[33m[!][39m Android toolchain - develop for Android devices (Android SDK version 36.0.0) [2.8s] |
||||
|
[32m•[39m Android SDK at /Users/liwei/Library/Android/sdk |
||||
|
[32m•[39m Platform android-36, build-tools 36.0.0 |
||||
|
[32m•[39m 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"`. |
||||
|
[32m•[39m Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1) |
||||
|
[33m![39m Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses |
||||
|
|
||||
|
[32m[✓][39m Xcode - develop for iOS and macOS (Xcode 16.2) [1,682ms] |
||||
|
[32m•[39m Xcode at /Applications/Xcode.app/Contents/Developer |
||||
|
[32m•[39m Build 16C5032a |
||||
|
[32m•[39m CocoaPods version 1.16.2 |
||||
|
|
||||
|
[32m[✓][39m Chrome - develop for the web [6ms] |
||||
|
[32m•[39m Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome |
||||
|
|
||||
|
[32m[✓][39m Android Studio (version 2024.2) [5ms] |
||||
|
[32m•[39m Android Studio at /Applications/Android Studio.app/Contents |
||||
|
[32m•[39m Flutter plugin can be installed from: |
||||
|
🔨 https://plugins.jetbrains.com/plugin/9212-flutter |
||||
|
[32m•[39m Dart plugin can be installed from: |
||||
|
🔨 https://plugins.jetbrains.com/plugin/6351-dart |
||||
|
[32m•[39m Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1) |
||||
|
|
||||
|
[32m[✓][39m VS Code (version 1.103.0) [4ms] |
||||
|
[32m•[39m VS Code at /Applications/Visual Studio Code.app/Contents |
||||
|
[32m•[39m Flutter extension version 3.116.0 |
||||
|
|
||||
|
[32m[✓][39m Connected device (5 available) [9.4s] |
||||
|
[32m•[39m RMX3310 (mobile) • a709b98b • android-arm64 • Android 14 (API 34) |
||||
|
[32m•[39m Voitrans的iPhoneXr (wireless) (mobile) • 00008020-000175D23C03002E • ios • iOS 18.6 22G86 |
||||
|
[32m•[39m iPhone XR (wireless) (mobile) • 00008020-000140140260802E • ios • iOS 18.5 22F76 |
||||
|
[32m•[39m macOS (desktop) • macos • darwin-arm64 • macOS 15.5 24F74 darwin-arm64 |
||||
|
[32m•[39m Chrome (web) • chrome • web-javascript • Google Chrome 139.0.7258.67 |
||||
|
[33m![39m 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) |
||||
|
[33m![39m 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) |
||||
|
|
||||
|
[32m[✓][39m Network resources [5.1s] |
||||
|
[32m•[39m All expected network resources are available. |
||||
|
|
||||
|
[33m![39m Doctor found issues in 1 category. |
||||
|
``` |
||||
@ -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; |
||||
|
} |
||||
@ -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()}'}; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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}'); |
||||
|
} |
||||
|
} |
||||
@ -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')}'; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/qqmusic_controller.dart'; |
||||
|
|
||||
|
class QQMusicBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<QQMusicController>(() => QQMusicController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,326 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:qq_music/qq_music.dart'; |
||||
|
import 'package:voitrans/data/services/qq_musoc.dart'; |
||||
|
import '../models/song_model.dart'; |
||||
|
|
||||
|
class QQMusicController extends GetxController { |
||||
|
final QqMusocService _qqMusic = Get.find<QqMusocService>(); |
||||
|
|
||||
|
// 登录状态 |
||||
|
final isLoggedIn = false.obs; |
||||
|
final userInfo = Rxn<QQMusicLoginResult>(); |
||||
|
|
||||
|
// 搜索相关 |
||||
|
final searchResults = <SongModel>[].obs; |
||||
|
final isSearching = false.obs; |
||||
|
final searchKeyword = ''.obs; |
||||
|
|
||||
|
// 播放相关 |
||||
|
final currentSong = Rxn<SongModel>(); |
||||
|
final isPlaying = false.obs; |
||||
|
final currentPosition = 0.obs; |
||||
|
final totalDuration = 0.obs; |
||||
|
final playlist = <SongModel>[].obs; |
||||
|
final currentIndex = 0.obs; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
_checkLoginStatus(); |
||||
|
_startPlaybackStateMonitor(); |
||||
|
} |
||||
|
|
||||
|
/// 检查登录状态 |
||||
|
void _checkLoginStatus() { |
||||
|
// 这里可以检查本地存储的登录信息 |
||||
|
// 暂时设为未登录状态 |
||||
|
isLoggedIn.value = false; |
||||
|
} |
||||
|
|
||||
|
/// QQ音乐登录 |
||||
|
Future<void> login() async { |
||||
|
try { |
||||
|
print('开始QQ音乐登录...'); |
||||
|
|
||||
|
Get.dialog( |
||||
|
const Center( |
||||
|
child: CircularProgressIndicator(), |
||||
|
), |
||||
|
barrierDismissible: false, |
||||
|
); |
||||
|
|
||||
|
// 添加超时处理 |
||||
|
final result = await _qqMusic.login().timeout( |
||||
|
const Duration(seconds: 30), |
||||
|
onTimeout: () { |
||||
|
print('QQ音乐登录超时'); |
||||
|
return {'success': false, 'error': '登录超时,请重试'}; |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
print('QQ音乐登录结果: $result'); |
||||
|
|
||||
|
Get.back(); // 关闭加载对话框 |
||||
|
|
||||
|
if (result['success'] == true) { |
||||
|
userInfo.value = QQMusicLoginResult.fromMap(result); |
||||
|
isLoggedIn.value = true; |
||||
|
print('QQ音乐登录成功: ${result['openId']}'); |
||||
|
Get.snackbar('登录成功', '欢迎使用QQ音乐'); |
||||
|
} else { |
||||
|
print('QQ音乐登录失败: ${result['error']}'); |
||||
|
Get.snackbar('登录失败', result['error'] ?? '未知错误'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
print('QQ音乐登录异常: $e'); |
||||
|
Get.back(); |
||||
|
Get.snackbar('登录失败', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 退出登录 |
||||
|
void logout() { |
||||
|
userInfo.value = null; |
||||
|
isLoggedIn.value = false; |
||||
|
currentSong.value = null; |
||||
|
isPlaying.value = false; |
||||
|
playlist.clear(); |
||||
|
Get.snackbar('已退出', '已退出QQ音乐登录'); |
||||
|
} |
||||
|
|
||||
|
/// 搜索歌曲 |
||||
|
Future<void> searchSongs(String keyword) async { |
||||
|
if (keyword.trim().isEmpty) return; |
||||
|
|
||||
|
print('开始搜索歌曲: $keyword'); |
||||
|
searchKeyword.value = keyword; |
||||
|
isSearching.value = true; |
||||
|
|
||||
|
try { |
||||
|
// 检查登录状态 |
||||
|
if (!isLoggedIn.value) { |
||||
|
print('用户未登录,无法搜索'); |
||||
|
Get.snackbar('搜索失败', '请先登录QQ音乐'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
print('调用搜索接口...'); |
||||
|
final result = await _qqMusic.searchSongs(keyword: keyword).timeout( |
||||
|
const Duration(seconds: 15), |
||||
|
onTimeout: () { |
||||
|
print('搜索超时'); |
||||
|
return {'success': false, 'error': '搜索超时,请重试'}; |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
print('搜索结果: $result'); |
||||
|
|
||||
|
if (result['success'] == true && result['songs'] != null) { |
||||
|
final List<dynamic> songsData = result['songs']; |
||||
|
print('找到 ${songsData.length} 首歌曲'); |
||||
|
|
||||
|
final songs = songsData.map((songData) { |
||||
|
return SongModel( |
||||
|
mid: songData['mid'] ?? '', |
||||
|
name: songData['name'] ?? '', |
||||
|
artist: songData['singer'] ?? '', |
||||
|
album: songData['album'] ?? '', |
||||
|
albumPic: songData['pic'], |
||||
|
duration: songData['duration'] ?? 0, |
||||
|
); |
||||
|
}).toList(); |
||||
|
|
||||
|
searchResults.assignAll(songs); |
||||
|
print('搜索成功,更新UI'); |
||||
|
} else { |
||||
|
print('搜索失败: ${result['error']}'); |
||||
|
Get.snackbar('搜索失败', result['error'] ?? '搜索结果为空'); |
||||
|
searchResults.clear(); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
print('搜索异常: $e'); |
||||
|
Get.snackbar('搜索失败', e.toString()); |
||||
|
searchResults.clear(); |
||||
|
} finally { |
||||
|
print('搜索完成,重置状态'); |
||||
|
isSearching.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 播放歌曲 |
||||
|
Future<void> playSong(SongModel song, {List<SongModel>? songList}) async { |
||||
|
try { |
||||
|
currentSong.value = song; |
||||
|
|
||||
|
if (songList != null) { |
||||
|
playlist.assignAll(songList); |
||||
|
currentIndex.value = songList.indexOf(song); |
||||
|
} else { |
||||
|
playlist.assignAll([song]); |
||||
|
currentIndex.value = 0; |
||||
|
} |
||||
|
|
||||
|
// 获取歌曲MID列表 |
||||
|
final songMids = playlist.map((s) => s.mid).toList(); |
||||
|
final playIndex = currentIndex.value; |
||||
|
|
||||
|
// 调用QQ音乐播放接口 |
||||
|
final result = await _qqMusic.playSongs( |
||||
|
songMids: songMids, |
||||
|
index: playIndex, |
||||
|
); |
||||
|
|
||||
|
if (result['success'] == true) { |
||||
|
isPlaying.value = true; |
||||
|
totalDuration.value = song.duration; |
||||
|
currentPosition.value = 0; |
||||
|
Get.snackbar('开始播放', song.name); |
||||
|
} else { |
||||
|
Get.snackbar('播放失败', result['error'] ?? '未知错误'); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Get.snackbar('播放失败', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 暂停/继续播放 |
||||
|
Future<void> togglePlayPause() async { |
||||
|
try { |
||||
|
if (isPlaying.value) { |
||||
|
final result = await _qqMusic.pauseMusic(); |
||||
|
if (result['success'] == true) { |
||||
|
isPlaying.value = false; |
||||
|
} |
||||
|
} else { |
||||
|
final result = await _qqMusic.resumeMusic(); |
||||
|
if (result['success'] == true) { |
||||
|
isPlaying.value = true; |
||||
|
} |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Get.snackbar('操作失败', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 上一首 |
||||
|
Future<void> previousSong() async { |
||||
|
try { |
||||
|
final result = await _qqMusic.skipToPrevious(); |
||||
|
if (result['success'] == true) { |
||||
|
if (playlist.isNotEmpty) { |
||||
|
int newIndex = currentIndex.value - 1; |
||||
|
if (newIndex < 0) newIndex = playlist.length - 1; |
||||
|
currentIndex.value = newIndex; |
||||
|
currentSong.value = playlist[newIndex]; |
||||
|
} |
||||
|
await _updateCurrentSong(); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Get.snackbar('切换失败', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 下一首 |
||||
|
Future<void> nextSong() async { |
||||
|
try { |
||||
|
final result = await _qqMusic.skipToNext(); |
||||
|
if (result['success'] == true) { |
||||
|
if (playlist.isNotEmpty) { |
||||
|
int newIndex = currentIndex.value + 1; |
||||
|
if (newIndex >= playlist.length) newIndex = 0; |
||||
|
currentIndex.value = newIndex; |
||||
|
currentSong.value = playlist[newIndex]; |
||||
|
} |
||||
|
await _updateCurrentSong(); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Get.snackbar('切换失败', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止播放 |
||||
|
Future<void> stopMusic() async { |
||||
|
try { |
||||
|
final result = await _qqMusic.stopMusic(); |
||||
|
if (result['success'] == true) { |
||||
|
isPlaying.value = false; |
||||
|
currentPosition.value = 0; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
Get.snackbar('停止失败', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新当前播放歌曲信息 |
||||
|
Future<void> _updateCurrentSong() async { |
||||
|
try { |
||||
|
final result = await _qqMusic.getCurrentSong(); |
||||
|
if (result['success'] == true && result['song'] != null) { |
||||
|
final songData = result['song']; |
||||
|
final song = SongModel( |
||||
|
mid: songData['mid'] ?? '', |
||||
|
name: songData['name'] ?? '', |
||||
|
artist: songData['singer'] ?? '', |
||||
|
album: songData['album'] ?? '', |
||||
|
albumPic: songData['pic'], |
||||
|
duration: songData['duration'] ?? 0, |
||||
|
); |
||||
|
currentSong.value = song; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
print('更新当前歌曲信息失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 启动播放状态监控 |
||||
|
void _startPlaybackStateMonitor() { |
||||
|
// 每秒检查一次播放状态和时间 |
||||
|
Stream.periodic(const Duration(seconds: 1)).listen((_) async { |
||||
|
if (isLoggedIn.value) { |
||||
|
await _updatePlaybackState(); |
||||
|
await _updatePlayTime(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 更新播放状态 |
||||
|
Future<void> _updatePlaybackState() async { |
||||
|
try { |
||||
|
final result = await _qqMusic.getPlaybackState(); |
||||
|
if (result['success'] == true && result['state'] != null) { |
||||
|
final state = result['state']; |
||||
|
// QQ音乐播放状态:0-停止,1-播放,2-暂停 |
||||
|
isPlaying.value = state == 1; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
// 静默处理错误,避免频繁弹窗 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 更新播放时间 |
||||
|
Future<void> _updatePlayTime() async { |
||||
|
try { |
||||
|
final result = await _qqMusic.getPlayTime(); |
||||
|
if (result['success'] == true) { |
||||
|
currentPosition.value = result['currentTime'] ?? 0; |
||||
|
totalDuration.value = result['totalTime'] ?? 0; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
// 静默处理错误,避免频繁弹窗 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 跳转到指定位置(暂时保留,QQ音乐可能不支持) |
||||
|
void seekTo(int position) { |
||||
|
currentPosition.value = position; |
||||
|
// QQ音乐SDK可能不支持跳转功能 |
||||
|
Get.snackbar('提示', '当前版本不支持跳转功能'); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
// 清理资源 |
||||
|
super.onClose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
class SongModel { |
||||
|
final String mid; |
||||
|
final String name; |
||||
|
final String artist; |
||||
|
final String album; |
||||
|
final String? albumPic; |
||||
|
final int duration; |
||||
|
final bool isFavorite; |
||||
|
|
||||
|
SongModel({ |
||||
|
required this.mid, |
||||
|
required this.name, |
||||
|
required this.artist, |
||||
|
required this.album, |
||||
|
this.albumPic, |
||||
|
required this.duration, |
||||
|
this.isFavorite = false, |
||||
|
}); |
||||
|
|
||||
|
factory SongModel.fromJson(Map<String, dynamic> json) { |
||||
|
return SongModel( |
||||
|
mid: json['mid'] ?? '', |
||||
|
name: json['name'] ?? '', |
||||
|
artist: json['artist'] ?? '', |
||||
|
album: json['album'] ?? '', |
||||
|
albumPic: json['albumPic'], |
||||
|
duration: json['duration'] ?? 0, |
||||
|
isFavorite: json['isFavorite'] ?? false, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Map<String, dynamic> toJson() { |
||||
|
return { |
||||
|
'mid': mid, |
||||
|
'name': name, |
||||
|
'artist': artist, |
||||
|
'album': album, |
||||
|
'albumPic': albumPic, |
||||
|
'duration': duration, |
||||
|
'isFavorite': isFavorite, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
String get durationText { |
||||
|
final minutes = duration ~/ 60; |
||||
|
final seconds = duration % 60; |
||||
|
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,242 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/qqmusic_controller.dart'; |
||||
|
import '../models/song_model.dart'; |
||||
|
|
||||
|
class QQMusicView extends GetView<QQMusicController> { |
||||
|
const QQMusicView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: const Text('QQ音乐'), |
||||
|
actions: [ |
||||
|
Obx(() => controller.isLoggedIn.value |
||||
|
? IconButton( |
||||
|
icon: const Icon(Icons.logout), |
||||
|
onPressed: controller.logout, |
||||
|
) |
||||
|
: IconButton( |
||||
|
icon: const Icon(Icons.login), |
||||
|
onPressed: controller.login, |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
body: Obx(() { |
||||
|
if (!controller.isLoggedIn.value) { |
||||
|
return _buildLoginView(); |
||||
|
} |
||||
|
return _buildMainView(); |
||||
|
}), |
||||
|
bottomNavigationBar: Obx(() { |
||||
|
if (controller.currentSong.value != null) { |
||||
|
return _buildMiniPlayer(); |
||||
|
} |
||||
|
return const SizedBox.shrink(); |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildLoginView() { |
||||
|
return Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
const Icon( |
||||
|
Icons.music_note, |
||||
|
size: 100, |
||||
|
color: Colors.blue, |
||||
|
), |
||||
|
const SizedBox(height: 20), |
||||
|
const Text( |
||||
|
'请先登录QQ音乐', |
||||
|
style: TextStyle(fontSize: 18), |
||||
|
), |
||||
|
const SizedBox(height: 20), |
||||
|
ElevatedButton( |
||||
|
onPressed: controller.login, |
||||
|
child: const Text('登录QQ音乐'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMainView() { |
||||
|
return Column( |
||||
|
children: [ |
||||
|
_buildSearchBar(), |
||||
|
Expanded( |
||||
|
child: Obx(() { |
||||
|
if (controller.isSearching.value) { |
||||
|
return const Center(child: CircularProgressIndicator()); |
||||
|
} |
||||
|
if (controller.searchResults.isEmpty) { |
||||
|
return const Center( |
||||
|
child: Text('搜索歌曲开始您的音乐之旅'), |
||||
|
); |
||||
|
} |
||||
|
return _buildSearchResults(); |
||||
|
}), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildSearchBar() { |
||||
|
return Container( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
child: TextField( |
||||
|
decoration: const InputDecoration( |
||||
|
hintText: '搜索歌曲、歌手、专辑', |
||||
|
prefixIcon: Icon(Icons.search), |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
onSubmitted: controller.searchSongs, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildSearchResults() { |
||||
|
return ListView.builder( |
||||
|
itemCount: controller.searchResults.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final song = controller.searchResults[index]; |
||||
|
return _buildSongItem(song); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildSongItem(SongModel song) { |
||||
|
return ListTile( |
||||
|
leading: Container( |
||||
|
width: 50, |
||||
|
height: 50, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey[300], |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: song.albumPic != null |
||||
|
? ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
child: Image.network( |
||||
|
song.albumPic!, |
||||
|
fit: BoxFit.cover, |
||||
|
errorBuilder: (context, error, stackTrace) { |
||||
|
return const Icon(Icons.music_note); |
||||
|
}, |
||||
|
), |
||||
|
) |
||||
|
: const Icon(Icons.music_note), |
||||
|
), |
||||
|
title: Text( |
||||
|
song.name, |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
subtitle: Text( |
||||
|
'${song.artist} • ${song.album}', |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
trailing: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text(song.durationText), |
||||
|
const SizedBox(width: 8), |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.play_arrow), |
||||
|
onPressed: () => controller.playSong( |
||||
|
song, |
||||
|
songList: controller.searchResults, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMiniPlayer() { |
||||
|
return Obx(() { |
||||
|
final song = controller.currentSong.value!; |
||||
|
return Container( |
||||
|
height: 80, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.1), |
||||
|
blurRadius: 4, |
||||
|
offset: const Offset(0, -2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// 进度条 |
||||
|
Obx(() => LinearProgressIndicator( |
||||
|
value: controller.totalDuration.value > 0 |
||||
|
? controller.currentPosition.value / |
||||
|
controller.totalDuration.value |
||||
|
: 0, |
||||
|
backgroundColor: Colors.grey[300], |
||||
|
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue), |
||||
|
)), |
||||
|
Expanded( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
// 歌曲信息 |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
song.name, |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
style: const TextStyle(fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
Text( |
||||
|
song.artist, |
||||
|
maxLines: 1, |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
style: TextStyle(color: Colors.grey[600]), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
// 控制按钮 |
||||
|
Row( |
||||
|
children: [ |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.skip_previous), |
||||
|
onPressed: controller.previousSong, |
||||
|
), |
||||
|
Obx(() => IconButton( |
||||
|
icon: Icon( |
||||
|
controller.isPlaying.value |
||||
|
? Icons.pause |
||||
|
: Icons.play_arrow, |
||||
|
), |
||||
|
onPressed: controller.togglePlayPause, |
||||
|
)), |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.skip_next), |
||||
|
onPressed: controller.nextSong, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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(), |
||||
); |
); |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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('录音计时器管理器已释放'); |
||||
|
} |
||||
|
} |
||||
@ -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), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -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('翻译历史管理器已释放'); |
||||
|
} |
||||
|
} |
||||
|
|||||
@ -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) ?? |
||||
|
'未知语言'; |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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) |
|
||||
} |
|
||||
} |
|
||||
@ -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 |
|
||||
} |
|
||||
File diff suppressed because it is too large
@ -1,4 +0,0 @@ |
|||||
#import <Flutter/Flutter.h> |
|
||||
|
|
||||
@interface BleServicePlugin : NSObject<FlutterPlugin> |
|
||||
@end |
|
||||
@ -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 |
|
||||
@ -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 |
|
||||
} |
|
||||
} |
|
||||
@ -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 +0,0 @@ |
|||||
|
|
||||
@ -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 |
||||
@ -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}\"}" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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) ?? "{}" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
# Miscellaneous |
||||
|
*.class |
||||
|
*.log |
||||
|
*.pyc |
||||
|
*.swp |
||||
|
.DS_Store |
||||
|
.atom/ |
||||
|
.build/ |
||||
|
.buildlog/ |
||||
|
.history |
||||
|
.svn/ |
||||
|
.swiftpm/ |
||||
|
migrate_working_dir/ |
||||
|
|
||||
|
# IntelliJ related |
||||
|
*.iml |
||||
|
*.ipr |
||||
|
*.iws |
||||
|
.idea/ |
||||
|
|
||||
|
# The .vscode folder contains launch configuration and tasks you configure in |
||||
|
# VS Code which you may wish to be included in version control, so this line |
||||
|
# is commented out by default. |
||||
|
#.vscode/ |
||||
|
|
||||
|
# Flutter/Dart/Pub related |
||||
|
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. |
||||
|
/pubspec.lock |
||||
|
**/doc/api/ |
||||
|
.dart_tool/ |
||||
|
.flutter-plugins |
||||
|
.flutter-plugins-dependencies |
||||
|
build/ |
||||
@ -0,0 +1,33 @@ |
|||||
|
# This file tracks properties of this Flutter project. |
||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
||||
|
# |
||||
|
# This file should be version controlled and should not be manually edited. |
||||
|
|
||||
|
version: |
||||
|
revision: "5c1433509f997e9059d230942bdf83f1b88a179e" |
||||
|
channel: "stable" |
||||
|
|
||||
|
project_type: plugin |
||||
|
|
||||
|
# Tracks metadata for the flutter migrate command |
||||
|
migration: |
||||
|
platforms: |
||||
|
- platform: root |
||||
|
create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
||||
|
base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
||||
|
- platform: android |
||||
|
create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
||||
|
base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
||||
|
- platform: ios |
||||
|
create_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
||||
|
base_revision: 5c1433509f997e9059d230942bdf83f1b88a179e |
||||
|
|
||||
|
# User provided section |
||||
|
|
||||
|
# List of Local paths (relative to this file) that should be |
||||
|
# ignored by the migrate tool. |
||||
|
# |
||||
|
# Files that are not part of the templates will be ignored by default. |
||||
|
unmanaged_files: |
||||
|
- 'lib/main.dart' |
||||
|
- 'ios/Runner.xcodeproj/project.pbxproj' |
||||
@ -0,0 +1,3 @@ |
|||||
|
## 0.0.1 |
||||
|
|
||||
|
* TODO: Describe initial release. |
||||
@ -0,0 +1 @@ |
|||||
|
TODO: Add your license here. |
||||
@ -0,0 +1,15 @@ |
|||||
|
# qq_music |
||||
|
|
||||
|
A new Flutter project. |
||||
|
|
||||
|
## Getting Started |
||||
|
|
||||
|
This project is a starting point for a Flutter |
||||
|
[plug-in package](https://flutter.dev/to/develop-plugins), |
||||
|
a specialized package that includes platform-specific implementation code for |
||||
|
Android and/or iOS. |
||||
|
|
||||
|
For help getting started with Flutter development, view the |
||||
|
[online documentation](https://docs.flutter.dev), which offers tutorials, |
||||
|
samples, guidance on mobile development, and a full API reference. |
||||
|
|
||||
@ -0,0 +1,4 @@ |
|||||
|
include: package:flutter_lints/flutter.yaml |
||||
|
|
||||
|
# Additional information about this file can be found at |
||||
|
# https://dart.dev/guides/language/analysis-options |
||||
@ -0,0 +1,9 @@ |
|||||
|
*.iml |
||||
|
.gradle |
||||
|
/local.properties |
||||
|
/.idea/workspace.xml |
||||
|
/.idea/libraries |
||||
|
.DS_Store |
||||
|
/build |
||||
|
/captures |
||||
|
.cxx |
||||
@ -0,0 +1,79 @@ |
|||||
|
group = "com.yunqiinnovation.qq_music" |
||||
|
version = "1.0-SNAPSHOT" |
||||
|
|
||||
|
buildscript { |
||||
|
ext.kotlin_version = "2.1.0" |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
classpath("com.android.tools.build:gradle:8.7.3") |
||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
allprojects { |
||||
|
repositories { |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
// 添加本地AAR仓库 |
||||
|
flatDir { |
||||
|
dirs 'libs' |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
apply plugin: "com.android.library" |
||||
|
apply plugin: "kotlin-android" |
||||
|
|
||||
|
android { |
||||
|
namespace = "com.yunqiinnovation.qq_music" |
||||
|
|
||||
|
compileSdk = 35 |
||||
|
|
||||
|
compileOptions { |
||||
|
sourceCompatibility = JavaVersion.VERSION_11 |
||||
|
targetCompatibility = JavaVersion.VERSION_11 |
||||
|
} |
||||
|
|
||||
|
kotlinOptions { |
||||
|
jvmTarget = JavaVersion.VERSION_11 |
||||
|
} |
||||
|
|
||||
|
// 合并后的 sourceSets 配置 |
||||
|
sourceSets { |
||||
|
main { |
||||
|
java.srcDirs += "src/main/kotlin" |
||||
|
aidl.srcDirs = ['src/main/aidl'] |
||||
|
} |
||||
|
test { |
||||
|
java.srcDirs += "src/test/kotlin" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
defaultConfig { |
||||
|
minSdk = 21 |
||||
|
} |
||||
|
|
||||
|
buildFeatures { |
||||
|
aidl true |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
testImplementation("org.jetbrains.kotlin:kotlin-test") |
||||
|
testImplementation("org.mockito:mockito-core:5.0.0") |
||||
|
|
||||
|
// 添加AndroidX AppCompat依赖 |
||||
|
implementation 'androidx.appcompat:appcompat:1.6.1' |
||||
|
|
||||
|
// 添加 Gson 依赖 |
||||
|
implementation 'com.google.code.gson:gson:2.10.1' |
||||
|
|
||||
|
// 添加 ConstraintLayout 依赖 |
||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' |
||||
|
|
||||
|
api(name: 'qqmusic-innovation-aidl-api-sdk-1.0.26', ext: 'aar') |
||||
|
} |
||||
|
} |
||||
Binary file not shown.
@ -0,0 +1 @@ |
|||||
|
rootProject.name = 'qq_music' |
||||
@ -0,0 +1,9 @@ |
|||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
package="com.yunqiinnovation.qq_music"> |
||||
|
|
||||
|
<uses-permission android:name="android.permission.INTERNET" /> |
||||
|
|
||||
|
<application> |
||||
|
<!-- 移除了 LoginExampleActivity 的声明,因为该类已被删除 --> |
||||
|
</application> |
||||
|
</manifest> |
||||
@ -0,0 +1,15 @@ |
|||||
|
// IPlayService.aidl |
||||
|
package com.yunqiinnovation.qq_music; |
||||
|
|
||||
|
// Declare any non-default types here with import statements |
||||
|
|
||||
|
import com.yunqiinnovation.qq_music.IPrint; |
||||
|
|
||||
|
interface IPlayService { |
||||
|
void bindService(); |
||||
|
void startPcmMode(); |
||||
|
void stopPcmMode(); |
||||
|
void resumeOrPause(); |
||||
|
void playNext(); |
||||
|
void setPrintMessageCallback(IPrint callback); |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
// IPrintMessage.aidl |
||||
|
package com.yunqiinnovation.qq_music; |
||||
|
|
||||
|
// Declare any non-default types here with import statements |
||||
|
|
||||
|
interface IPrint { |
||||
|
void print(String msg); |
||||
|
} |
||||
@ -0,0 +1,361 @@ |
|||||
|
package com.tencent.qqmusic.api.demo |
||||
|
|
||||
|
import android.os.Bundle |
||||
|
import androidx.annotation.Nullable |
||||
|
import android.util.Log |
||||
|
import com.google.gson.Gson |
||||
|
import com.tencent.qqmusic.third.api.contract.* |
||||
|
import com.tencent.qqmusic.third.api.contract.ErrorCodes.ERROR_API_NOT_INITIALIZED |
||||
|
|
||||
|
|
||||
|
// |
||||
|
// Created by tylertan on 2020-03-19. |
||||
|
// Copyright (c) 2020 Tencent. All rights reserved. |
||||
|
// |
||||
|
|
||||
|
/** |
||||
|
* 对[Methods]中的接口进行基础封装 |
||||
|
* |
||||
|
*/ |
||||
|
object ApiSample { |
||||
|
|
||||
|
const val TAG = "ApiSample" |
||||
|
|
||||
|
/** |
||||
|
* 获取接口返回错误码,对应[ErrorCodes]中的错误码列表 |
||||
|
* |
||||
|
* @return 返回[ErrorCodes.ERROR_OK]代表成功,反之为失败 |
||||
|
*/ |
||||
|
private fun apiCode(bundle: Bundle?): Int? { |
||||
|
return bundle?.getInt(Keys.API_RETURN_KEY_CODE) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取接口返回错误详细信息 |
||||
|
* |
||||
|
*/ |
||||
|
private fun apiError(bundle: Bundle?): String? { |
||||
|
return bundle?.getString(Keys.API_RETURN_KEY_ERROR) |
||||
|
} |
||||
|
|
||||
|
fun hi(api: IQQMusicApi?) { |
||||
|
val bundle = Bundle() |
||||
|
bundle.putInt(Keys.API_PARAM_KEY_SDK_VERSION, CommonCmd.SDK_VERSION) |
||||
|
bundle.putString(Keys.API_PARAM_KEY_PLATFORM_TYPE, CommonCmd.AIDL_PLATFORM_TYPE_PHONE) |
||||
|
val result = api?.execute("hi", bundle) |
||||
|
Log.i(TAG, "sayHi ret:" + result!!.getInt(Keys.API_RETURN_KEY_CODE)) |
||||
|
} |
||||
|
|
||||
|
fun playSongMid(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("midList", ids) |
||||
|
api?.executeAsync("playSongMid", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
private fun playSongMidAtIndex(api: IQQMusicApi?, songList: List<Data.Song>, index: Int, block: (() -> Unit)) { |
||||
|
val midList = ArrayList<String>() |
||||
|
for (i in songList.indices) { |
||||
|
midList.add(songList[i].mid) |
||||
|
} |
||||
|
|
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("midList", midList) |
||||
|
params.putInt("index", index) |
||||
|
Log.i(TAG, "[playSongMidAtIndex] 播放歌曲列表... $index,${songList.size}") |
||||
|
api?.executeAsync("playSongMidAtIndex", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun playSongLocalPath(api: IQQMusicApi?, pathList: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("pathList", pathList) |
||||
|
api?.executeAsync("playSongLocalPath", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun playMusic(api: IQQMusicApi?): Int { |
||||
|
val ret = api?.execute("playMusic", null) |
||||
|
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
||||
|
} |
||||
|
|
||||
|
fun stopMusic(api: IQQMusicApi?): Int { |
||||
|
val ret = api?.execute("stopMusic", null) |
||||
|
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
||||
|
} |
||||
|
|
||||
|
fun pauseMusic(api: IQQMusicApi?): Int { |
||||
|
val ret = api?.execute("pauseMusic", null) |
||||
|
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
||||
|
} |
||||
|
|
||||
|
fun resumeMusic(api: IQQMusicApi?): Int { |
||||
|
val ret = api?.execute("resumeMusic", null) |
||||
|
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
||||
|
} |
||||
|
|
||||
|
fun skipToNext(api: IQQMusicApi?): Int { |
||||
|
val ret = api?.execute("skipToNext", null) |
||||
|
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
||||
|
} |
||||
|
|
||||
|
fun skipToPrevious(api: IQQMusicApi?): Int { |
||||
|
val ret = api?.execute("skipToPrevious", null) |
||||
|
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
||||
|
} |
||||
|
|
||||
|
fun getPlaybackState(api: IQQMusicApi?): Int { |
||||
|
val ret = api?.execute("getPlaybackState", null) |
||||
|
return ret?.getInt(Keys.API_RETURN_KEY_CODE) ?: 0 |
||||
|
} |
||||
|
|
||||
|
@Nullable |
||||
|
fun getCurrentSong(api: IQQMusicApi?, gson: Gson): Data.Song? { |
||||
|
val ret = api?.execute("getCurrentSong", null) |
||||
|
val curSongJson = ret?.getString(Keys.API_RETURN_KEY_DATA) |
||||
|
return gson.fromJson(curSongJson, Data.Song::class.java) |
||||
|
} |
||||
|
|
||||
|
fun addToFavourite(api: IQQMusicApi?, midList: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("midList", midList) |
||||
|
api?.executeAsync("addToFavourite", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun removeFromFavourite(api: IQQMusicApi?, midList: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("midList", midList) |
||||
|
api?.executeAsync("removeFromFavourite", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun addLocalPathToFavourite(api: IQQMusicApi?, localPathList: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("localPathList", localPathList) |
||||
|
api?.executeAsync("addLocalPathToFavourite", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun removeLocalPathFromFavourite(api: IQQMusicApi?, localPathList: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("localPathList", localPathList) |
||||
|
api?.executeAsync("removeLocalPathFromFavourite", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun isFavouriteMid(api: IQQMusicApi?, midList: ArrayList<String>, block: ((ret: BooleanArray?) -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("midList", midList) |
||||
|
api?.executeAsync("isFavouriteMid", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) |
||||
|
block(ret) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun isFavouriteLocalPath(api: IQQMusicApi?, localPathList: ArrayList<String>, block: ((ret: BooleanArray?) -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("localPathList", localPathList) |
||||
|
api?.executeAsync("isFavouriteLocalPath", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
val ret = result.getBooleanArray(Keys.API_RETURN_KEY_DATA) |
||||
|
block(ret) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun registerEventListener(api: IQQMusicApi?, events: ArrayList<String>, block: (() -> Unit)) { |
||||
|
api?.registerEventListener(events, object : IQQMusicApiEventListener.Stub() { |
||||
|
override fun onEvent(event: String, extra: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun unregisterEventListener(api: IQQMusicApi?, events: ArrayList<String>, block: (() -> Unit)) { |
||||
|
api?.unregisterEventListener(events, object : IQQMusicApiEventListener.Stub() { |
||||
|
override fun onEvent(event: String, extra: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun playFromChorus(api: IQQMusicApi?, fromChorus: Boolean) { |
||||
|
val params = Bundle() |
||||
|
params.putBoolean("fromChorus", fromChorus) |
||||
|
api?.execute("playFromChorus", params) |
||||
|
} |
||||
|
|
||||
|
@Deprecated("Use playSongMid instead.") |
||||
|
fun playSongId(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("songIdList", ids) |
||||
|
api?.executeAsync("playSongId", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
@Deprecated("Use playSongMidAtIndex instead.") |
||||
|
fun playSongIdAtIndex(api: IQQMusicApi?, ids: ArrayList<String>, block: (() -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("songIdList", ids) |
||||
|
api?.executeAsync("playSongIdAtIndex", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun getTotalTime(api: IQQMusicApi?): Long { |
||||
|
val result = api?.execute("getTotalTime", null) |
||||
|
return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 |
||||
|
} |
||||
|
|
||||
|
fun getCurrTime(api: IQQMusicApi?): Long { |
||||
|
val result = api?.execute("getCurrTime", null) |
||||
|
return (result?.getLong(Keys.API_RETURN_KEY_DATA) ?: 0) / 1000 |
||||
|
} |
||||
|
|
||||
|
fun getPlayList(api: IQQMusicApi?, ids: ArrayList<String>, block: ((json: String?) -> Unit)) { |
||||
|
val params = Bundle() |
||||
|
params.putStringArrayList("songIdList", ids) |
||||
|
api?.executeAsync("getPlayList", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(result: Bundle) { |
||||
|
val json = result.getString(Keys.API_RETURN_KEY_DATA) |
||||
|
block(json) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun getFolderList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
||||
|
val params = Bundle().apply { |
||||
|
putString("folderId", folderId) |
||||
|
putInt("folderType", folderType) |
||||
|
putInt("page", page) |
||||
|
} |
||||
|
|
||||
|
api?.executeAsync("getFolderList", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(p0: Bundle?) { |
||||
|
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
||||
|
block(json) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun getSongList(api: IQQMusicApi?, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
||||
|
val params = Bundle().apply { |
||||
|
putString("folderId", folderId) |
||||
|
putInt("folderType", folderType) |
||||
|
putInt("page", page) |
||||
|
} |
||||
|
|
||||
|
api?.executeAsync("getSongList", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(p0: Bundle?) { |
||||
|
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
||||
|
block(json) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun getUserFolderList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
||||
|
val params = Bundle().apply { |
||||
|
putString("openId", openId) |
||||
|
putString("openToken", openToken) |
||||
|
putString("folderId", folderId) |
||||
|
putInt("folderType", folderType) |
||||
|
putInt("page", page) |
||||
|
} |
||||
|
|
||||
|
api?.executeAsync("getUserFolderList", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(p0: Bundle?) { |
||||
|
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
||||
|
block(json) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun getUserSongList(api: IQQMusicApi?, openId: String, openToken: String, folderId: String, folderType: Int, page: Int, block: (json: String?) -> Unit) { |
||||
|
val params = Bundle().apply { |
||||
|
putString("openId", openId) |
||||
|
putString("openToken", openToken) |
||||
|
putString("folderId", folderId) |
||||
|
putInt("folderType", folderType) |
||||
|
putInt("page", page) |
||||
|
} |
||||
|
|
||||
|
api?.executeAsync("getUserSongList", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(p0: Bundle?) { |
||||
|
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
||||
|
block(json) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun search(api: IQQMusicApi?, block: () -> Unit) { |
||||
|
val params = Bundle().apply { |
||||
|
putString("keyword", "周杰伦") |
||||
|
putInt("searchType", 0) |
||||
|
putBoolean("firstPage", true) |
||||
|
} |
||||
|
|
||||
|
api?.executeAsync("search", params, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(p0: Bundle?) { |
||||
|
block() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun voiceShortcut(api: IQQMusicApi?, intent: String, block: (code: Int?) -> Unit) { |
||||
|
val bundle = Bundle() |
||||
|
bundle.apply { |
||||
|
putString("intent", intent) |
||||
|
} |
||||
|
api?.executeAsync("voiceShortcut", bundle, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(p0: Bundle?) { |
||||
|
val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED |
||||
|
block(code) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fun voicePlay(api: IQQMusicApi?, query: String, slotList: ArrayList<String>, block: (code: Int, json: String?) -> Unit) { |
||||
|
val bundle = Bundle() |
||||
|
bundle.apply { |
||||
|
putString("query", query) |
||||
|
putStringArrayList("slotList", slotList) |
||||
|
} |
||||
|
api?.executeAsync("voicePlay", bundle, object : IQQMusicApiCallback.Stub() { |
||||
|
override fun onReturn(p0: Bundle?) { |
||||
|
val code = p0?.getInt(Keys.API_RETURN_KEY_CODE) ?: ERROR_API_NOT_INITIALIZED |
||||
|
val json = p0?.getString(Keys.API_RETURN_KEY_DATA) |
||||
|
block(code, json) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.yunqiinnovation.qq_music; |
||||
|
|
||||
|
import com.tencent.qqmusic.third.api.contract.CommonCmd; |
||||
|
|
||||
|
public class Config { |
||||
|
|
||||
|
/** |
||||
|
* 需向QQ音乐发邮件申请,具体参考README |
||||
|
*/ |
||||
|
public static final String OPENID_APPID = "2000000921"; |
||||
|
|
||||
|
/** |
||||
|
* 配置接入方自己生成的RSA私钥,由于是Demo,所以直接写在代码中了,真实环境中需要注意私钥泄漏的风险。 |
||||
|
*/ |
||||
|
public static final String OPENID_APP_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\n" + |
||||
|
"MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALWuTayxg0296yfm\n" + |
||||
|
"h8WhunybY1BEFt9fB4qb3TGvB0vkd+oKDoBdiTevtgwSL47h4O0ISNTQqqmVOvk1\n" + |
||||
|
"EQMGcLESBEdHquPX2uOplwmmVWx1un9NYB4JKaHmUkCXOIoiCgmEY052zFY2T+a0\n" + |
||||
|
"RfFiaUTc0wRMynquE8Qp18i23IUBAgMBAAECgYBf/s1D5jd+qE7MMl6i6iVjGaeM\n" + |
||||
|
"Da4YMFoHvvtM+TJdw0Yo/09SPBn4tnr2PdWrJWZ3V91DtU23dQZIDuYCNzDrOkVr\n" + |
||||
|
"93W5Pkqprdbde6qRhWm1VTZVN7h85DStGBzovSxQ6DQNHZFzIriJkfGDjPopmIil\n" + |
||||
|
"awQvMPlS3lLYkzHwAQJBAOWBlshEMHXeqXekjJExwX7YSy2oabWXtllCFbcPprAY\n" + |
||||
|
"MiM8cdNsEhnhrsmdBN4crqTQKJq/ATaYFhzASuIiZ2ECQQDKp17+A3u6q7RZ3Vmb\n" + |
||||
|
"khvAATQpVK5loCOzuyPU7/UGU49AO1DIaPwDvaUqL6PuF9eFrcRHA5AFbBjOgYNn\n" + |
||||
|
"piGhAkEAr5gMo0D4ytYHVxDAs74by071hgNajaN70qLrNlp1MDny8l6mwBnpZ3/y\n" + |
||||
|
"WTMXrFDJljPlPTByXzDRikHdiVLFQQJATMOTpxK4tqA+A+BmBhkVa+jI0nMiUJqV\n" + |
||||
|
"i4py60EuBoR2efU+B3xFIkGfI9jB9vmPCTMma2P9FSC6UNccaJG0oQJBAJYb6Ch9\n" + |
||||
|
"yowGPpY1CShaR+TfauUreJBd4PSpu8mFlYUssp2AXYcJM51RMzrZKXvTTOvwkStc\n" + |
||||
|
"NRCBcPVAYcVUnXo=\n" + |
||||
|
"-----END PRIVATE KEY-----"; |
||||
|
|
||||
|
/** |
||||
|
* 获取纯Base64格式的私钥(去除PEM头尾标记) |
||||
|
* @return 纯Base64格式的私钥字符串 |
||||
|
*/ |
||||
|
public static String getBase64PrivateKey() { |
||||
|
return OPENID_APP_PRIVATE_KEY |
||||
|
.replace("-----BEGIN PRIVATE KEY-----", "") |
||||
|
.replace("-----END PRIVATE KEY-----", "") |
||||
|
.replaceAll("\\s+", ""); // 移除所有空白字符(包括换行符)
|
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 配置平台类型,可选值如下: |
||||
|
* {@link CommonCmd#AIDL_PLATFORM_TYPE_PHONE } |
||||
|
* {@link CommonCmd#AIDL_PLATFORM_TYPE_TV} |
||||
|
* {@link CommonCmd#AIDL_PLATFORM_TYPE_CAR} |
||||
|
* {@link CommonCmd#AIDL_PLATFORM_TYPE_PAD} |
||||
|
*/ |
||||
|
// 修改这一行:使用CommonCmd中的常量而不是字符串
|
||||
|
public static String BIND_PLATFORM = CommonCmd.AIDL_PLATFORM_TYPE_PHONE; |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,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) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
) |
||||
|
} |
||||
@ -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() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,314 @@ |
|||||
|
package com.yunqiinnovation.qq_music.openid; |
||||
|
|
||||
|
import android.util.Base64; |
||||
|
|
||||
|
import java.io.ByteArrayOutputStream; |
||||
|
import java.security.Key; |
||||
|
import java.security.KeyFactory; |
||||
|
import java.security.KeyPair; |
||||
|
import java.security.KeyPairGenerator; |
||||
|
import java.security.PrivateKey; |
||||
|
import java.security.PublicKey; |
||||
|
import java.security.Signature; |
||||
|
import java.security.interfaces.RSAPrivateKey; |
||||
|
import java.security.interfaces.RSAPublicKey; |
||||
|
import java.security.spec.PKCS8EncodedKeySpec; |
||||
|
import java.security.spec.X509EncodedKeySpec; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
import javax.crypto.Cipher; |
||||
|
|
||||
|
public class RSAUtils { |
||||
|
/** |
||||
|
* 加密算法RSA |
||||
|
*/ |
||||
|
public static final String KEY_ALGORITHM = "RSA"; |
||||
|
|
||||
|
/** |
||||
|
* 签名算法 |
||||
|
*/ |
||||
|
public static final String SIGNATURE_ALGORITHM = "SHA256withRSA";//"MD5withRSA";
|
||||
|
|
||||
|
/** |
||||
|
* 获取公钥的key |
||||
|
*/ |
||||
|
private static final String PUBLIC_KEY = "RSAPublicKey"; |
||||
|
|
||||
|
/** |
||||
|
* 获取私钥的key |
||||
|
*/ |
||||
|
private static final String PRIVATE_KEY = "RSAPrivateKey"; |
||||
|
|
||||
|
private static final String CIHPER_KEY = "RSA/ECB/PKCS1Padding";//
|
||||
|
|
||||
|
/** |
||||
|
* RSA最大加密明文大小 |
||||
|
*/ |
||||
|
private static final int MAX_ENCRYPT_BLOCK = 117; |
||||
|
|
||||
|
/** |
||||
|
* RSA最大解密密文大小 |
||||
|
*/ |
||||
|
private static final int MAX_DECRYPT_BLOCK = 128; |
||||
|
|
||||
|
/** |
||||
|
* 生成密钥对(公钥和私钥) |
||||
|
* |
||||
|
* @return |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public static Map<String, Object> genKeyPair() throws Exception { |
||||
|
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM); |
||||
|
keyPairGen.initialize(1024); |
||||
|
KeyPair keyPair = keyPairGen.generateKeyPair(); |
||||
|
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); |
||||
|
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); |
||||
|
Map<String, Object> keyMap = new HashMap<>(2); |
||||
|
keyMap.put(PUBLIC_KEY, publicKey); |
||||
|
keyMap.put(PRIVATE_KEY, privateKey); |
||||
|
return keyMap; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 用私钥对信息生成数字签名 |
||||
|
* |
||||
|
* @param data 已加密数据 |
||||
|
* @param privateKey 私钥(BASE64编码) |
||||
|
* @return |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public static String sign(byte[] data, String privateKey) throws Exception { |
||||
|
|
||||
|
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); |
||||
|
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); |
||||
|
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
||||
|
PrivateKey privateK = keyFactory.generatePrivate(pkcs8KeySpec); |
||||
|
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); |
||||
|
signature.initSign(privateK); |
||||
|
signature.update(data); |
||||
|
return Base64.encodeToString(signature.sign(), Base64.DEFAULT); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 校验数字签名 |
||||
|
* |
||||
|
* @param data 已加密数据 |
||||
|
* @param publicKey 公钥(BASE64编码) |
||||
|
* @param sign 数字签名 |
||||
|
* @return |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public static boolean verify(byte[] data, String publicKey, String sign) |
||||
|
throws Exception { |
||||
|
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); |
||||
|
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); |
||||
|
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
||||
|
PublicKey publicK = keyFactory.generatePublic(keySpec); |
||||
|
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); |
||||
|
signature.initVerify(publicK); |
||||
|
signature.update(data); |
||||
|
return signature.verify(Base64.decode(sign, Base64.DEFAULT)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 私钥解密 |
||||
|
* |
||||
|
* @param encryptedData 已加密数据 |
||||
|
* @param privateKey 私钥(BASE64编码) |
||||
|
* @return |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public static byte[] decryptByPrivateKey(byte[] encryptedData, String privateKey) |
||||
|
throws Exception { |
||||
|
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); |
||||
|
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); |
||||
|
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
||||
|
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec); |
||||
|
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
||||
|
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
||||
|
|
||||
|
cipher.init(Cipher.DECRYPT_MODE, privateK); |
||||
|
int inputLen = encryptedData.length; |
||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
||||
|
int offSet = 0; |
||||
|
byte[] cache; |
||||
|
int i = 0; |
||||
|
// 对数据分段解密
|
||||
|
while (inputLen - offSet > 0) { |
||||
|
if (inputLen - offSet > MAX_DECRYPT_BLOCK) { |
||||
|
cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK); |
||||
|
} else { |
||||
|
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet); |
||||
|
} |
||||
|
out.write(cache, 0, cache.length); |
||||
|
i++; |
||||
|
offSet = i * MAX_DECRYPT_BLOCK; |
||||
|
} |
||||
|
byte[] decryptedData = out.toByteArray(); |
||||
|
out.close(); |
||||
|
return decryptedData; |
||||
|
} |
||||
|
|
||||
|
public static String testDecryptByJavaPrivKey(String privkey_from_java, String encryptedString) throws Exception { |
||||
|
//加解密类
|
||||
|
Cipher cipher = Cipher.getInstance("RSA");//Cipher.getInstance("RSA/ECB/PKCS1Padding");
|
||||
|
// G0语言通过java产生的公钥加密得到的加密后的字符串
|
||||
|
byte[] enBytes = Base64.decode(encryptedString, Base64.DEFAULT); |
||||
|
//通过密钥字符串得到密钥
|
||||
|
|
||||
|
PrivateKey privateKey = testGetPrivateKey(privkey_from_java); |
||||
|
|
||||
|
|
||||
|
//解密
|
||||
|
cipher.init(Cipher.DECRYPT_MODE, privateKey); |
||||
|
byte[] deBytes = cipher.doFinal(enBytes); |
||||
|
return new String(deBytes); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
public static PrivateKey testGetPrivateKey(String key) throws Exception { |
||||
|
byte[] keyBytes; |
||||
|
keyBytes = Base64.decode(key, Base64.DEFAULT); |
||||
|
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); |
||||
|
KeyFactory keyFactory = KeyFactory.getInstance("RSA"); |
||||
|
return keyFactory.generatePrivate(keySpec); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 公钥解密 |
||||
|
* |
||||
|
* @param encryptedData 已加密数据 |
||||
|
* @param publicKey 公钥(BASE64编码) |
||||
|
* @return |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public static byte[] decryptByPublicKey(byte[] encryptedData, String publicKey) |
||||
|
throws Exception { |
||||
|
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); |
||||
|
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes); |
||||
|
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
||||
|
Key publicK = keyFactory.generatePublic(x509KeySpec); |
||||
|
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
||||
|
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
||||
|
cipher.init(Cipher.DECRYPT_MODE, publicK); |
||||
|
int inputLen = encryptedData.length; |
||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
||||
|
int offSet = 0; |
||||
|
byte[] cache; |
||||
|
int i = 0; |
||||
|
// 对数据分段解密
|
||||
|
while (inputLen - offSet > 0) { |
||||
|
if (inputLen - offSet > MAX_DECRYPT_BLOCK) { |
||||
|
cache = cipher.doFinal(encryptedData, offSet, MAX_DECRYPT_BLOCK); |
||||
|
} else { |
||||
|
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet); |
||||
|
} |
||||
|
out.write(cache, 0, cache.length); |
||||
|
i++; |
||||
|
offSet = i * MAX_DECRYPT_BLOCK; |
||||
|
} |
||||
|
byte[] decryptedData = out.toByteArray(); |
||||
|
out.close(); |
||||
|
return decryptedData; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 公钥加密 |
||||
|
* |
||||
|
* @param data 源数据 |
||||
|
* @param publicKey 公钥(BASE64编码) |
||||
|
* @return |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public static byte[] encryptByPublicKey(byte[] data, String publicKey) |
||||
|
throws Exception { |
||||
|
byte[] keyBytes = Base64.decode(publicKey, Base64.DEFAULT); |
||||
|
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes); |
||||
|
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
||||
|
Key publicK = keyFactory.generatePublic(x509KeySpec); |
||||
|
// 对数据加密
|
||||
|
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
||||
|
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
||||
|
cipher.init(Cipher.ENCRYPT_MODE, publicK); |
||||
|
int inputLen = data.length; |
||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
||||
|
int offSet = 0; |
||||
|
byte[] cache; |
||||
|
int i = 0; |
||||
|
// 对数据分段加密
|
||||
|
while (inputLen - offSet > 0) { |
||||
|
if (inputLen - offSet > MAX_ENCRYPT_BLOCK) { |
||||
|
cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK); |
||||
|
} else { |
||||
|
cache = cipher.doFinal(data, offSet, inputLen - offSet); |
||||
|
} |
||||
|
out.write(cache, 0, cache.length); |
||||
|
i++; |
||||
|
offSet = i * MAX_ENCRYPT_BLOCK; |
||||
|
} |
||||
|
byte[] encryptedData = out.toByteArray(); |
||||
|
out.close(); |
||||
|
return encryptedData; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 私钥加密 |
||||
|
* |
||||
|
* @param data 源数据 |
||||
|
* @param privateKey 私钥(BASE64编码) |
||||
|
* @return |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
public static byte[] encryptByPrivateKey(byte[] data, String privateKey) |
||||
|
throws Exception { |
||||
|
byte[] keyBytes = Base64.decode(privateKey, Base64.DEFAULT); |
||||
|
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes); |
||||
|
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); |
||||
|
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec); |
||||
|
// Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
|
||||
|
Cipher cipher = Cipher.getInstance(CIHPER_KEY); |
||||
|
cipher.init(Cipher.ENCRYPT_MODE, privateK); |
||||
|
int inputLen = data.length; |
||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream(); |
||||
|
int offSet = 0; |
||||
|
byte[] cache; |
||||
|
int i = 0; |
||||
|
// 对数据分段加密
|
||||
|
while (inputLen - offSet > 0) { |
||||
|
if (inputLen - offSet > MAX_ENCRYPT_BLOCK) { |
||||
|
cache = cipher.doFinal(data, offSet, MAX_ENCRYPT_BLOCK); |
||||
|
} else { |
||||
|
cache = cipher.doFinal(data, offSet, inputLen - offSet); |
||||
|
} |
||||
|
out.write(cache, 0, cache.length); |
||||
|
i++; |
||||
|
offSet = i * MAX_ENCRYPT_BLOCK; |
||||
|
} |
||||
|
byte[] encryptedData = out.toByteArray(); |
||||
|
out.close(); |
||||
|
return encryptedData; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取私钥 |
||||
|
* |
||||
|
* @param keyMap 密钥对 |
||||
|
* @return |
||||
|
*/ |
||||
|
public static String getPrivateKey(Map<String, Object> keyMap) { |
||||
|
Key key = (Key) keyMap.get(PRIVATE_KEY); |
||||
|
return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取公钥 |
||||
|
* |
||||
|
* @param keyMap 密钥对 |
||||
|
* @return |
||||
|
*/ |
||||
|
public static String getPublicKey(Map<String, Object> keyMap) { |
||||
|
Key key = (Key) keyMap.get(PUBLIC_KEY); |
||||
|
return Base64.encodeToString(key.getEncoded(), Base64.DEFAULT); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,253 @@ |
|||||
|
package com.yunqiinnovation.qq_music.pcm |
||||
|
|
||||
|
import android.media.AudioFormat |
||||
|
import android.media.AudioManager |
||||
|
import android.media.AudioRecord |
||||
|
import android.media.AudioTrack |
||||
|
import android.media.AudioTrack.* |
||||
|
import android.os.Build |
||||
|
import android.os.ParcelFileDescriptor |
||||
|
import android.os.Process |
||||
|
import android.util.Log |
||||
|
import com.yunqiinnovation.qq_music.util.BitConverter |
||||
|
import java.io.DataInputStream |
||||
|
import java.io.File |
||||
|
import java.io.FileInputStream |
||||
|
import java.io.InputStream |
||||
|
import java.util.concurrent.ConcurrentLinkedQueue |
||||
|
|
||||
|
|
||||
|
class AudioTrackManager { |
||||
|
companion object { |
||||
|
private const val TAG = "AudioTrackManager" |
||||
|
private const val LOG_LOOP = true |
||||
|
|
||||
|
//音频流类型 |
||||
|
private const val mStreamType = AudioManager.STREAM_MUSIC |
||||
|
|
||||
|
//指定采样率 (MediaRecoder 的采样率通常是8000Hz AAC的通常是44100Hz。 设置采样率为44100,目前为常用的采样率,官方文档表示这个值可以兼容所有的设置) |
||||
|
private const val mSampleRateInHz = 44100 |
||||
|
|
||||
|
//指定捕获音频的声道数目。在AudioFormat类中指定用于此的常量 |
||||
|
private const val mChannelConfig = AudioFormat.CHANNEL_IN_STEREO //STEREO立体声 |
||||
|
|
||||
|
//指定音频量化位数 ,在AudioFormaat类中指定了以下各种可能的常量。通常我们选择ENCODING_PCM_16BIT和ENCODING_PCM_8BIT PCM代表的是脉冲编码调制,它实际上是原始音频样本。 |
||||
|
//因此可以设置每个样本的分辨率为16位或者8位,16位将占用更多的空间和处理能力,表示的音频也更加接近真实。 |
||||
|
private const val mAudioFormat = AudioFormat.ENCODING_PCM_FLOAT |
||||
|
|
||||
|
//STREAM的意思是由用户在应用程序通过write方式把数据一次一次得写到audiotrack中。这个和我们在socket中发送数据一样, |
||||
|
// 应用层从某个地方获取数据,例如通过编解码得到PCM数据,然后write到audiotrack。 |
||||
|
private const val mMode = AudioTrack.MODE_STREAM |
||||
|
} |
||||
|
|
||||
|
private var mAudioTrack: AudioTrack? = null |
||||
|
|
||||
|
private var mInputStream: InputStream? = null //播放的数据流 |
||||
|
private var mPlayerThread: Thread? = null |
||||
|
|
||||
|
//指定缓冲区大小。调用AudioRecord类的getMinBufferSize方法可以获得。 |
||||
|
private var mMinBufferSize = 0 |
||||
|
|
||||
|
var printMessageCallback: ((String) -> Unit)? = null |
||||
|
var enterForeground: ((Boolean) -> Unit)? = null |
||||
|
|
||||
|
var isFloatPcm = true |
||||
|
|
||||
|
fun initData(sampleRateInHz: Int = mSampleRateInHz, channelConfig: Int = mChannelConfig, audioFormat: Int = mAudioFormat) { |
||||
|
Log.d(TAG, "[initData】$sampleRateInHz, $channelConfig, $audioFormat") |
||||
|
//根据采样率,采样精度,单双声道来得到frame的大小。 |
||||
|
mMinBufferSize = AudioTrack.getMinBufferSize(sampleRateInHz, channelConfig, audioFormat) //计算最小缓冲区 |
||||
|
//注意,按照数字音频的知识,这个算出来的是一秒钟buffer的大小。 |
||||
|
//创建AudioTrack |
||||
|
mAudioTrack = AudioTrack(mStreamType, sampleRateInHz, channelConfig, audioFormat, mMinBufferSize, mMode) |
||||
|
isFloatPcm = audioFormat == AudioFormat.ENCODING_PCM_FLOAT |
||||
|
play() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 销毁线程方法 |
||||
|
*/ |
||||
|
private fun destroyThread() { |
||||
|
try { |
||||
|
if (null != mPlayerThread && Thread.State.RUNNABLE == mPlayerThread!!.state) { |
||||
|
try { |
||||
|
Thread.sleep(500) |
||||
|
mPlayerThread!!.interrupt() |
||||
|
} catch (e: Exception) { |
||||
|
mPlayerThread = null |
||||
|
} |
||||
|
} |
||||
|
mPlayerThread = null |
||||
|
} catch (e: Exception) { |
||||
|
e.printStackTrace() |
||||
|
} finally { |
||||
|
mPlayerThread = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 启动播放线程 |
||||
|
*/ |
||||
|
private fun startThread() { |
||||
|
destroyThread() |
||||
|
if (mPlayerThread == null) { |
||||
|
mPlayerThread = Thread(streamPlayerRunnable) |
||||
|
mPlayerThread!!.start() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private val streamPlayerRunnable = Runnable { |
||||
|
try { |
||||
|
//设置线程的优先级 |
||||
|
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) |
||||
|
Log.d(TAG, "streamPlayerRunnable") |
||||
|
|
||||
|
val tempBuffer = ByteArray(mMinBufferSize * 4) |
||||
|
var readCount = 0 |
||||
|
while (true) { |
||||
|
if (LOG_LOOP) { |
||||
|
//Log.d(TAG, "while true") |
||||
|
} |
||||
|
if (mInputStream!!.available() <= 0) { |
||||
|
if (LOG_LOOP) { |
||||
|
//Log.d(TAG, "sleep") |
||||
|
} |
||||
|
printMessageCallback?.invoke("Play thread sleep \n PlayState:${mAudioTrack?.playState?.playStateToString()}") |
||||
|
Thread.sleep(500) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
readCount = mInputStream!!.read(tempBuffer) |
||||
|
if (LOG_LOOP) { |
||||
|
Log.d(TAG, "readCount:$readCount") |
||||
|
} |
||||
|
if (readCount == AudioTrack.ERROR_INVALID_OPERATION || readCount == AudioTrack.ERROR_BAD_VALUE) { |
||||
|
continue |
||||
|
} |
||||
|
if (readCount != 0 && readCount != -1) { |
||||
|
//判断AudioTrack未初始化,停止播放的时候释放了,状态就为STATE_UNINITIALIZED |
||||
|
if (mAudioTrack!!.state == AudioTrack.STATE_UNINITIALIZED) { |
||||
|
initData() |
||||
|
} |
||||
|
|
||||
|
printMessageCallback?.invoke("AudioTrack write $readCount Bytes\n PlayState:${mAudioTrack?.playState?.playStateToString()}") |
||||
|
|
||||
|
if (isFloatPcm) { |
||||
|
val size = readCount / 4 |
||||
|
val audioData = FloatArray(size) |
||||
|
for (i in 0 until size) { |
||||
|
// 框架地址:https://github.com/smartdevicelink/sdl_java_suite/blob/master/base/src/main/java/com/smartdevicelink/util/BitConverter.java |
||||
|
val bitInt = BitConverter.intFromByteArray(tempBuffer, i * 4) |
||||
|
val fl = java.lang.Float.intBitsToFloat(bitInt) |
||||
|
audioData[i] = fl |
||||
|
} |
||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
||||
|
val ret = mAudioTrack?.write(audioData, 0, audioData.size, WRITE_BLOCKING) |
||||
|
Log.d(TAG, "ret=$ret") |
||||
|
} |
||||
|
} else { |
||||
|
mAudioTrack!!.write(tempBuffer, 0, readCount) |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
e.printStackTrace() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun Int.playStateToString(): String { |
||||
|
return when (this) { |
||||
|
PLAYSTATE_STOPPED -> { |
||||
|
"PLAYSTATE_STOPPED" |
||||
|
} |
||||
|
PLAYSTATE_PAUSED -> { |
||||
|
"PLAYSTATE_PAUSED" |
||||
|
} |
||||
|
PLAYSTATE_PLAYING -> { |
||||
|
"PLAYSTATE_PLAYING" |
||||
|
} |
||||
|
else -> { |
||||
|
"unknown" |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 播放文件 |
||||
|
* |
||||
|
* @param path |
||||
|
* @throws Exception |
||||
|
*/ |
||||
|
@Throws(Exception::class) |
||||
|
private fun setPath(path: String) { |
||||
|
val file = File(path) |
||||
|
mInputStream = DataInputStream(FileInputStream(file)) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 启动播放 |
||||
|
* |
||||
|
* @param path |
||||
|
*/ |
||||
|
fun startPlay(path: String) { |
||||
|
try { |
||||
|
//AudioTrack未初始化 |
||||
|
setPath(path) |
||||
|
startThread() |
||||
|
} catch (e: Exception) { |
||||
|
Log.d(TAG, "startPlay: Exception$e") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun startPlay(inputStream: InputStream?) { |
||||
|
initData() |
||||
|
try { |
||||
|
mInputStream = DataInputStream(inputStream) |
||||
|
startThread() |
||||
|
} catch (e: Exception) { |
||||
|
Log.d(TAG, "startPlay: Exception$e") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fun startPlayByFileDescriptor(fileDescriptor: ParcelFileDescriptor) { |
||||
|
mInputStream = ParcelFileDescriptor.AutoCloseInputStream(fileDescriptor) |
||||
|
startThread() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 停止播放 |
||||
|
*/ |
||||
|
fun stopPlay() { |
||||
|
try { |
||||
|
destroyThread() //销毁线程 |
||||
|
if (mAudioTrack != null) { |
||||
|
if (mAudioTrack!!.state == AudioRecord.STATE_INITIALIZED) { //初始化成功 |
||||
|
mAudioTrack!!.stop() //停止播放 |
||||
|
} |
||||
|
if (mAudioTrack != null) { |
||||
|
mAudioTrack!!.release() //释放audioTrack资源 |
||||
|
} |
||||
|
mAudioTrack = null |
||||
|
} |
||||
|
if (mInputStream != null) { |
||||
|
mInputStream!!.close() //关闭数据输入流 |
||||
|
} |
||||
|
} catch (e: Exception) { |
||||
|
e.printStackTrace() |
||||
|
} |
||||
|
enterForeground?.invoke(false) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
fun pause() { |
||||
|
mAudioTrack?.pause() |
||||
|
enterForeground?.invoke(false) |
||||
|
} |
||||
|
|
||||
|
fun play() { |
||||
|
mAudioTrack?.play() |
||||
|
enterForeground?.invoke(true) |
||||
|
} |
||||
|
|
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue