281 changed files with 12122 additions and 14206 deletions
@ -1,10 +0,0 @@ |
|||
{ |
|||
"mcpServers": { |
|||
"web-search": { |
|||
"url": "http://mcp.ideapsound.com:8000/sse" |
|||
}, |
|||
"amap-amap-sse": { |
|||
"url": "https://mcp.amap.com/sse?key=df7b0b6747c632fc38ddad22e7176646" |
|||
} |
|||
} |
|||
} |
|||
@ -1 +0,0 @@ |
|||
rootProject.name = 'amap_flutter' |
|||
@ -1,3 +0,0 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.example.amap_flutter"> |
|||
</manifest> |
|||
@ -1,169 +0,0 @@ |
|||
package com.example.amap_flutter; |
|||
|
|||
import android.content.Context; |
|||
import androidx.annotation.NonNull; |
|||
import android.util.Log; |
|||
|
|||
import com.amap.api.location.AMapLocation; |
|||
import com.amap.api.location.AMapLocationClient; |
|||
import com.amap.api.location.AMapLocationClientOption; |
|||
import com.amap.api.location.AMapLocationListener; |
|||
|
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin; |
|||
import io.flutter.plugin.common.EventChannel; |
|||
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 java.util.HashMap; |
|||
import java.util.Map; |
|||
|
|||
/** AmapFlutterPlugin */ |
|||
public class AmapFlutterPlugin implements FlutterPlugin, MethodCallHandler { |
|||
/// The MethodChannel that will the communication between Flutter and native Android
|
|||
///
|
|||
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
|
|||
/// when the Flutter Engine is detached from the Activity
|
|||
private MethodChannel methodChannel; |
|||
private EventChannel eventChannel; |
|||
private Context applicationContext; |
|||
private AMapLocationClient mLocationClient; |
|||
private EventChannel.EventSink eventSink; |
|||
|
|||
@Override |
|||
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { |
|||
applicationContext = flutterPluginBinding.getApplicationContext(); |
|||
methodChannel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter"); |
|||
methodChannel.setMethodCallHandler(this); |
|||
|
|||
// 初始化EventChannel
|
|||
eventChannel = new EventChannel(flutterPluginBinding.getBinaryMessenger(), "amap_flutter/events"); |
|||
eventChannel.setStreamHandler(new EventChannel.StreamHandler() { |
|||
@Override |
|||
public void onListen(Object arguments, EventChannel.EventSink events) { |
|||
eventSink = events; |
|||
} |
|||
|
|||
@Override |
|||
public void onCancel(Object arguments) { |
|||
eventSink = null; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@Override |
|||
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { |
|||
switch (call.method) { |
|||
case "setApiKey": |
|||
setApiKey(call, result); |
|||
break; |
|||
case "initLocation": |
|||
initLocation(result); |
|||
break; |
|||
case "startLocation": |
|||
startLocation(result); |
|||
break; |
|||
case "stopLocation": |
|||
stopLocation(result); |
|||
break; |
|||
case "dispose": |
|||
dispose(result); |
|||
break; |
|||
default: |
|||
result.notImplemented(); |
|||
} |
|||
} |
|||
|
|||
private void setApiKey(MethodCall call, Result result) { |
|||
try { |
|||
String apiKey = call.argument("androidKey"); |
|||
AMapLocationClient.updatePrivacyShow(applicationContext, true, true); |
|||
AMapLocationClient.updatePrivacyAgree(applicationContext, true); |
|||
result.success(null); |
|||
} catch (Exception e) { |
|||
result.error("INIT_ERROR", "初始化失败", e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
private void initLocation(Result result) { |
|||
try { |
|||
mLocationClient = new AMapLocationClient(applicationContext); |
|||
|
|||
// 配置定位参数
|
|||
AMapLocationClientOption option = new AMapLocationClientOption(); |
|||
option.setOnceLocation(true); |
|||
option.setNeedAddress(true); |
|||
mLocationClient.setLocationOption(option); |
|||
|
|||
mLocationClient.setLocationListener(mLocationListener); |
|||
result.success(null); |
|||
} catch (Exception e) { |
|||
result.error("INIT_ERROR", "初始化失败", e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
private void startLocation(Result result) { |
|||
mLocationClient.startLocation(); |
|||
result.success(null); |
|||
} |
|||
|
|||
private void stopLocation(Result result) { |
|||
destroyLocationClient(); |
|||
result.success(null); |
|||
} |
|||
|
|||
private void dispose(Result result) { |
|||
try { |
|||
destroyLocationClient(); |
|||
result.success(null); |
|||
} catch (Exception e) { |
|||
result.error("DISPOSE_ERROR", "资源释放失败", e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
private void destroyLocationClient() { |
|||
if (mLocationClient != null) { |
|||
try { |
|||
// 停止定位
|
|||
mLocationClient.stopLocation(); |
|||
// 销毁定位客户端
|
|||
mLocationClient.onDestroy(); |
|||
mLocationClient = null; |
|||
|
|||
// 关闭事件通道
|
|||
if (eventSink != null) { |
|||
eventSink.endOfStream(); |
|||
eventSink = null; |
|||
} |
|||
} catch (Exception e) { |
|||
Log.e("AmapFlutter", "销毁定位客户端失败", e); |
|||
} |
|||
} |
|||
} |
|||
|
|||
AMapLocationListener mLocationListener = new AMapLocationListener() { |
|||
@Override |
|||
public void onLocationChanged(AMapLocation amapLocation) { |
|||
if (eventSink == null) return; |
|||
|
|||
if (amapLocation != null) { |
|||
if (amapLocation.getErrorCode() == 0) { |
|||
Map<String, Object> locationData = new HashMap<>(); |
|||
locationData.put("latitude", amapLocation.getLatitude()); |
|||
locationData.put("longitude", amapLocation.getLongitude()); |
|||
locationData.put("city", amapLocation.getCity()); |
|||
locationData.put("address", amapLocation.getAddress()); |
|||
eventSink.success(locationData); |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
|
|||
@Override |
|||
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { |
|||
methodChannel.setMethodCallHandler(null); |
|||
eventChannel.setStreamHandler(null); |
|||
destroyLocationClient(); |
|||
} |
|||
} |
|||
@ -1,36 +0,0 @@ |
|||
import 'amap_flutter_platform_interface.dart'; |
|||
|
|||
class AmapFlutter { |
|||
// 初始化SDK |
|||
Future<void> initSdk({String? iosKey, String? androidKey}) async { |
|||
await AmapFlutterPlatform.instance.setApiKey( |
|||
iosKey: iosKey, |
|||
androidKey: androidKey, |
|||
); |
|||
} |
|||
|
|||
// 定位初始化 |
|||
Future<void> initLocation() async { |
|||
return AmapFlutterPlatform.instance.initLocation(); |
|||
} |
|||
|
|||
// 开始定位 |
|||
Future<void> startLocation() async { |
|||
return AmapFlutterPlatform.instance.startLocation(); |
|||
} |
|||
|
|||
// 监听定位返回位置 |
|||
Stream<dynamic> locationStream() { |
|||
return AmapFlutterPlatform.instance.locationStream(); |
|||
} |
|||
|
|||
// 停止定位 |
|||
Future<void> stopLocation() async { |
|||
await AmapFlutterPlatform.instance.stopLocation(); |
|||
} |
|||
|
|||
// 销毁 |
|||
Future<void> dispose() async { |
|||
await AmapFlutterPlatform.instance.dispose(); |
|||
} |
|||
} |
|||
@ -1,45 +0,0 @@ |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:flutter/services.dart'; |
|||
|
|||
import 'amap_flutter_platform_interface.dart'; |
|||
|
|||
/// An implementation of [AmapFlutterPlatform] that uses method channels. |
|||
class MethodChannelAmapFlutter extends AmapFlutterPlatform { |
|||
/// The method channel used to interact with the native platform. |
|||
@visibleForTesting |
|||
final methodChannel = const MethodChannel('amap_flutter'); |
|||
final eventChannel = const EventChannel('amap_flutter/events'); |
|||
|
|||
@override |
|||
Future<void> setApiKey({String? iosKey, String? androidKey}) async { |
|||
await methodChannel.invokeMethod('setApiKey', { |
|||
"iosKey": iosKey, |
|||
"androidKey": androidKey, |
|||
}); |
|||
} |
|||
|
|||
@override |
|||
Future<void> initLocation() async { |
|||
return await methodChannel.invokeMethod('initLocation'); |
|||
} |
|||
|
|||
@override |
|||
Future<void> startLocation() async { |
|||
return await methodChannel.invokeMethod('startLocation'); |
|||
} |
|||
|
|||
@override |
|||
Stream<dynamic> locationStream() { |
|||
return eventChannel.receiveBroadcastStream(); |
|||
} |
|||
|
|||
@override |
|||
Future<void> stopLocation() async { |
|||
await methodChannel.invokeMethod('stopLocation'); |
|||
} |
|||
|
|||
@override |
|||
Future<void> dispose() async { |
|||
await methodChannel.invokeMethod('dispose'); |
|||
} |
|||
} |
|||
@ -1,49 +0,0 @@ |
|||
import 'package:plugin_platform_interface/plugin_platform_interface.dart'; |
|||
|
|||
import 'amap_flutter_method_channel.dart'; |
|||
|
|||
abstract class AmapFlutterPlatform extends PlatformInterface { |
|||
/// Constructs a AmapFlutterPlatform. |
|||
AmapFlutterPlatform() : super(token: _token); |
|||
|
|||
static final Object _token = Object(); |
|||
|
|||
static AmapFlutterPlatform _instance = MethodChannelAmapFlutter(); |
|||
|
|||
/// The default instance of [AmapFlutterPlatform] to use. |
|||
/// |
|||
/// Defaults to [MethodChannelAmapFlutter]. |
|||
static AmapFlutterPlatform get instance => _instance; |
|||
|
|||
/// Platform-specific implementations should set this with their own |
|||
/// platform-specific class that extends [AmapFlutterPlatform] when |
|||
/// they register themselves. |
|||
static set instance(AmapFlutterPlatform instance) { |
|||
PlatformInterface.verifyToken(instance, _token); |
|||
_instance = instance; |
|||
} |
|||
|
|||
Future<void> setApiKey({String? iosKey, String? androidKey}) { |
|||
throw UnimplementedError('init() has not been implemented.'); |
|||
} |
|||
|
|||
Future<void> initLocation() { |
|||
throw UnimplementedError('initLocation() has not been implemented.'); |
|||
} |
|||
|
|||
Future<void> startLocation() { |
|||
throw UnimplementedError('startLocation() has not been implemented.'); |
|||
} |
|||
|
|||
Stream<dynamic> locationStream() { |
|||
throw UnimplementedError('locationStream() has not been implemented.'); |
|||
} |
|||
|
|||
Future<void> stopLocation() { |
|||
throw UnimplementedError('stopLocation() has not been implemented.'); |
|||
} |
|||
|
|||
Future<void> dispose() { |
|||
throw UnimplementedError('dispose() has not been implemented.'); |
|||
} |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>CLIENT_ID</key> |
|||
<string>907754492821-oc9e77svgmmlgouo0op7vv00e1hb8278.apps.googleusercontent.com</string> |
|||
<key>REVERSED_CLIENT_ID</key> |
|||
<string>com.googleusercontent.apps.907754492821-oc9e77svgmmlgouo0op7vv00e1hb8278</string> |
|||
<key>ANDROID_CLIENT_ID</key> |
|||
<string>907754492821-9jakqvathu61e6b8g95i41ssi7o4dnka.apps.googleusercontent.com</string> |
|||
<key>API_KEY</key> |
|||
<string>AIzaSyAvyBQcOXuO_q851vevqZIufsPRTMCe808</string> |
|||
<key>GCM_SENDER_ID</key> |
|||
<string>907754492821</string> |
|||
<key>PLIST_VERSION</key> |
|||
<string>1</string> |
|||
<key>BUNDLE_ID</key> |
|||
<string>com.yunqiinnovation.deepsound</string> |
|||
<key>PROJECT_ID</key> |
|||
<string>deepsound-e0e3d</string> |
|||
<key>STORAGE_BUCKET</key> |
|||
<string>deepsound-e0e3d.firebasestorage.app</string> |
|||
<key>IS_ADS_ENABLED</key> |
|||
<false></false> |
|||
<key>IS_ANALYTICS_ENABLED</key> |
|||
<false></false> |
|||
<key>IS_APPINVITE_ENABLED</key> |
|||
<true></true> |
|||
<key>IS_GCM_ENABLED</key> |
|||
<true></true> |
|||
<key>IS_SIGNIN_ENABLED</key> |
|||
<true></true> |
|||
<key>GOOGLE_APP_ID</key> |
|||
<string>1:907754492821:ios:246794fc44b23174145c9c</string> |
|||
</dict> |
|||
</plist> |
|||
@ -1,123 +0,0 @@ |
|||
{ |
|||
"originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", |
|||
"pins" : [ |
|||
{ |
|||
"identity" : "abseil-cpp-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/abseil-cpp-binary.git", |
|||
"state" : { |
|||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", |
|||
"version" : "1.2024072200.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "app-check", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/app-check.git", |
|||
"state" : { |
|||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5", |
|||
"version" : "11.2.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "firebase-ios-sdk", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/firebase-ios-sdk", |
|||
"state" : { |
|||
"revision" : "2e4a4daac9da51f79c131e76cb47e785dd7708c4", |
|||
"version" : "11.10.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleappmeasurement", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleAppMeasurement.git", |
|||
"state" : { |
|||
"revision" : "defe1970a9099d47765592ebd7ac5f5e9fdb57c6", |
|||
"version" : "11.10.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googledatatransport", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleDataTransport.git", |
|||
"state" : { |
|||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", |
|||
"version" : "10.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleutilities", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleUtilities.git", |
|||
"state" : { |
|||
"revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", |
|||
"version" : "8.0.2" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "grpc-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/grpc-binary.git", |
|||
"state" : { |
|||
"revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", |
|||
"version" : "1.69.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "gtm-session-fetcher", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/gtm-session-fetcher.git", |
|||
"state" : { |
|||
"revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35", |
|||
"version" : "4.4.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "interop-ios-for-google-sdks", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git", |
|||
"state" : { |
|||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", |
|||
"version" : "101.0.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "leveldb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/leveldb.git", |
|||
"state" : { |
|||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", |
|||
"version" : "1.22.5" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "nanopb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/nanopb.git", |
|||
"state" : { |
|||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", |
|||
"version" : "2.30910.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "promises", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/promises.git", |
|||
"state" : { |
|||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", |
|||
"version" : "2.4.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "swift-protobuf", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/apple/swift-protobuf.git", |
|||
"state" : { |
|||
"revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", |
|||
"version" : "1.29.0" |
|||
} |
|||
} |
|||
], |
|||
"version" : 3 |
|||
} |
|||
@ -1,123 +0,0 @@ |
|||
{ |
|||
"originHash" : "c63c63846d9c539229e96de38d6af51417e28c0ee9a0bc48bd0f0f19d923c329", |
|||
"pins" : [ |
|||
{ |
|||
"identity" : "abseil-cpp-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/abseil-cpp-binary.git", |
|||
"state" : { |
|||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", |
|||
"version" : "1.2024072200.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "app-check", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/app-check.git", |
|||
"state" : { |
|||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5", |
|||
"version" : "11.2.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "firebase-ios-sdk", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/firebase-ios-sdk", |
|||
"state" : { |
|||
"revision" : "2e4a4daac9da51f79c131e76cb47e785dd7708c4", |
|||
"version" : "11.10.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleappmeasurement", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleAppMeasurement.git", |
|||
"state" : { |
|||
"revision" : "defe1970a9099d47765592ebd7ac5f5e9fdb57c6", |
|||
"version" : "11.10.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googledatatransport", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleDataTransport.git", |
|||
"state" : { |
|||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", |
|||
"version" : "10.1.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "googleutilities", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/GoogleUtilities.git", |
|||
"state" : { |
|||
"revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", |
|||
"version" : "8.0.2" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "grpc-binary", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/grpc-binary.git", |
|||
"state" : { |
|||
"revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", |
|||
"version" : "1.69.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "gtm-session-fetcher", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/gtm-session-fetcher.git", |
|||
"state" : { |
|||
"revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35", |
|||
"version" : "4.4.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "interop-ios-for-google-sdks", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git", |
|||
"state" : { |
|||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", |
|||
"version" : "101.0.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "leveldb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/leveldb.git", |
|||
"state" : { |
|||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", |
|||
"version" : "1.22.5" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "nanopb", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/firebase/nanopb.git", |
|||
"state" : { |
|||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", |
|||
"version" : "2.30910.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "promises", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/google/promises.git", |
|||
"state" : { |
|||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", |
|||
"version" : "2.4.0" |
|||
} |
|||
}, |
|||
{ |
|||
"identity" : "swift-protobuf", |
|||
"kind" : "remoteSourceControl", |
|||
"location" : "https://github.com/apple/swift-protobuf.git", |
|||
"state" : { |
|||
"revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", |
|||
"version" : "1.29.0" |
|||
} |
|||
} |
|||
], |
|||
"version" : 3 |
|||
} |
|||
@ -1,743 +1,13 @@ |
|||
import Flutter |
|||
import UIKit |
|||
import AVFoundation |
|||
|
|||
@main |
|||
@objc class AppDelegate: FlutterAppDelegate { |
|||
private var classicBluetoothHelper: ClassicBluetoothHelper? |
|||
private var mediaButtonHelper: Any? |
|||
// 添加Azure语音服务辅助类 |
|||
private var azureAsrHelper: Any? |
|||
private var azureTtsHelper: Any? |
|||
// 添加火山AI服务 |
|||
private var volcanoAIService: VolcanoAIService? |
|||
// 语音交互服务 |
|||
private var voiceInteractionService: VoiceInteractionService? |
|||
|
|||
private var audioSessionManager: AudioSessionManager? |
|||
|
|||
override func application( |
|||
_ application: UIApplication, |
|||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? |
|||
) -> Bool { |
|||
|
|||
// 获取flutter控制器 |
|||
guard let controller = window?.rootViewController as? FlutterViewController else { |
|||
NSLog("AppDelegate: Flutter控制器不可用") |
|||
return false |
|||
} |
|||
|
|||
// 初始化音频会话管理器 |
|||
audioSessionManager = AudioSessionManager.shared |
|||
|
|||
|
|||
// 统一注册所有方法通道 |
|||
registerMethodChannel() |
|||
|
|||
GeneratedPluginRegistrant.register(with: self) |
|||
return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
|||
} |
|||
|
|||
private func registerMethodChannel() { |
|||
let controller = window?.rootViewController as? FlutterViewController |
|||
guard let controller = controller else { |
|||
NSLog("AppDelegate: FlutterViewController 不可用") |
|||
return |
|||
} |
|||
|
|||
// 注册经典蓝牙方法通道 |
|||
registerClassicBluetoothChannels(controller: controller) |
|||
|
|||
// 仅在iOS 13.0及以上版本注册媒体按钮通道和Azure语音服务通道 |
|||
if #available(iOS 13.0, *) { |
|||
// 注册媒体按钮方法通道 |
|||
registerMediaButtonChannels(controller: controller) |
|||
|
|||
// 注册Azure ASR方法通道 |
|||
registerAzureAsrChannels(controller: controller) |
|||
|
|||
// 注册Azure TTS方法通道 |
|||
registerAzureTtsChannels(controller: controller) |
|||
|
|||
// 注册火山AI服务方法通道 |
|||
registerVolcanoAIServiceChannels(controller: controller) |
|||
|
|||
// 注册语音交互服务方法通道 |
|||
registerVoiceInteractionChannels(controller: controller) |
|||
} |
|||
} |
|||
|
|||
// MARK: - 经典蓝牙通道 |
|||
|
|||
private func registerClassicBluetoothChannels(controller: FlutterViewController) { |
|||
// 初始化蓝牙辅助类 |
|||
if classicBluetoothHelper == nil { |
|||
classicBluetoothHelper = ClassicBluetoothHelper() |
|||
} |
|||
|
|||
guard let classicBluetoothHelper = self.classicBluetoothHelper else { |
|||
NSLog("AppDelegate: 经典蓝牙辅助实例不可用") |
|||
return |
|||
} |
|||
|
|||
// 注册经典蓝牙方法通道 |
|||
let classicBluetoothChannel = FlutterMethodChannel( |
|||
name: "com.deep_voice.classic_bluetooth", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
classicBluetoothChannel.setMethodCallHandler { [weak self, weak classicBluetoothHelper] (call, result) in |
|||
guard let _ = self, let classicBluetoothHelper = classicBluetoothHelper else { |
|||
result(FlutterError(code: "UNAVAILABLE", message: "经典蓝牙辅助实例不可用", details: nil)) |
|||
return |
|||
} |
|||
|
|||
switch call.method { |
|||
case "isBluetoothEnabled": |
|||
result(classicBluetoothHelper.isBluetoothEnabled()) |
|||
|
|||
case "getConnectedHeadsetDevices": |
|||
// 获取已连接的耳机设备 |
|||
classicBluetoothHelper.getConnectedHeadsetDevices { devices, error in |
|||
if let error = error { |
|||
result(FlutterError(code: "GET_DEVICES_FAILED", message: error, details: nil)) |
|||
} else { |
|||
result(devices) |
|||
} |
|||
} |
|||
case "initialize": |
|||
classicBluetoothHelper.initialize() |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// 注册蓝牙流通道 |
|||
let classicBluetoothStreamChannel = FlutterEventChannel( |
|||
name: "com.deep_voice.classic_bluetooth_events", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 创建蓝牙流处理器(作为局部变量) |
|||
let bluetoothStreamHandler = BluetoothStreamHandler(bluetoothHelper: classicBluetoothHelper) |
|||
classicBluetoothStreamChannel.setStreamHandler(bluetoothStreamHandler) |
|||
} |
|||
|
|||
// MARK: - 消息通道设置 |
|||
|
|||
// MARK: - 蓝牙媒体按键通道 |
|||
|
|||
@available(iOS 13.0, *) |
|||
private func registerMediaButtonChannels(controller: FlutterViewController) { |
|||
// 初始化媒体按钮辅助类 |
|||
if mediaButtonHelper == nil { |
|||
mediaButtonHelper = BluetoothMediaButtonHelper() |
|||
} |
|||
// guard let mediaButtonHelper = self.mediaButtonHelper as? BluetoothMediaButtonHelper else { |
|||
// NSLog("AppDelegate: 媒体按钮辅助实例不可用") |
|||
// return |
|||
// } |
|||
// mediaButtonHelper.startButtonListening() |
|||
// 注册媒体按钮方法通道 |
|||
let mediaButtonChannel = FlutterMethodChannel( |
|||
name: "com.deep_voice.bluetooth_media_button", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
mediaButtonChannel.setMethodCallHandler { [weak self] (call, result) in |
|||
guard let _ = self, let mediaButtonHelper = self?.mediaButtonHelper as? BluetoothMediaButtonHelper else { |
|||
result(FlutterError(code: "UNAVAILABLE", message: "媒体按钮辅助实例不可用", details: nil)) |
|||
return |
|||
} |
|||
|
|||
switch call.method { |
|||
case "startButtonListening": |
|||
let success = mediaButtonHelper.startButtonListening() |
|||
result(success) |
|||
|
|||
case "stopButtonListening": |
|||
let success = mediaButtonHelper.stopButtonListening() |
|||
result(success) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// 注册媒体按钮流通道 |
|||
let mediaButtonStreamChannel = FlutterEventChannel( |
|||
name: "com.deep_voice.bluetooth_media_button_events", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 创建媒体按钮流处理器 |
|||
let mediaButtonStreamHandler = MediaButtonStreamHandler(mediaButtonHelper: mediaButtonHelper as! BluetoothMediaButtonHelper) |
|||
mediaButtonStreamChannel.setStreamHandler(mediaButtonStreamHandler) |
|||
} |
|||
|
|||
|
|||
// 注册Azure ASR通道 |
|||
@available(iOS 13.0, *) |
|||
private func registerAzureAsrChannels(controller: FlutterViewController) { |
|||
// 注册Azure ASR方法通道 |
|||
let azureAsrChannel = FlutterMethodChannel( |
|||
name: "com.deep_voice.azure_asr", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 初始化Azure ASR辅助类 |
|||
azureAsrHelper = AzureAsrHelper() |
|||
|
|||
// 设置ASR方法处理器 |
|||
azureAsrChannel.setMethodCallHandler { [weak self] (call, result) in |
|||
guard let self = self, |
|||
let asrHelper = self.azureAsrHelper as? AzureAsrHelper else { |
|||
result(FlutterError(code: "UNAVAILABLE", message: "Azure ASR辅助实例不可用", details: nil)) |
|||
return |
|||
} |
|||
|
|||
switch call.method { |
|||
case "initialize": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let key = args["subscriptionKey"] as? String, |
|||
let region = args["region"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let languages = args["supportedLanguages"] as? [String] |
|||
let success = asrHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, supportedLanguages: languages) |
|||
result(success) |
|||
|
|||
case "recognizeOnce": |
|||
let success = asrHelper.recognizeOnce() |
|||
result(success) |
|||
|
|||
case "startContinuousRecognition": |
|||
let success = asrHelper.startContinuousRecognition() |
|||
result(success) |
|||
|
|||
case "stopContinuousRecognition": |
|||
let success = asrHelper.stopContinuousRecognition() |
|||
result(success) |
|||
|
|||
case "isContinuousRecognitionActive": |
|||
let isActive = asrHelper.isContinuousRecognitionActive() |
|||
result(isActive) |
|||
|
|||
case "dispose": |
|||
asrHelper.dispose() |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// 注册Azure ASR流通道 |
|||
let azureAsrStreamChannel = FlutterEventChannel( |
|||
name: "com.deep_voice.azure_asr_events", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 创建ASR流处理器 |
|||
let asrStreamHandler = AsrStreamHandler(azureAsrHelper: azureAsrHelper as! AzureAsrHelper) |
|||
azureAsrStreamChannel.setStreamHandler(asrStreamHandler) |
|||
} |
|||
|
|||
// 注册Azure TTS通道 |
|||
@available(iOS 13.0, *) |
|||
private func registerAzureTtsChannels(controller: FlutterViewController) { |
|||
// 注册Azure TTS方法通道 |
|||
let azureTtsChannel = FlutterMethodChannel( |
|||
name: "com.deep_voice.azure_tts", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 初始化Azure TTS辅助类 |
|||
azureTtsHelper = AzureTtsHelper() |
|||
|
|||
// 设置TTS方法处理器 |
|||
azureTtsChannel.setMethodCallHandler { [weak self] (call, result) in |
|||
guard let self = self, |
|||
let ttsHelper = self.azureTtsHelper as? AzureTtsHelper else { |
|||
result(FlutterError(code: "UNAVAILABLE", message: "Azure TTS辅助实例不可用", details: nil)) |
|||
return |
|||
} |
|||
|
|||
switch call.method { |
|||
case "initialize": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let key = args["subscriptionKey"] as? String, |
|||
let region = args["region"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let language = args["language"] as? String ?? "zh-CN" |
|||
let success = ttsHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, language: language) |
|||
result(success) |
|||
|
|||
case "setVoice": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let voiceName = args["voiceName"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGS", message: "语音名称参数无效", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = ttsHelper.setVoice(voiceName: voiceName) |
|||
result(success) |
|||
|
|||
case "setSpeechParams": |
|||
guard let args = call.arguments as? [String: Any] else { |
|||
result(FlutterError(code: "INVALID_ARGS", message: "语音参数无效", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let rate = args["rate"] as? Int ?? 0 |
|||
let pitch = args["pitch"] as? Int ?? 0 |
|||
let volume = args["volume"] as? Int ?? 100 |
|||
|
|||
let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) |
|||
result(success) |
|||
|
|||
case "speakText": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGS", message: "文本参数无效", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = ttsHelper.speakText(text: text) |
|||
result(success) |
|||
|
|||
case "stopSpeaking": |
|||
let success = ttsHelper.stopSpeaking() |
|||
result(success) |
|||
|
|||
case "isSpeaking": |
|||
let isSpeaking = ttsHelper.isSpeaking() |
|||
result(isSpeaking) |
|||
|
|||
case "dispose": |
|||
ttsHelper.dispose() |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// 注册Azure TTS流通道 |
|||
let azureTtsStreamChannel = FlutterEventChannel( |
|||
name: "com.deep_voice.azure_tts_events", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 创建TTS流处理器 |
|||
let ttsStreamHandler = TtsStreamHandler(azureTtsHelper: azureTtsHelper as! AzureTtsHelper) |
|||
azureTtsStreamChannel.setStreamHandler(ttsStreamHandler) |
|||
} |
|||
|
|||
// 注册火山AI服务方法通道 |
|||
@available(iOS 13.0, *) |
|||
private func registerVolcanoAIServiceChannels(controller: FlutterViewController) { |
|||
guard let volcanoAIService = volcanoAIService else { |
|||
return |
|||
} |
|||
|
|||
// 注册火山AI方法通道 |
|||
let volcanoAIChannel = FlutterMethodChannel( |
|||
name: "com.deep_voice.volcano_ai", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
volcanoAIChannel.setMethodCallHandler { [weak self, weak volcanoAIService] (call, result) in |
|||
guard let _ = self, let volcanoAIService = volcanoAIService else { |
|||
result(FlutterError(code: "UNAVAILABLE", message: "火山AI服务实例不可用", details: nil)) |
|||
return |
|||
} |
|||
|
|||
switch call.method { |
|||
case "initialize": |
|||
let apiKey = call.arguments as? String ?? "" |
|||
let success = volcanoAIService.initialize(apiKey: apiKey) |
|||
result(success) |
|||
|
|||
case "sendMessage": |
|||
guard let arguments = call.arguments as? [String: Any], |
|||
let message = arguments["message"] as? String, |
|||
let systemPrompt = arguments["systemPrompt"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) |
|||
return |
|||
} |
|||
|
|||
if message.isEmpty { |
|||
result(FlutterError(code: "EMPTY_MESSAGE", message: "消息不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
// 异步发送消息 |
|||
Task { |
|||
do { |
|||
// 创建用户消息 |
|||
let userMessage = volcanoAIService.createUserMessage(content: message) |
|||
|
|||
// 发送消息并等待响应 |
|||
let response = try await volcanoAIService.sendMessage( |
|||
messages: [userMessage], |
|||
systemPrompt: systemPrompt |
|||
) |
|||
|
|||
// 返回响应 |
|||
DispatchQueue.main.async { |
|||
result(response) |
|||
} |
|||
} catch { |
|||
// 处理错误 |
|||
DispatchQueue.main.async { |
|||
result(FlutterError( |
|||
code: "AI_ERROR", |
|||
message: "AI请求失败: \(error.localizedDescription)", |
|||
details: nil |
|||
)) |
|||
} |
|||
} |
|||
} |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// 注册火山AI流通道 |
|||
let volcanoAIStreamChannel = FlutterEventChannel( |
|||
name: "com.deep_voice.volcano_ai_stream", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 创建火山AI流处理器(作为局部变量) |
|||
let volcanoAIStreamHandler = VolcanoAIStreamHandler(volcanoAIService: volcanoAIService) |
|||
volcanoAIStreamChannel.setStreamHandler(volcanoAIStreamHandler) |
|||
} |
|||
|
|||
// 注册语音交互服务方法通道 |
|||
@available(iOS 13.0, *) |
|||
private func registerVoiceInteractionChannels(controller: FlutterViewController) { |
|||
// 初始化语音交互服务 |
|||
voiceInteractionService = VoiceInteractionService.shared |
|||
|
|||
// 注册语音交互服务方法通道 |
|||
let voiceInteractionChannel = FlutterMethodChannel( |
|||
name: "com.deep_voice.voice_interaction", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
voiceInteractionChannel.setMethodCallHandler { [weak self, weak voiceInteractionService] (call, result) in |
|||
guard let _ = self, let voiceInteractionService = voiceInteractionService else { |
|||
result(FlutterError(code: "UNAVAILABLE", message: "语音交互服务实例不可用", details: nil)) |
|||
return |
|||
} |
|||
|
|||
switch call.method { |
|||
case "startService": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let azureKey = args["azure_speech_key"] as? String, |
|||
let azureRegion = args["azure_speech_region"] as? String, |
|||
let volcanoKey = args["volcano_ai_api_key"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGS", message: "启动服务参数无效", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = voiceInteractionService.start(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) |
|||
result(success) |
|||
|
|||
case "stopService": |
|||
voiceInteractionService.stop() |
|||
result(true) |
|||
|
|||
case "isServiceRunning": |
|||
let isRunning = voiceInteractionService.isServiceRunning() |
|||
result(isRunning) |
|||
|
|||
case "pauseVoiceInteraction": |
|||
voiceInteractionService.pauseVoiceInteraction() |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// 注册语音交互流通道 |
|||
let voiceInteractionStreamChannel = FlutterEventChannel( |
|||
name: "com.deep_voice.voice_interaction_events", |
|||
binaryMessenger: controller.binaryMessenger |
|||
) |
|||
|
|||
// 创建语音交互流处理器(作为局部变量) |
|||
let voiceInteractionStreamHandler = VoiceInteractionStreamHandler(voiceInteractionService: voiceInteractionService!) |
|||
voiceInteractionStreamChannel.setStreamHandler(voiceInteractionStreamHandler) |
|||
} |
|||
} |
|||
|
|||
// 蓝牙事件流处理器 |
|||
class BluetoothStreamHandler: NSObject, FlutterStreamHandler, BluetoothEventHandler { |
|||
private let bluetoothHelper: ClassicBluetoothHelper |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
init(bluetoothHelper: ClassicBluetoothHelper) { |
|||
self.bluetoothHelper = bluetoothHelper |
|||
super.init() |
|||
|
|||
// 设置事件处理器 |
|||
bluetoothHelper.setEventHandler(self) |
|||
} |
|||
|
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
// NSLog("BluetoothStreamHandler: 开始监听蓝牙事件") |
|||
self.eventSink = events |
|||
return nil |
|||
} |
|||
|
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
// NSLog("BluetoothStreamHandler: 取消监听蓝牙事件") |
|||
eventSink = nil |
|||
return nil |
|||
} |
|||
|
|||
// 实现BluetoothEventHandler协议 |
|||
func sendEvent(_ event: [String: Any]) { |
|||
NSLog("BluetoothStreamHandler: 接收到蓝牙事件: \(event)") |
|||
DispatchQueue.main.async { [weak self] in |
|||
self?.eventSink?(event) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 媒体按钮事件流处理器 |
|||
@available(iOS 13.0, *) |
|||
class MediaButtonStreamHandler: NSObject, FlutterStreamHandler, MediaButtonEventHandler { |
|||
private let mediaButtonHelper: BluetoothMediaButtonHelper |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
init(mediaButtonHelper: BluetoothMediaButtonHelper) { |
|||
self.mediaButtonHelper = mediaButtonHelper |
|||
super.init() |
|||
|
|||
// 设置事件处理器 |
|||
mediaButtonHelper.setEventHandler(self) |
|||
} |
|||
|
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
NSLog("MediaButtonStreamHandler: 开始监听媒体按钮事件") |
|||
self.eventSink = events |
|||
return nil |
|||
} |
|||
|
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
NSLog("MediaButtonStreamHandler: 取消监听媒体按钮事件") |
|||
eventSink = nil |
|||
return nil |
|||
} |
|||
|
|||
// 实现MediaButtonEventHandler协议 |
|||
func sendEvent(_ event: [String: Any]) { |
|||
NSLog("MediaButtonStreamHandler: 收到按钮事件 \(event)") |
|||
DispatchQueue.main.async { [weak self] in |
|||
self?.eventSink?(event) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ASR事件处理器协议 |
|||
@available(iOS 13.0, *) |
|||
protocol AsrEventHandler: AnyObject { |
|||
func sendEvent(_ event: [String: Any]) |
|||
} |
|||
|
|||
// ASR事件流处理器 |
|||
@available(iOS 13.0, *) |
|||
class AsrStreamHandler: NSObject, FlutterStreamHandler, AsrEventHandler { |
|||
private let azureAsrHelper: AzureAsrHelper |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
init(azureAsrHelper: AzureAsrHelper) { |
|||
self.azureAsrHelper = azureAsrHelper |
|||
super.init() |
|||
|
|||
// 设置事件处理器 |
|||
azureAsrHelper.setEventHandler { [weak self] event in |
|||
self?.sendEvent(event) |
|||
} |
|||
} |
|||
|
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
NSLog("AsrStreamHandler: 开始监听ASR事件") |
|||
self.eventSink = events |
|||
return nil |
|||
} |
|||
|
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
NSLog("AsrStreamHandler: 取消监听ASR事件") |
|||
eventSink = nil |
|||
return nil |
|||
} |
|||
|
|||
// 实现AsrEventHandler协议 |
|||
func sendEvent(_ event: [String: Any]) { |
|||
// NSLog("AsrStreamHandler: 收到事件 \(event)") |
|||
DispatchQueue.main.async { [weak self] in |
|||
self?.eventSink?(event) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// TTS事件处理器协议 |
|||
@available(iOS 13.0, *) |
|||
protocol TtsEventHandler: AnyObject { |
|||
func sendEvent(_ event: [String: Any]) |
|||
} |
|||
|
|||
// TTS事件流处理器 |
|||
@available(iOS 13.0, *) |
|||
class TtsStreamHandler: NSObject, FlutterStreamHandler, TtsEventHandler { |
|||
private let azureTtsHelper: AzureTtsHelper |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
init(azureTtsHelper: AzureTtsHelper) { |
|||
self.azureTtsHelper = azureTtsHelper |
|||
super.init() |
|||
|
|||
// 设置事件处理器 |
|||
azureTtsHelper.setEventHandler { [weak self] event in |
|||
self?.sendEvent(event) |
|||
} |
|||
} |
|||
|
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
NSLog("TtsStreamHandler: 开始监听TTS事件") |
|||
self.eventSink = events |
|||
return nil |
|||
} |
|||
|
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
NSLog("TtsStreamHandler: 取消监听TTS事件") |
|||
eventSink = nil |
|||
return nil |
|||
} |
|||
|
|||
// 实现TtsEventHandler协议 |
|||
func sendEvent(_ event: [String: Any]) { |
|||
NSLog("TtsStreamHandler: 收到事件 \(event)") |
|||
DispatchQueue.main.async { [weak self] in |
|||
self?.eventSink?(event) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 火山AI事件流处理器 |
|||
@available(iOS 13.0, *) |
|||
class VolcanoAIStreamHandler: NSObject, FlutterStreamHandler { |
|||
private let volcanoAIService: VolcanoAIService |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
init(volcanoAIService: VolcanoAIService) { |
|||
self.volcanoAIService = volcanoAIService |
|||
super.init() |
|||
} |
|||
|
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
self.eventSink = events |
|||
return nil |
|||
} |
|||
|
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
eventSink = nil |
|||
return nil |
|||
} |
|||
|
|||
// 处理来自Flutter的流式请求 |
|||
func handleStreamRequest(messages: [[String: Any]], systemPrompt: String) { |
|||
volcanoAIService.sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: VolcanoAIService.StreamCallback( |
|||
onToken: { [weak self] token in |
|||
guard let self = self, let eventSink = self.eventSink else { return } |
|||
|
|||
let event: [String: Any] = [ |
|||
"type": "token", |
|||
"token": token |
|||
] |
|||
|
|||
DispatchQueue.main.async { |
|||
eventSink(event) |
|||
} |
|||
}, |
|||
onComplete: { [weak self] in |
|||
guard let self = self, let eventSink = self.eventSink else { return } |
|||
|
|||
let event: [String: Any] = [ |
|||
"type": "complete" |
|||
] |
|||
|
|||
DispatchQueue.main.async { |
|||
eventSink(event) |
|||
} |
|||
}, |
|||
onError: { [weak self] error in |
|||
guard let self = self, let eventSink = self.eventSink else { return } |
|||
|
|||
let event: [String: Any] = [ |
|||
"type": "error", |
|||
"error": error.localizedDescription |
|||
] |
|||
|
|||
DispatchQueue.main.async { |
|||
eventSink(event) |
|||
} |
|||
} |
|||
)) |
|||
} |
|||
} |
|||
|
|||
// 语音交互事件处理器协议 |
|||
@available(iOS 13.0, *) |
|||
protocol VoiceInteractionEventHandler: AnyObject { |
|||
func sendEvent(_ event: [String: Any]) |
|||
} |
|||
|
|||
// 语音交互流处理器 |
|||
@available(iOS 13.0, *) |
|||
class VoiceInteractionStreamHandler: NSObject, FlutterStreamHandler, VoiceInteractionEventHandler { |
|||
private let voiceInteractionService: VoiceInteractionService |
|||
private var eventSink: FlutterEventSink? |
|||
|
|||
init(voiceInteractionService: VoiceInteractionService) { |
|||
self.voiceInteractionService = voiceInteractionService |
|||
super.init() |
|||
|
|||
// 设置事件处理器 |
|||
voiceInteractionService.setEventHandler(self) |
|||
} |
|||
|
|||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
NSLog("VoiceInteractionStreamHandler: 开始监听语音交互事件") |
|||
self.eventSink = events |
|||
return nil |
|||
} |
|||
|
|||
func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
NSLog("VoiceInteractionStreamHandler: 取消监听语音交互事件") |
|||
eventSink = nil |
|||
return nil |
|||
} |
|||
|
|||
// 实现VoiceInteractionEventHandler协议 |
|||
func sendEvent(_ event: [String: Any]) { |
|||
NSLog("VoiceInteractionStreamHandler: 收到事件 \(event)") |
|||
DispatchQueue.main.async { [weak self] in |
|||
self?.eventSink?(event) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -1,589 +0,0 @@ |
|||
import Foundation |
|||
import AVFoundation |
|||
import UIKit |
|||
|
|||
/// 统一音频会话管理类 |
|||
/// 用于统一管理所有组件的音频会话配置 |
|||
@objc class AudioSessionManager: NSObject { |
|||
// MARK: - 单例实现 |
|||
|
|||
@objc static let shared = AudioSessionManager() |
|||
|
|||
// 音频会话 |
|||
private let audioSession = AVAudioSession.sharedInstance() |
|||
|
|||
// 音频会话状态 |
|||
private var isConfigured = false |
|||
private var isActive = false |
|||
|
|||
// 当前配置类型 |
|||
private var currentCategory: AVAudioSession.Category? |
|||
private var currentMode: AVAudioSession.Mode? |
|||
private var currentOptions: AVAudioSession.CategoryOptions? |
|||
|
|||
// 中断监听器 |
|||
private var interruptionObserver: NSObjectProtocol? |
|||
|
|||
// 路由变化监听器 |
|||
private var routeChangeObserver: NSObjectProtocol? |
|||
|
|||
// 私有初始化方法,确保只能通过shared访问 |
|||
private override init() { |
|||
super.init() |
|||
NSLog("[AudioSessionManager] 音频会话管理器已初始化") |
|||
setupNotifications() |
|||
|
|||
} |
|||
|
|||
deinit { |
|||
removeNotifications() |
|||
} |
|||
|
|||
|
|||
// MARK: - 通知设置 |
|||
|
|||
private func setupNotifications() { |
|||
// 监听中断事件 |
|||
interruptionObserver = NotificationCenter.default.addObserver( |
|||
forName: AVAudioSession.interruptionNotification, |
|||
object: nil, |
|||
queue: .main) { [weak self] notification in |
|||
self?.handleInterruption(notification) |
|||
} |
|||
|
|||
// 监听路由变化事件 |
|||
routeChangeObserver = NotificationCenter.default.addObserver( |
|||
forName: AVAudioSession.routeChangeNotification, |
|||
object: nil, |
|||
queue: .main) { [weak self] notification in |
|||
self?.handleRouteChange(notification) |
|||
} |
|||
|
|||
// 监听应用进入后台/前台 |
|||
NotificationCenter.default.addObserver( |
|||
self, |
|||
selector: #selector(handleAppDidEnterBackground), |
|||
name: UIApplication.didEnterBackgroundNotification, |
|||
object: nil) |
|||
|
|||
NotificationCenter.default.addObserver( |
|||
self, |
|||
selector: #selector(handleAppWillEnterForeground), |
|||
name: UIApplication.willEnterForegroundNotification, |
|||
object: nil) |
|||
} |
|||
|
|||
private func removeNotifications() { |
|||
if let interruptionObserver = interruptionObserver { |
|||
NotificationCenter.default.removeObserver(interruptionObserver) |
|||
} |
|||
|
|||
if let routeChangeObserver = routeChangeObserver { |
|||
NotificationCenter.default.removeObserver(routeChangeObserver) |
|||
} |
|||
|
|||
NotificationCenter.default.removeObserver(self) |
|||
} |
|||
|
|||
// MARK: - 通知处理 |
|||
|
|||
private func handleInterruption(_ notification: Notification) { |
|||
guard let userInfo = notification.userInfo, |
|||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, |
|||
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { |
|||
return |
|||
} |
|||
|
|||
switch type { |
|||
case .began: |
|||
// 中断开始 |
|||
NSLog("[AudioSessionManager] 音频会话被中断") |
|||
isActive = false |
|||
|
|||
case .ended: |
|||
// 中断结束,尝试恢复 |
|||
if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { |
|||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) |
|||
if options.contains(.shouldResume) { |
|||
NSLog("[AudioSessionManager] 尝试恢复被中断的音频会话") |
|||
try? activateSession() |
|||
} |
|||
} |
|||
|
|||
@unknown default: |
|||
break |
|||
} |
|||
} |
|||
|
|||
private func handleRouteChange(_ notification: Notification) { |
|||
guard let userInfo = notification.userInfo, |
|||
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, |
|||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { |
|||
return |
|||
} |
|||
|
|||
switch reason { |
|||
case .newDeviceAvailable: |
|||
// 新设备连接 |
|||
let currentRoute = audioSession.currentRoute |
|||
for output in currentRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { |
|||
NSLog("[AudioSessionManager] 检测到蓝牙音频设备连接: \(output.portName)") |
|||
// 蓝牙设备连接,可能需要更新配置 |
|||
if isActive && currentOptions?.contains(.allowBluetooth) == true { |
|||
try? forceReconfigure() |
|||
} |
|||
break |
|||
} |
|||
|
|||
case .oldDeviceUnavailable: |
|||
// 设备断开连接 |
|||
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { |
|||
for output in previousRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { |
|||
NSLog("[AudioSessionManager] 蓝牙音频设备断开连接: \(output.portName)") |
|||
// 蓝牙设备断开,重新配置到内置扬声器 |
|||
if isActive { |
|||
try? forceReconfigure() |
|||
} |
|||
break |
|||
} |
|||
} |
|||
|
|||
default: |
|||
break |
|||
} |
|||
} |
|||
|
|||
@objc private func handleAppDidEnterBackground() { |
|||
NSLog("[AudioSessionManager] 应用进入后台") |
|||
// 应用进入后台时可能需要调整音频会话 |
|||
|
|||
} |
|||
|
|||
@objc private func handleAppWillEnterForeground() { |
|||
NSLog("[AudioSessionManager] 应用即将进入前台") |
|||
// 应用返回前台,检查并恢复音频会话 |
|||
if isConfigured && !isActive { |
|||
try? activateSession() |
|||
} |
|||
} |
|||
|
|||
// MARK: - 音频会话配置 |
|||
|
|||
/// 统一配置方法 - 所有音频配置都通过此方法进行 |
|||
@objc func configureSession(category: AVAudioSession.Category, |
|||
mode: AVAudioSession.Mode, |
|||
options: AVAudioSession.CategoryOptions, |
|||
force: Bool = false) throws { |
|||
|
|||
NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode), 选项=\(options), 强制=\(force)") |
|||
|
|||
// 如果已配置且不强制重新配置,则直接返回 |
|||
if isConfigured && !force && |
|||
currentCategory == category && |
|||
currentMode == mode && |
|||
currentOptions == options { |
|||
NSLog("[AudioSessionManager] 音频会话已配置,跳过配置") |
|||
return |
|||
} |
|||
|
|||
do { |
|||
// NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode)") |
|||
|
|||
// 暂时停用当前会话(如果正在活动) |
|||
if isActive { |
|||
try audioSession.setActive(false, options: .notifyOthersOnDeactivation) |
|||
} |
|||
|
|||
// 设置新的分类、模式和选项 |
|||
try audioSession.setCategory(category, mode: mode, options: options) |
|||
|
|||
// 设置采样率和缓冲区大小(对于语音识别优化) |
|||
// if mode == .spokenAudio { |
|||
// try audioSession.setPreferredSampleRate(16000) |
|||
// try audioSession.setPreferredIOBufferDuration(0.01) // 10ms |
|||
// } |
|||
|
|||
// 重新激活会话 |
|||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
|||
isActive = true |
|||
// 切换到扬声器 |
|||
// 记录当前配置 |
|||
currentCategory = category |
|||
currentMode = mode |
|||
currentOptions = options |
|||
isConfigured = true |
|||
|
|||
// 检查并设置首选的输入设备(如果需要) |
|||
if category == .playAndRecord || category == .record { |
|||
try configurePreferredInput() |
|||
} |
|||
return |
|||
} catch { |
|||
NSLog("[AudioSessionManager] 配置音频会话失败: \(error.localizedDescription)") |
|||
throw error |
|||
} |
|||
} |
|||
|
|||
/// 配置首选输入设备 |
|||
private func configurePreferredInput() throws { |
|||
// 获取可用输入设备 |
|||
guard let availableInputs = audioSession.availableInputs else { |
|||
NSLog("[AudioSessionManager] 无法获取可用输入设备") |
|||
return |
|||
} |
|||
|
|||
// 首选顺序:有线耳机 > 蓝牙耳机 > 内置麦克风 |
|||
var preferredInput: AVAudioSessionPortDescription? = nil |
|||
|
|||
// 先尝试找到有线耳机 |
|||
if let wiredHeadset = availableInputs.first(where: { $0.portType == .headsetMic }) { |
|||
preferredInput = wiredHeadset |
|||
NSLog("[AudioSessionManager] 设置首选输入设备: 有线耳机") |
|||
} |
|||
// 如果没有有线耳机,尝试使用蓝牙 |
|||
else if let bluetooth = availableInputs.first(where: { $0.portType == .bluetoothHFP }) { |
|||
preferredInput = bluetooth |
|||
NSLog("[AudioSessionManager] 设置首选输入设备: 蓝牙耳机") |
|||
} |
|||
// 如果没有外部设备,使用内置麦克风 |
|||
else if let builtIn = availableInputs.first(where: { $0.portType == .builtInMic }) { |
|||
preferredInput = builtIn |
|||
NSLog("[AudioSessionManager] 设置首选输入设备: 内置麦克风") |
|||
} |
|||
|
|||
// 设置首选输入设备 |
|||
if let input = preferredInput { |
|||
try audioSession.setPreferredInput(input) |
|||
} |
|||
} |
|||
|
|||
|
|||
|
|||
/// 激活音频会话(如果已配置) |
|||
@objc func activateSession() throws { |
|||
if !isConfigured { |
|||
NSLog("[AudioSessionManager] 警告: 尝试激活未配置的音频会话,应用默认配置") |
|||
// 未配置,应用默认配置 |
|||
// try configureSession( |
|||
// category: .playAndRecord, |
|||
// mode: .spokenAudio, |
|||
// options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers, .defaultToSpeaker], |
|||
// force: true |
|||
// ) |
|||
|
|||
try configureSession( |
|||
category: .playAndRecord, // 允许同时录音和播放 |
|||
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|||
options: [ |
|||
.allowBluetooth, // 允许蓝牙设备 |
|||
.defaultToSpeaker, // 默认使用扬声器 |
|||
.mixWithOthers // 允许与其他应用混音 |
|||
], |
|||
force: false // 是否强制重新配置 |
|||
) |
|||
|
|||
|
|||
} else if !isActive { |
|||
NSLog("[AudioSessionManager] 激活已配置的音频会话") |
|||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) |
|||
isActive = true |
|||
} |
|||
} |
|||
|
|||
/// 停用音频会话(保留配置) |
|||
@objc func deactivateSession() throws { |
|||
if isActive { |
|||
NSLog("[AudioSessionManager] 停用音频会话") |
|||
try audioSession.setActive(false, options: .notifyOthersOnDeactivation) |
|||
isActive = false |
|||
} |
|||
} |
|||
|
|||
/// 重新配置当前会话 |
|||
@objc func forceReconfigure() throws { |
|||
NSLog("[AudioSessionManager] 强制重新配置音频会话") |
|||
// try configureSession( |
|||
// category: currentCategory ?? .playAndRecord, |
|||
// mode: currentMode ?? .default, |
|||
// options: currentOptions ?? [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker], |
|||
// force: true |
|||
// ) |
|||
|
|||
try configureSession( |
|||
category: .playAndRecord, // 允许同时录音和播放 |
|||
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|||
options: [ |
|||
.allowBluetooth, // 允许蓝牙设备 |
|||
.defaultToSpeaker, // 默认使用扬声器 |
|||
.mixWithOthers // 允许与其他应用混音 |
|||
], |
|||
force: false // 是否强制重新配置 |
|||
) |
|||
try audioSession.overrideOutputAudioPort(.speaker) |
|||
|
|||
|
|||
} |
|||
|
|||
// MARK: - 状态检查 |
|||
|
|||
/// 检查麦克风权限 |
|||
@objc func checkMicrophonePermission(completion: @escaping (Bool) -> Void) { |
|||
switch AVAudioSession.sharedInstance().recordPermission { |
|||
case .granted: |
|||
completion(true) |
|||
case .denied: |
|||
NSLog("[AudioSessionManager] 麦克风权限被拒绝") |
|||
completion(false) |
|||
case .undetermined: |
|||
NSLog("[AudioSessionManager] 请求麦克风权限") |
|||
AVAudioSession.sharedInstance().requestRecordPermission { granted in |
|||
DispatchQueue.main.async { |
|||
completion(granted) |
|||
} |
|||
} |
|||
@unknown default: |
|||
completion(false) |
|||
} |
|||
} |
|||
|
|||
/// 获取当前音频会话的配置信息 |
|||
@objc func getCurrentConfiguration() -> [String: Any] { |
|||
return [ |
|||
"isConfigured": isConfigured, |
|||
"isActive": isActive, |
|||
"category": String(describing: currentCategory), |
|||
"mode": String(describing: currentMode), |
|||
"bluetoothEnabled": currentOptions?.contains(.allowBluetooth) == true, |
|||
"currentInputs": audioSession.currentRoute.inputs.map { [$0.portType.rawValue: $0.portName] }, |
|||
"currentOutputs": audioSession.currentRoute.outputs.map { [$0.portType.rawValue: $0.portName] } |
|||
] |
|||
} |
|||
|
|||
/// 检查当前是否有蓝牙耳机连接 |
|||
@objc func hasBluetoothHeadsetConnected() -> Bool { |
|||
let outputs = audioSession.currentRoute.outputs |
|||
return outputs.contains { $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE } |
|||
} |
|||
|
|||
/// 检查蓝牙是否启用 |
|||
@objc func isBluetoothEnabled() -> Bool { |
|||
// 在iOS中无法直接检查蓝牙是否启用,我们可以通过检查音频会话配置来推断 |
|||
|
|||
// 检查音频会话是否配置了允许蓝牙 |
|||
if let options = currentOptions { |
|||
return options.contains(.allowBluetooth) || options.contains(.allowBluetoothA2DP) |
|||
} |
|||
|
|||
// 如果尚未配置音频会话,尝试检查当前路由中是否有蓝牙设备 |
|||
let outputs = audioSession.currentRoute.outputs |
|||
let hasBluetooth = outputs.contains { |
|||
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE |
|||
} |
|||
|
|||
return hasBluetooth |
|||
} |
|||
|
|||
/// 获取当前音频路由 |
|||
@objc func getCurrentAudioRoute() -> String { |
|||
let outputs = audioSession.currentRoute.outputs |
|||
if let output = outputs.first { |
|||
return "\(output.portType.rawValue) (\(output.portName))" |
|||
} |
|||
return "unknown" |
|||
} |
|||
|
|||
/// 获取当前输入设备 |
|||
@objc func getCurrentInputDevice() -> String { |
|||
let inputs = audioSession.currentRoute.inputs |
|||
if let input = inputs.first { |
|||
return "\(input.portType.rawValue) (\(input.portName))" |
|||
} |
|||
return "unknown" |
|||
} |
|||
|
|||
// MARK: - 路由管理和监听 |
|||
|
|||
/// 获取当前路由信息 |
|||
@objc func getCurrentRoute() -> AVAudioSessionRouteDescription { |
|||
return audioSession.currentRoute |
|||
} |
|||
|
|||
/// 添加路由变更监听器 |
|||
/// - Parameters: |
|||
/// - observer: 监听对象 |
|||
/// - selector: 监听方法选择器 |
|||
@objc func addRouteChangeListener(_ observer: Any, selector: Selector) { |
|||
NotificationCenter.default.addObserver( |
|||
observer, |
|||
selector: selector, |
|||
name: AVAudioSession.routeChangeNotification, |
|||
object: nil |
|||
) |
|||
} |
|||
|
|||
/// 移除路由变更监听器 |
|||
/// - Parameter observer: 要移除的监听对象 |
|||
@objc func removeRouteChangeListener(_ observer: Any) { |
|||
NotificationCenter.default.removeObserver( |
|||
observer, |
|||
name: AVAudioSession.routeChangeNotification, |
|||
object: nil |
|||
) |
|||
} |
|||
/// 获取设备类型描述 |
|||
private func getDeviceType(portType: AVAudioSession.Port) -> String { |
|||
switch portType { |
|||
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: |
|||
return "bluetooth" |
|||
case .headphones, .headsetMic: |
|||
return "wired" |
|||
case .airPlay: |
|||
return "airplay" |
|||
default: |
|||
return "unknown" |
|||
} |
|||
} |
|||
|
|||
|
|||
|
|||
|
|||
// /// 为Azure语音识别专门配置的音频会话 |
|||
// /// 此函数优化Azure语音识别的音频处理配置,提供更强的回音消除能力 |
|||
// /// - Parameters: |
|||
// /// - force: 是否强制重新配置 |
|||
// /// - configureAdditionalSettings: 配置完成后进行额外的音频设置 |
|||
// /// - Returns: 配置是否成功 |
|||
// @objc func configureForAzureSpeechRecognition(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { |
|||
// NSLog("[AudioSessionManager] 配置Azure ASR语音识别的音频会话") |
|||
// do { |
|||
// // 配置音频会话以优化语音识别 |
|||
// try configureSession( |
|||
// category: .playAndRecord, // 允许同时录音和播放 |
|||
// mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|||
// options: [ |
|||
// .allowBluetooth, // 允许蓝牙设备 |
|||
// .defaultToSpeaker, // 默认使用扬声器 |
|||
// .mixWithOthers // 允许与其他应用混音 |
|||
// ], |
|||
// force: force // 是否强制重新配置 |
|||
// ) |
|||
|
|||
// // 额外的优化配置 |
|||
// if configureAdditionalSettings { |
|||
// // 获取当前是否使用耳机 |
|||
// let currentRoute = audioSession.currentRoute |
|||
// let hasHeadphones = currentRoute.outputs.contains { |
|||
// $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
|||
// } |
|||
|
|||
// // 设置采样率为16kHz(Azure Speech API推荐) |
|||
// try audioSession.setPreferredSampleRate(16000.0) |
|||
|
|||
// // 设置较小的缓冲区大小以减少延迟 |
|||
// try audioSession.setPreferredIOBufferDuration(0.01) |
|||
|
|||
// // 根据是否有耳机连接调整输入增益 |
|||
// if !hasHeadphones { |
|||
// // 无耳机时降低输入增益以减少回音 |
|||
// try audioSession.setInputGain(0.8) |
|||
// NSLog("[AudioSessionManager] 启用扬声器回音消除优化") |
|||
// } else { |
|||
// // 使用耳机时可以使用较高增益 |
|||
// try audioSession.setInputGain(1.0) |
|||
// NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") |
|||
// } |
|||
// } |
|||
|
|||
// NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") |
|||
// return true |
|||
// } catch { |
|||
// NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") |
|||
// return false |
|||
// } |
|||
// } |
|||
|
|||
|
|||
@objc func configureForVoiceInteraction(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { |
|||
NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话") |
|||
do { |
|||
// 配置音频会话以优化语音识别 |
|||
try configureSession( |
|||
category: .playAndRecord, // 允许同时录音和播放 |
|||
mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 |
|||
options: [ |
|||
.allowBluetooth, // 允许蓝牙设备 |
|||
.defaultToSpeaker, // 默认使用扬声器 |
|||
.mixWithOthers // 允许与其他应用混音 |
|||
], |
|||
force: force // 是否强制重新配置 |
|||
) |
|||
|
|||
try audioSession.overrideOutputAudioPort(.speaker) |
|||
|
|||
// 额外的优化配置 |
|||
if configureAdditionalSettings { |
|||
// 获取当前是否使用耳机 |
|||
let currentRoute = audioSession.currentRoute |
|||
let hasHeadphones = currentRoute.outputs.contains { |
|||
$0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP |
|||
} |
|||
|
|||
// 设置采样率为16kHz(Azure Speech API推荐) |
|||
try audioSession.setPreferredSampleRate(16000.0) |
|||
|
|||
// 设置较小的缓冲区大小以减少延迟 |
|||
try audioSession.setPreferredIOBufferDuration(0.01) |
|||
|
|||
// 根据是否有耳机连接调整输入增益 |
|||
if !hasHeadphones { |
|||
// 无耳机时降低输入增益以减少回音 |
|||
try audioSession.setInputGain(0.8) |
|||
NSLog("[AudioSessionManager] 启用扬声器回音消除优化") |
|||
} else { |
|||
// 使用耳机时可以使用较高增益 |
|||
try audioSession.setInputGain(1.0) |
|||
NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") |
|||
} |
|||
} |
|||
|
|||
NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") |
|||
return true |
|||
} catch { |
|||
NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
|
|||
/// 配置用于语音交互(同时支持ASR和TTS)的音频会话 |
|||
/// 此函数综合优化语音识别和语音合成,适用于需要双向交互的场景 |
|||
/// - Returns: 配置是否成功 |
|||
// @objc func configureForVoiceInteraction(force: Bool = false) -> Bool { |
|||
// do { |
|||
// // 配置音频会话以支持语音交互 |
|||
// // 使用playAndRecord类别允许同时录音和播放 |
|||
// // 使用spokenAudio模式优化语音交互 |
|||
// try configureSession( |
|||
// category: .playAndRecord, |
|||
// mode: .voiceChat, |
|||
// options: [ |
|||
// // .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 |
|||
// .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 |
|||
// .defaultToSpeaker, // 默认使用扬声器输出 |
|||
// .mixWithOthers |
|||
// ], |
|||
// force: force // 强制重新配置,确保设置生效 |
|||
// ) |
|||
|
|||
// // 设置首选输入设备 |
|||
// try configurePreferredInput() |
|||
|
|||
// NSLog("[AudioSessionManager] 已配置语音交互(ASR+TTS)的音频会话") |
|||
// return true |
|||
// } catch { |
|||
// NSLog("[AudioSessionManager] 配置语音交互(ASR+TTS)的音频会话失败: \(error.localizedDescription)") |
|||
// return false |
|||
// } |
|||
// } |
|||
|
|||
} |
|||
@ -1,813 +0,0 @@ |
|||
import Foundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
import AVFoundation |
|||
import AudioToolbox |
|||
|
|||
/// Azure ASR工具类,负责实现语音识别服务接口 |
|||
@available(iOS 13.0, *) |
|||
class AzureAsrHelper: NSObject { |
|||
// MARK: - 属性 |
|||
|
|||
/// 是否使用自定义音频处理器 - 设置为false时将使用系统默认麦克风输入 |
|||
private let useCustomAudioProcessor = true |
|||
|
|||
/// 事件处理回调 |
|||
private var eventHandler: (([String: Any]) -> Void)? |
|||
|
|||
/// 语音配置信息 |
|||
private var speechSubscriptionKey: String = "" |
|||
private var serviceRegion: String = "" |
|||
|
|||
/// 语音识别相关 |
|||
private var speechConfig: SPXSpeechConfiguration? |
|||
private var recognizer: SPXSpeechRecognizer? |
|||
private var audioConfig: SPXAudioConfiguration? |
|||
|
|||
/// 添加自定义音频处理相关 |
|||
private var pushStream: SPXPushAudioInputStream? |
|||
private var audioProcessor: CustomAudioProcessor? |
|||
private var isProcessingAudio = false |
|||
private var audioProcessingTimer: Timer? |
|||
|
|||
/// 状态标志 |
|||
private var isInitialized = false |
|||
private var _isContinuousRecognitionActive = false |
|||
|
|||
/// 当前语言和支持的语言 |
|||
private var currentLanguage = "zh-CN" |
|||
private var supportedLanguages: [String] = ["zh-CN", "en-US"] |
|||
private var isAutoDetectLanguage = false |
|||
|
|||
/// 音频会话管理器 |
|||
private let audioSessionManager = AudioSessionManager.shared |
|||
|
|||
// MARK: - 初始化 |
|||
|
|||
override init() { |
|||
super.init() |
|||
} |
|||
|
|||
deinit { |
|||
dispose() |
|||
} |
|||
|
|||
|
|||
// MARK: - ASR Service 接口实现 |
|||
|
|||
/// 初始化语音识别服务 |
|||
/// - Parameters: |
|||
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
|||
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
|||
/// - supportedLanguages: 支持的语言代码数组 (可选) |
|||
/// - Returns: 初始化是否成功 |
|||
func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { |
|||
print("[AzureAsrHelper] 初始化 Azure 语音服务") |
|||
|
|||
// 检查配置是否为空 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
// 检查语言参数 |
|||
if let languages = supportedLanguages, languages.isEmpty { |
|||
print("[AzureAsrHelper] 警告: 传入的支持语言列表为空,将使用默认语言") |
|||
} |
|||
|
|||
// 释放之前的资源 |
|||
dispose() |
|||
|
|||
// 记录配置信息 |
|||
self.speechSubscriptionKey = speechSubscriptionKey |
|||
self.serviceRegion = serviceRegion |
|||
|
|||
// 设置语言 |
|||
if let languages = supportedLanguages, !languages.isEmpty { |
|||
self.supportedLanguages = languages |
|||
} |
|||
|
|||
// 根据支持的语言数量决定是否启用自动语言检测 |
|||
isAutoDetectLanguage = self.supportedLanguages.count >= 2 |
|||
|
|||
// 如果只有一种语言,设置为当前语言 |
|||
if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { |
|||
currentLanguage = self.supportedLanguages[0] |
|||
} |
|||
|
|||
// 创建识别器和设置回调 |
|||
if !createRecognizerAndSetupCallbacks() { |
|||
return false |
|||
} |
|||
|
|||
print("[AzureAsrHelper] Azure 语音服务初始化成功") |
|||
isInitialized = true |
|||
return true |
|||
} |
|||
|
|||
/// 创建识别器并设置回调 |
|||
private func createRecognizerAndSetupCallbacks() -> Bool { |
|||
// 释放之前的 recognizer |
|||
recognizer = nil |
|||
audioConfig = nil |
|||
|
|||
do { |
|||
// 创建语音配置 |
|||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|||
|
|||
// 设置音频输入参数 |
|||
try setupAudioSession() |
|||
|
|||
// 根据设置决定是否使用自定义音频处理器 |
|||
if useCustomAudioProcessor { |
|||
// 创建自定义音频流和处理器,替代默认的麦克风输入 |
|||
pushStream = try SPXPushAudioInputStream() |
|||
audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) |
|||
|
|||
// 初始化自定义音频处理器 |
|||
audioProcessor = CustomAudioProcessor() |
|||
} else { |
|||
// 使用默认麦克风输入 |
|||
audioConfig = try SPXAudioConfiguration() |
|||
} |
|||
|
|||
// 设置语言配置 |
|||
if isAutoDetectLanguage { |
|||
// 设置自动语言检测 |
|||
speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) |
|||
|
|||
// 创建自动语言检测配置 |
|||
let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) |
|||
|
|||
// 创建识别器 |
|||
recognizer = try SPXSpeechRecognizer( |
|||
speechConfiguration: speechConfig!, |
|||
autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, |
|||
audioConfiguration: audioConfig! |
|||
) |
|||
} else { |
|||
// 设置指定的识别语言 |
|||
speechConfig?.speechRecognitionLanguage = currentLanguage |
|||
|
|||
// 创建识别器 |
|||
recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) |
|||
} |
|||
|
|||
// 设置所有回调 |
|||
setupAllCallbacks() |
|||
|
|||
return true |
|||
} catch { |
|||
print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") |
|||
eventHandler?(["type": "error", "message": "创建识别器失败: \(error.localizedDescription)"]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 设置音频会话 |
|||
private func setupAudioSession() throws { |
|||
print("[AzureAsrHelper] 开始配置音频会话...") |
|||
|
|||
// 使用AudioSessionManager配置音频会话,使用专门为Azure ASR优化的配置 |
|||
let success = audioSessionManager.configureForVoiceInteraction() |
|||
if !success { |
|||
print("[AzureAsrHelper] 警告: 通过AudioSessionManager配置音频会话失败") |
|||
} |
|||
} |
|||
|
|||
|
|||
/// 设置所有回调 |
|||
private func setupAllCallbacks() { |
|||
guard let recognizer = recognizer else { return } |
|||
|
|||
// 最终识别结果 |
|||
recognizer.addRecognizedEventHandler { [weak self] _, event in |
|||
guard let self = self else { return } |
|||
|
|||
if event.result.reason == SPXResultReason.recognizedSpeech { |
|||
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
|||
print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
|||
self.eventHandler?(["type": "result", |
|||
"text": event.result.text ?? "", |
|||
"detectedLanguage": detectedLanguage |
|||
]) |
|||
} |
|||
} |
|||
|
|||
// 识别中事件 |
|||
recognizer.addRecognizingEventHandler { [weak self] _, event in |
|||
guard let self = self else { return } |
|||
|
|||
if event.result.reason == SPXResultReason.recognizingSpeech { |
|||
let detectedLanguage = self.getDetectedLanguage(from: event.result) |
|||
// print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") |
|||
self.eventHandler?(["type": "recognizing", |
|||
"text": event.result.text ?? "", |
|||
"detectedLanguage": detectedLanguage |
|||
]) |
|||
} |
|||
} |
|||
|
|||
// 会话事件 |
|||
recognizer.addSessionStartedEventHandler { [weak self] _, _ in |
|||
guard let self = self else { return } |
|||
|
|||
print("[AzureAsrHelper] 识别会话已开始") |
|||
self._isContinuousRecognitionActive = true |
|||
self.eventHandler?(["type": "sessionStarted"]) |
|||
} |
|||
|
|||
recognizer.addSessionStoppedEventHandler { [weak self] _, _ in |
|||
guard let self = self else { return } |
|||
|
|||
print("[AzureAsrHelper] 识别会话已结束") |
|||
self._isContinuousRecognitionActive = false |
|||
self.eventHandler?(["type": "sessionStopped"]) |
|||
} |
|||
|
|||
// 取消事件 |
|||
recognizer.addCanceledEventHandler { [weak self] _, event in |
|||
guard let self = self else { return } |
|||
|
|||
let reason = event.reason.rawValue |
|||
let errorDetails = event.errorDetails ?? "未知错误" |
|||
|
|||
print("[AzureAsrHelper] 识别取消: \(errorDetails)") |
|||
|
|||
self.eventHandler?(["type": "canceled", "reason": reason, "errorDetails": errorDetails]) |
|||
|
|||
self._isContinuousRecognitionActive = false |
|||
} |
|||
} |
|||
|
|||
/// 执行一次性语音识别 |
|||
/// - Returns: 是否成功启动识别 |
|||
func recognizeOnce() -> Bool { |
|||
// 检查是否初始化 |
|||
if !isInitialized { |
|||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|||
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 检查参数有效性 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
// 如果正在连续识别,先停止 |
|||
if _isContinuousRecognitionActive { |
|||
stopContinuousRecognition() |
|||
} |
|||
|
|||
// 确保识别器已创建 |
|||
if recognizer == nil && !createRecognizerAndSetupCallbacks() { |
|||
return false |
|||
} |
|||
|
|||
do { |
|||
// 启动音频处理 |
|||
startAudioProcessing() |
|||
|
|||
// 通知会话开始 |
|||
eventHandler?(["type": "sessionStarted"]) |
|||
|
|||
// 执行识别 |
|||
try recognizer?.recognizeOnceAsync { [weak self] result in |
|||
guard let self = self else { return } |
|||
|
|||
// 停止音频处理 |
|||
self.stopAudioProcessing() |
|||
|
|||
if result.reason == SPXResultReason.recognizedSpeech { |
|||
let detectedLanguage = self.getDetectedLanguage(from: result) |
|||
self.eventHandler?(["type": "result", |
|||
"text": result.text ?? "", |
|||
"detectedLanguage": detectedLanguage |
|||
]) |
|||
} else if result.reason == SPXResultReason.noMatch { |
|||
print("[AzureAsrHelper] 无匹配结果") |
|||
self.eventHandler?(["type": "noMatch"]) |
|||
} else if result.reason == SPXResultReason.canceled { |
|||
do { |
|||
let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) |
|||
let errorDetails = details.errorDetails ?? "未知错误" |
|||
self.eventHandler?(["type": "error", "message": "识别取消: \(errorDetails)"]) |
|||
} catch { |
|||
print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") |
|||
self.eventHandler?(["type": "error", "message": "识别取消,无法获取详细原因"]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} catch { |
|||
print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") |
|||
eventHandler?(["type": "error", "message": "识别异常: \(error.localizedDescription)"]) |
|||
stopAudioProcessing() |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 开始连续语音识别 |
|||
/// - Returns: 是否成功启动识别 |
|||
func startContinuousRecognition() -> Bool { |
|||
// 检查是否初始化 |
|||
if !isInitialized { |
|||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|||
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 检查参数有效性 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureAsrHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
// 检查是否已经在识别 |
|||
if _isContinuousRecognitionActive { |
|||
print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") |
|||
return true |
|||
} |
|||
|
|||
// 确保识别器已创建 |
|||
if recognizer == nil { |
|||
print("[AzureAsrHelper] 尝试重新创建识别器...") |
|||
if !createRecognizerAndSetupCallbacks() { |
|||
eventHandler?(["type": "error", "message": "无法创建语音识别器"]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// 启动音频处理 |
|||
startAudioProcessing() |
|||
|
|||
// 尝试启动连续识别 |
|||
do { |
|||
// 启动连续识别 |
|||
try recognizer?.startContinuousRecognition() |
|||
_isContinuousRecognitionActive = true |
|||
|
|||
print("[AzureAsrHelper] 连续识别已启动") |
|||
return true |
|||
} catch { |
|||
_isContinuousRecognitionActive = false |
|||
print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") |
|||
|
|||
// 停止音频处理 |
|||
stopAudioProcessing() |
|||
|
|||
// 发送错误通知 |
|||
eventHandler?(["type": "error", "message": "开始连续识别失败: \(error.localizedDescription)"]) |
|||
|
|||
return false |
|||
} |
|||
} |
|||
|
|||
|
|||
/// 停止连续语音识别 |
|||
/// - Returns: 是否成功停止识别 |
|||
func stopContinuousRecognition() -> Bool { |
|||
// 停止音频处理 |
|||
stopAudioProcessing() |
|||
|
|||
// 检查是否初始化 |
|||
if !isInitialized { |
|||
print("[AzureAsrHelper] 错误: 语音服务未初始化") |
|||
eventHandler?(["type": "error", "message": "语音服务未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
if !_isContinuousRecognitionActive { |
|||
print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") |
|||
return true |
|||
} |
|||
|
|||
print("[AzureAsrHelper] 停止连续语音识别") |
|||
|
|||
// 防止空指针异常 |
|||
guard let recognizer = recognizer else { |
|||
print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") |
|||
_isContinuousRecognitionActive = false |
|||
eventHandler?(["type": "sessionStopped"]) |
|||
return true |
|||
} |
|||
|
|||
// 先标记为非活跃状态,防止重复调用 |
|||
_isContinuousRecognitionActive = false |
|||
|
|||
// 异步执行停止操作,避免阻塞主线程 |
|||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
do { |
|||
// 在后台线程执行停止操作 |
|||
try recognizer.stopContinuousRecognition() |
|||
|
|||
// 主线程回调通知结果 |
|||
DispatchQueue.main.async { |
|||
print("[AzureAsrHelper] 连续识别已停止") |
|||
self.eventHandler?(["type": "sessionStopped"]) |
|||
} |
|||
} catch { |
|||
// 主线程回调通知错误 |
|||
DispatchQueue.main.async { |
|||
print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") |
|||
// 停止失败,恢复状态 |
|||
self._isContinuousRecognitionActive = true |
|||
self.eventHandler?(["type": "error", "message": "停止识别失败: \(error.localizedDescription)"]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 立即返回,不阻塞调用方 |
|||
return true |
|||
} |
|||
|
|||
/// 检查连续识别是否活跃 |
|||
/// - Returns: 连续识别是否处于活跃状态 |
|||
func isContinuousRecognitionActive() -> Bool { |
|||
return _isContinuousRecognitionActive |
|||
} |
|||
|
|||
/// 释放资源 |
|||
func dispose() { |
|||
// 停止音频处理 |
|||
stopAudioProcessing() |
|||
|
|||
// 尝试停止所有识别操作 |
|||
if _isContinuousRecognitionActive { |
|||
do { |
|||
if let recognizer = recognizer { |
|||
try recognizer.stopContinuousRecognition() |
|||
print("[AzureAsrHelper] 连续识别已停止") |
|||
} |
|||
} catch { |
|||
print("[AzureAsrHelper] 警告: 停止连续识别失败: \(error.localizedDescription)") |
|||
} |
|||
_isContinuousRecognitionActive = false |
|||
} |
|||
|
|||
// 释放识别器 |
|||
recognizer = nil |
|||
audioConfig = nil |
|||
speechConfig = nil |
|||
pushStream = nil |
|||
audioProcessor = nil |
|||
|
|||
// 重置状态 |
|||
isInitialized = false |
|||
|
|||
print("[AzureAsrHelper] 资源已释放") |
|||
} |
|||
|
|||
/// 从结果中获取检测到的语言 |
|||
private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { |
|||
if isAutoDetectLanguage { |
|||
do { |
|||
let langResult = try SPXAutoDetectSourceLanguageResult(result) |
|||
return langResult.language ?? currentLanguage |
|||
} catch { |
|||
print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") |
|||
return currentLanguage |
|||
} |
|||
} else { |
|||
return currentLanguage |
|||
} |
|||
} |
|||
|
|||
/// 设置事件处理器 |
|||
@objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { |
|||
self.eventHandler = handler |
|||
} |
|||
|
|||
// MARK: - 音频处理 |
|||
|
|||
/// 开始音频处理 |
|||
private func startAudioProcessing() { |
|||
// 如果未使用自定义音频处理器,跳过 |
|||
if !useCustomAudioProcessor { |
|||
return |
|||
} |
|||
|
|||
guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } |
|||
|
|||
isProcessingAudio = true |
|||
|
|||
// 启动音频处理器 |
|||
if !audioProcessor.startRecord() { |
|||
print("[AzureAsrHelper] 错误: 启动音频处理器失败") |
|||
eventHandler?(["type": "error", "message": "启动音频处理器失败"]) |
|||
return |
|||
} |
|||
|
|||
// 启动音频处理定时器 |
|||
audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in |
|||
guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { |
|||
return |
|||
} |
|||
|
|||
// 读取处理后的音频数据 |
|||
var bytes = [UInt8](repeating: 0, count: 2560) |
|||
let bytesRead = processor.read(bytes: &bytes) |
|||
|
|||
if bytesRead > 0 { |
|||
// 推送数据到Azure语音服务 |
|||
let data = Data(bytes: bytes, count: bytesRead) |
|||
stream.write(data) |
|||
|
|||
// 通知音频数据可用(可选) |
|||
// self.eventHandler?(["type": "audioData", "data": bytes]) |
|||
} |
|||
} |
|||
|
|||
print("[AzureAsrHelper] 音频处理已启动") |
|||
} |
|||
|
|||
/// 停止音频处理 |
|||
private func stopAudioProcessing() { |
|||
// 如果未使用自定义音频处理器,跳过 |
|||
if !useCustomAudioProcessor { |
|||
return |
|||
} |
|||
|
|||
// 停止定时器 |
|||
audioProcessingTimer?.invalidate() |
|||
audioProcessingTimer = nil |
|||
|
|||
// 停止音频处理器 |
|||
audioProcessor?.stopRecord() |
|||
|
|||
isProcessingAudio = false |
|||
print("[AzureAsrHelper] 音频处理已停止") |
|||
} |
|||
} |
|||
|
|||
// MARK: - 自定义音频处理器 |
|||
|
|||
@available(iOS 13.0, *) |
|||
class CustomAudioProcessor: NSObject { |
|||
// 音频单元 |
|||
private var ioUnit: AudioUnit? |
|||
|
|||
// 音频格式 |
|||
private var audioFormat: AudioStreamBasicDescription |
|||
|
|||
// 音频缓冲 |
|||
private var audioBufferList: AudioBufferList |
|||
private var audioList: [Float] = [] |
|||
private let audioListQueue = DispatchQueue(label: "audioListQueue") |
|||
|
|||
// 回音消除状态 |
|||
private var isEchoCancellationEnabled = true |
|||
|
|||
override init() { |
|||
// 设置音频格式 - 16kHz, 16位, 单声道 |
|||
audioFormat = AudioStreamBasicDescription( |
|||
mSampleRate: 16000.0, |
|||
mFormatID: kAudioFormatLinearPCM, |
|||
mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, |
|||
mBytesPerPacket: 2, |
|||
mFramesPerPacket: 1, |
|||
mBytesPerFrame: 2, |
|||
mChannelsPerFrame: 1, |
|||
mBitsPerChannel: 16, |
|||
mReserved: 0 |
|||
) |
|||
|
|||
// 初始化音频缓冲 |
|||
audioBufferList = AudioBufferList( |
|||
mNumberBuffers: 1, |
|||
mBuffers: AudioBuffer( |
|||
mNumberChannels: 1, |
|||
mDataByteSize: 4096, |
|||
mData: malloc(4096) |
|||
) |
|||
) |
|||
|
|||
super.init() |
|||
} |
|||
|
|||
deinit { |
|||
stopRecord() |
|||
free(audioBufferList.mBuffers.mData) |
|||
} |
|||
|
|||
/// 启动音频处理 |
|||
/// - Returns: 是否成功启动 |
|||
func startRecord() -> Bool { |
|||
print("[CustomAudioProcessor] 配置音频单元") |
|||
|
|||
// 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 |
|||
var ioUnitDescription = AudioComponentDescription( |
|||
componentType: kAudioUnitType_Output, |
|||
componentSubType: kAudioUnitSubType_VoiceProcessingIO, |
|||
componentManufacturer: kAudioUnitManufacturer_Apple, |
|||
componentFlags: 0, |
|||
componentFlagsMask: 0 |
|||
) |
|||
|
|||
// 查找音频组件 |
|||
guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { |
|||
print("[CustomAudioProcessor] 错误: 未找到音频组件") |
|||
return false |
|||
} |
|||
|
|||
// 创建音频单元实例 |
|||
if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { |
|||
ioUnit = nil |
|||
return false |
|||
} |
|||
|
|||
// 启用输入端口 |
|||
var enableInput: UInt32 = 1 |
|||
let kInputBus: AudioUnitElement = 1 |
|||
let kOutputBus: AudioUnitElement = 0 |
|||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|||
kAudioUnitScope_Input, kInputBus, &enableInput, |
|||
UInt32(MemoryLayout<UInt32>.size)), "启用输入端口") { |
|||
return false |
|||
} |
|||
|
|||
// 禁用输出端口 (我们只需要输入) |
|||
var enableOutput: UInt32 = 0 |
|||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, |
|||
kAudioUnitScope_Output, kOutputBus, |
|||
&enableOutput, UInt32(MemoryLayout<UInt32>.size)), "禁用输出端口") { |
|||
return false |
|||
} |
|||
|
|||
// 设置缓冲区分配标志 |
|||
var flag: UInt32 = 0 |
|||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, |
|||
kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout<UInt32>.size)), "设置缓冲区分配标志") { |
|||
return false |
|||
} |
|||
|
|||
// 设置音频格式 |
|||
let size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size) |
|||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|||
kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { |
|||
return false |
|||
} |
|||
|
|||
if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, |
|||
kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { |
|||
return false |
|||
} |
|||
|
|||
// 启用回音消除 - 注意: kAUVoiceIOProperty_BypassVoiceProcessing值为1时表示绕过处理,值为0表示启用处理 |
|||
if isEchoCancellationEnabled { |
|||
var echoCancellation: UInt32 = 0 // 0表示不绕过,即启用回音消除 |
|||
AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, |
|||
kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout<UInt32>.size)) |
|||
} |
|||
|
|||
// 设置输入回调 - 当有新音频数据时调用 |
|||
var inputCallback = AURenderCallbackStruct( |
|||
inputProc: CustomAudioProcessor.onAudioDataAvailable, |
|||
inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) |
|||
) |
|||
|
|||
if checkError(AudioUnitSetProperty(ioUnit!, |
|||
kAudioOutputUnitProperty_SetInputCallback, |
|||
kAudioUnitScope_Global, kInputBus, |
|||
&inputCallback, UInt32(MemoryLayout<AURenderCallbackStruct>.size)), "设置输入回调") { |
|||
return false |
|||
} |
|||
|
|||
// 初始化音频单元 |
|||
var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
|||
while hasError { |
|||
Thread.sleep(forTimeInterval: 0.1) |
|||
hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") |
|||
} |
|||
|
|||
// 启动音频单元 |
|||
hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") |
|||
|
|||
print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") |
|||
return !hasError |
|||
} |
|||
|
|||
/// 停止音频处理 |
|||
func stopRecord() { |
|||
print("[CustomAudioProcessor] 停止音频处理器") |
|||
|
|||
if let ioUnit = ioUnit { |
|||
// 停止音频单元 |
|||
_ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") |
|||
|
|||
// 关闭音频单元 |
|||
_ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") |
|||
_ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") |
|||
|
|||
self.ioUnit = nil |
|||
} |
|||
|
|||
// 清空音频数据缓冲 |
|||
audioListQueue.sync { |
|||
audioList.removeAll() |
|||
} |
|||
} |
|||
|
|||
/// 音频数据回调 - 当有新的音频数据可用时调用 |
|||
private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in |
|||
// 获取实例 |
|||
let processor = Unmanaged<CustomAudioProcessor>.fromOpaque(inRefCon).takeUnretainedValue() |
|||
|
|||
// 计算预期数据大小 |
|||
let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame |
|||
|
|||
// 确保缓冲区足够大 |
|||
if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { |
|||
processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) |
|||
processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize |
|||
} |
|||
|
|||
// 渲染音频数据 |
|||
let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, |
|||
inBusNumber, inNumberFrames, &processor.audioBufferList), |
|||
"渲染音频数据") |
|||
|
|||
// 将Int16数据转换为浮点数据进行处理 |
|||
var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) |
|||
let buffer = processor.audioBufferList.mBuffers |
|||
let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) |
|||
|
|||
for j in 0..<Int(buffer.mDataByteSize / UInt32(MemoryLayout<Int16>.size)) { |
|||
// 归一化到[-1.0, 1.0]范围 |
|||
audioDataFloat[j] = Float(bufferData[j]) / 32768.0 |
|||
} |
|||
|
|||
// 保存处理后的数据 |
|||
if status == noErr { |
|||
processor.audioListQueue.async { |
|||
processor.audioList.append(contentsOf: audioDataFloat) |
|||
} |
|||
} |
|||
|
|||
return status |
|||
} |
|||
|
|||
/// 读取处理后的音频数据 |
|||
/// - Parameter bytes: 输出字节数组 |
|||
/// - Returns: 读取的字节数 |
|||
func read(bytes: inout [UInt8]) -> Int { |
|||
return audioListQueue.sync { |
|||
// 如果没有数据,返回0 |
|||
if audioList.isEmpty { |
|||
return 0 |
|||
} |
|||
|
|||
// 确保有足够的数据 (至少1280个样本) |
|||
if audioList.count < 1280 { |
|||
return 0 |
|||
} |
|||
|
|||
// 读取一帧数据 (1280个样本) |
|||
let frameLength = 1280 |
|||
let buffer = Array(audioList.prefix(frameLength)) |
|||
audioList.removeFirst(frameLength) |
|||
|
|||
// 将浮点数据转回Int16格式 |
|||
var int16Data = buffer.map { Int16($0 * 32767) } |
|||
|
|||
// 转换为字节数组 |
|||
let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) |
|||
bytes = [UInt8](data) |
|||
|
|||
// 每个样本2字节 (16位PCM) |
|||
return frameLength * 2 |
|||
} |
|||
} |
|||
|
|||
/// 检查错误并打印日志 |
|||
/// - Parameters: |
|||
/// - status: 操作状态 |
|||
/// - operation: 操作描述 |
|||
/// - Returns: 是否发生错误 |
|||
private func checkError(_ status: OSStatus, _ operation: String) -> Bool { |
|||
if status != noErr { |
|||
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
|||
return true |
|||
} |
|||
return false |
|||
} |
|||
|
|||
/// 检查OSStatus并返回状态 |
|||
/// - Parameters: |
|||
/// - status: 操作状态 |
|||
/// - operation: 操作描述 |
|||
/// - Returns: 原始状态 |
|||
private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { |
|||
if status != noErr { |
|||
print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") |
|||
} |
|||
return status |
|||
} |
|||
} |
|||
@ -1,454 +0,0 @@ |
|||
import Foundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
import AVFoundation |
|||
|
|||
/// Azure TTS工具类,负责实现TTS服务接口 |
|||
@available(iOS 13.0, *) |
|||
class AzureTtsHelper: NSObject { |
|||
// MARK: - 属性 |
|||
|
|||
/// 事件处理回调 |
|||
private var eventHandler: (([String: Any]) -> Void)? |
|||
|
|||
/// 语音配置信息 |
|||
private var speechSubscriptionKey: String = "" |
|||
private var serviceRegion: String = "" |
|||
|
|||
/// 语音合成配置 |
|||
private var speechConfig: SPXSpeechConfiguration? |
|||
|
|||
/// 语音合成器 |
|||
private var synthesizer: SPXSpeechSynthesizer? |
|||
|
|||
/// 是否初始化成功 |
|||
private var isInitialized = false |
|||
|
|||
/// 当前是否正在播放 |
|||
private var _isSpeaking = false |
|||
|
|||
/// 音频会话配置 |
|||
private var isAudioSessionConfigured = false |
|||
|
|||
/// 音频会话管理器 |
|||
private let audioSessionManager = AudioSessionManager.shared |
|||
|
|||
// MARK: - 语音设置 |
|||
|
|||
/// 当前语音 |
|||
private var currentVoice = "zh-CN-XiaoxiaoNeural" |
|||
|
|||
/// 支持的语音映射 |
|||
private var voiceMap: [String: String] = [ |
|||
"zh-CN": "zh-CN-XiaoxiaoNeural", |
|||
"en-US": "en-US-JennyNeural", |
|||
"ja-JP": "ja-JP-NanamiNeural", |
|||
"ko-KR": "ko-KR-SunHiNeural", |
|||
"zh-TW": "zh-TW-HsiaoChenNeural", |
|||
"zh-HK": "zh-HK-HiuMaanNeural" |
|||
] |
|||
|
|||
/// 当前语音合成参数 |
|||
private var currentSpeechRate = "0%" |
|||
private var currentPitch = "0%" |
|||
private var currentVolume = "100%" |
|||
|
|||
// MARK: - 初始化 |
|||
|
|||
override init() { |
|||
super.init() |
|||
} |
|||
|
|||
deinit { |
|||
dispose() |
|||
} |
|||
|
|||
/// 设置事件处理器 |
|||
@objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { |
|||
self.eventHandler = handler |
|||
} |
|||
|
|||
// MARK: - TTS 接口实现 |
|||
|
|||
/// 初始化语音合成服务 |
|||
/// - Parameters: |
|||
/// - speechSubscriptionKey: Azure 语音服务订阅密钥 |
|||
/// - serviceRegion: Azure 服务区域 (如 eastasia) |
|||
/// - language: 语言代码 (默认 zh-CN) |
|||
/// - Returns: 初始化是否成功 |
|||
func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { |
|||
print("[AzureTtsHelper] 初始化语音合成服务") |
|||
|
|||
// 检查配置是否为空 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
// 释放之前的资源 |
|||
dispose() |
|||
|
|||
// 记录配置信息 |
|||
self.speechSubscriptionKey = speechSubscriptionKey |
|||
self.serviceRegion = serviceRegion |
|||
|
|||
// 配置音频会话 |
|||
if !configureAudioSession() { |
|||
print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") |
|||
} |
|||
|
|||
do { |
|||
// 创建语音配置 |
|||
speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) |
|||
|
|||
// 设置默认语音 |
|||
let defaultVoice = getDefaultVoiceForLanguage(language) |
|||
currentVoice = defaultVoice |
|||
speechConfig?.speechSynthesisVoiceName = defaultVoice |
|||
|
|||
// 创建语音合成器 |
|||
synthesizer = try SPXSpeechSynthesizer(speechConfig!) |
|||
|
|||
// 设置事件处理器 |
|||
setupSynthesizerEvents() |
|||
|
|||
isInitialized = true |
|||
print("[AzureTtsHelper] TTS 引擎初始化成功") |
|||
|
|||
return true |
|||
} catch { |
|||
print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)") |
|||
eventHandler?(["type": "error", "message": "初始化语音合成服务失败: \(error.localizedDescription)"]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 配置音频会话 |
|||
private func configureAudioSession() -> Bool { |
|||
do { |
|||
// 使用音频会话管理器配置媒体播放模式 |
|||
try audioSessionManager.configureForVoiceInteraction() |
|||
|
|||
isAudioSessionConfigured = true |
|||
print("[AzureTtsHelper] 音频会话配置成功") |
|||
return true |
|||
} catch { |
|||
print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") |
|||
isAudioSessionConfigured = false |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 设置语音 |
|||
/// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") |
|||
/// - Returns: 设置是否成功 |
|||
func setVoice(voiceName: String) -> Bool { |
|||
// 检查是否初始化 |
|||
if !isInitialized { |
|||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 检查参数有效性 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
if voiceName.isEmpty { |
|||
print("[AzureTtsHelper] 错误: 声音名称为空") |
|||
eventHandler?(["type": "error", "message": "声音名称不能为空"]) |
|||
return false |
|||
} |
|||
|
|||
if voiceName == currentVoice { |
|||
print("[AzureTtsHelper] 已设置语音: \(voiceName)") |
|||
return true |
|||
} |
|||
|
|||
print("[AzureTtsHelper] 设置声音: \(voiceName)") |
|||
currentVoice = voiceName |
|||
|
|||
// 更新语音配置 |
|||
if let speechConfig = speechConfig { |
|||
speechConfig.speechSynthesisVoiceName = voiceName |
|||
return true |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
/// 设置语音合成参数 |
|||
/// - Parameters: |
|||
/// - rate: 语速,范围 -100 到 100,默认为 0 |
|||
/// - pitch: 音调,范围 -100 到 100,默认为 0 |
|||
/// - volume: 音量,范围 0 到 100,默认为 100 |
|||
/// - Returns: 是否设置成功 |
|||
func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { |
|||
// 检查是否初始化 |
|||
if !isInitialized { |
|||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 检查参数有效性 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
// 转换参数格式 |
|||
currentSpeechRate = formatRateParam(rate) |
|||
currentPitch = formatPitchParam(pitch) |
|||
currentVolume = formatVolumeParam(volume) |
|||
|
|||
print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)") |
|||
return true |
|||
} |
|||
|
|||
/// 合成文本为语音并播放 |
|||
/// - Parameter text: 要合成的文本 |
|||
/// - Returns: 操作是否成功启动 |
|||
func speakText(text: String) -> Bool { |
|||
// 检查是否初始化 |
|||
if !isInitialized { |
|||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
// 检查参数有效性 |
|||
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { |
|||
print("[AzureTtsHelper] 错误: Azure 配置信息不完整") |
|||
eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) |
|||
return false |
|||
} |
|||
|
|||
if text.isEmpty { |
|||
print("[AzureTtsHelper] 警告: 要播放的文本为空") |
|||
return true |
|||
} |
|||
|
|||
// 确保音频会话已配置 |
|||
if !isAudioSessionConfigured { |
|||
_ = configureAudioSession() |
|||
} |
|||
|
|||
print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...") |
|||
|
|||
// 生成SSML |
|||
let ssml = generateSsml(text: text) |
|||
|
|||
// 直接进行SSML合成 |
|||
return speakSsmlInternal(text: ssml) |
|||
} |
|||
|
|||
/// 内部SSML合成和播放 |
|||
private func speakSsmlInternal(text: String) -> Bool { |
|||
guard let synthesizer = synthesizer else { |
|||
print("[AzureTtsHelper] 错误: 合成器未初始化") |
|||
eventHandler?(["type": "error", "message": "合成器未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
_isSpeaking = true |
|||
eventHandler?(["type": "started"]) |
|||
|
|||
Task { |
|||
do { |
|||
// 使用异步方法进行合成并直接播放 |
|||
_ = try synthesizer.startSpeakingSsml(text) |
|||
|
|||
} catch { |
|||
print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") |
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler?(["type": "error", "message": "语音合成失败: \(error.localizedDescription)"]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
/// 停止当前语音合成 |
|||
/// - Returns: 操作是否成功 |
|||
func stopSpeaking() -> Bool { |
|||
// 检查是否初始化 |
|||
if !isInitialized { |
|||
print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") |
|||
eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) |
|||
return false |
|||
} |
|||
|
|||
if !_isSpeaking { |
|||
print("[AzureTtsHelper] 未进行语音播放,忽略停止请求") |
|||
return true |
|||
} |
|||
|
|||
// 停止合成 |
|||
do { |
|||
try synthesizer?.stopSpeaking() |
|||
_isSpeaking = false |
|||
eventHandler?(["type": "canceled"]) |
|||
print("[AzureTtsHelper] 已停止语音合成") |
|||
return true |
|||
} catch { |
|||
print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") |
|||
eventHandler?(["type": "error", "message": "停止语音合成失败: \(error.localizedDescription)"]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/// 检查是否正在播放 |
|||
/// - Returns: 当前是否正在播放语音 |
|||
func isSpeaking() -> Bool { |
|||
return _isSpeaking |
|||
} |
|||
|
|||
/// 释放资源 |
|||
func dispose() { |
|||
// 尝试停止所有语音合成 |
|||
if _isSpeaking { |
|||
do { |
|||
if let synthesizer = synthesizer { |
|||
try synthesizer.stopSpeaking() |
|||
print("[AzureTtsHelper] 语音合成已停止") |
|||
} |
|||
} catch { |
|||
print("[AzureTtsHelper] 警告: 停止语音合成失败: \(error.localizedDescription)") |
|||
} |
|||
_isSpeaking = false |
|||
} |
|||
|
|||
// 释放合成器 |
|||
synthesizer = nil |
|||
speechConfig = nil |
|||
|
|||
// 重置状态 |
|||
isInitialized = false |
|||
isAudioSessionConfigured = false |
|||
|
|||
print("[AzureTtsHelper] 资源已释放") |
|||
} |
|||
|
|||
// MARK: - 私有辅助方法 |
|||
|
|||
/// 设置合成器事件处理 |
|||
private func setupSynthesizerEvents() { |
|||
guard let synthesizer = synthesizer else { return } |
|||
|
|||
// 添加书签到达事件处理 |
|||
synthesizer.addBookmarkReachedEventHandler { _, e in |
|||
print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"") |
|||
} |
|||
|
|||
// 合成完成事件 |
|||
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in |
|||
guard let self = self else { return } |
|||
print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)") |
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler?(["type": "completed"]) |
|||
} |
|||
} |
|||
|
|||
// 合成取消事件 |
|||
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in |
|||
guard let self = self else { return } |
|||
|
|||
let result = e.result |
|||
do { |
|||
let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result) |
|||
print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)") |
|||
|
|||
if cancellationDetails.reason == SPXCancellationReason.error { |
|||
print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)") |
|||
print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")") |
|||
} |
|||
|
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler?(["type": "error", "message": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) |
|||
} |
|||
} catch { |
|||
print("[AzureTtsHelper] 获取取消详情时出错: \(error)") |
|||
|
|||
DispatchQueue.main.async { |
|||
self._isSpeaking = false |
|||
self.eventHandler?(["type": "error", "message": "语音合成被取消"]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 合成开始事件 |
|||
synthesizer.addSynthesisStartedEventHandler { _, _ in |
|||
// print("[AzureTtsHelper] 语音合成开始") |
|||
} |
|||
|
|||
// 合成中事件 |
|||
synthesizer.addSynthesizingEventHandler { _, _ in |
|||
// print("[AzureTtsHelper] 语音合成中") |
|||
} |
|||
} |
|||
|
|||
/// 生成 SSML 文本 |
|||
private func generateSsml(text: String) -> String { |
|||
return """ |
|||
<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='zh-CN'> |
|||
<voice name='\(currentVoice)'> |
|||
<prosody rate='\(currentSpeechRate)' pitch='\(currentPitch)' volume='\(currentVolume)'> |
|||
\(text) |
|||
</prosody> |
|||
</voice> |
|||
</speak> |
|||
""" |
|||
} |
|||
|
|||
/// 格式化语速参数 |
|||
private func formatRateParam(_ rate: Int) -> String { |
|||
let clampedRate = rate.clamp(min: -100, max: 100) |
|||
if clampedRate == 0 { |
|||
return "0%" |
|||
} else if clampedRate < 0 { |
|||
return "\(Int(Double(clampedRate) * 0.9))%" |
|||
} else { |
|||
return "+\(clampedRate)%" |
|||
} |
|||
} |
|||
|
|||
/// 格式化音调参数 |
|||
private func formatPitchParam(_ pitch: Int) -> String { |
|||
let clampedPitch = pitch.clamp(min: -100, max: 100) |
|||
if clampedPitch == 0 { |
|||
return "0%" |
|||
} else { |
|||
return "\(Int(Double(clampedPitch) * 0.5))%" |
|||
} |
|||
} |
|||
|
|||
/// 格式化音量参数 |
|||
private func formatVolumeParam(_ volume: Int) -> String { |
|||
let clampedVolume = volume.clamp(min: 0, max: 100) |
|||
return "\(clampedVolume)%" |
|||
} |
|||
|
|||
/// 获取指定语言的默认语音 |
|||
private func getDefaultVoiceForLanguage(_ language: String) -> String { |
|||
return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural" |
|||
} |
|||
} |
|||
|
|||
// MARK: - 扩展 |
|||
|
|||
extension Int { |
|||
func clamp(min: Int, max: Int) -> Int { |
|||
if self < min { return min } |
|||
if self > max { return max } |
|||
return self |
|||
} |
|||
} |
|||
@ -1,464 +0,0 @@ |
|||
import Foundation |
|||
import MediaPlayer |
|||
import AVFoundation |
|||
import UIKit |
|||
|
|||
/// 媒体按钮事件处理协议 |
|||
@available(iOS 13.0, *) |
|||
@objc protocol MediaButtonEventHandler: AnyObject { |
|||
func sendEvent(_ event: [String: Any]) |
|||
} |
|||
|
|||
/// 蓝牙媒体按键事件处理类 |
|||
/// 该类仅在iOS 13.0及以上版本可用 |
|||
@available(iOS 13.0, *) |
|||
class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { |
|||
// MARK: - 属性 |
|||
|
|||
@objc static let shared = BluetoothMediaButtonHelper() |
|||
|
|||
/// 日志标签 |
|||
private let TAG = "BluetoothMediaButtonHelper" |
|||
|
|||
/// 远程控制中心 |
|||
private var remoteCommandCenter: MPRemoteCommandCenter |
|||
|
|||
/// 事件处理器 - 用于发送事件到Flutter |
|||
private weak var eventHandler: MediaButtonEventHandler? |
|||
|
|||
/// 是否已启用监听 |
|||
private var isListening = false |
|||
|
|||
/// 静音音频播放器 - 用于保持音频会话活跃 |
|||
private var silentAudioPlayer: AVAudioPlayer? |
|||
|
|||
/// 播放计时器 - 用于定期更新播放信息 |
|||
private var playbackTimer: Timer? |
|||
|
|||
/// 当前播放时间 - 模拟播放进度 |
|||
private var currentPlaybackTime: TimeInterval = 0 |
|||
|
|||
/// 总时长 - 模拟 |
|||
private let totalDuration: TimeInterval = 300.0 |
|||
|
|||
/// 音频会话管理器 |
|||
private let audioSessionManager = AudioSessionManager.shared |
|||
|
|||
// MARK: - 初始化 |
|||
|
|||
override init() { |
|||
// 获取远程控制中心单例 |
|||
self.remoteCommandCenter = MPRemoteCommandCenter.shared() |
|||
|
|||
super.init() |
|||
|
|||
NSLog("\(TAG): 初始化媒体按钮处理器") |
|||
|
|||
// 初始化静音音频播放器 |
|||
initSilentAudioPlayer() |
|||
} |
|||
|
|||
deinit { |
|||
stopButtonListening() |
|||
NSLog("\(TAG): 媒体按钮处理器已释放") |
|||
} |
|||
|
|||
// MARK: - 公共方法 |
|||
|
|||
/// 设置事件处理器 |
|||
@objc func setEventHandler(_ handler: MediaButtonEventHandler) { |
|||
eventHandler = handler |
|||
NSLog("\(TAG): 已设置媒体按钮事件处理器") |
|||
} |
|||
|
|||
/// 开始监听蓝牙媒体按钮事件 |
|||
@objc func startButtonListening() -> Bool { |
|||
if isListening { |
|||
NSLog("\(TAG): 已经在监听蓝牙媒体按钮事件") |
|||
return true |
|||
} |
|||
|
|||
NSLog("\(TAG): 开始监听蓝牙媒体按钮事件") |
|||
|
|||
// 立即注册远程命令 - 这不需要音频会话 |
|||
registerRemoteCommands() |
|||
|
|||
// 标记为已在监听 |
|||
isListening = true |
|||
|
|||
// 确保NowPlaying信息可用 - 这也不依赖于活跃的音频会话 |
|||
setupNowPlaying(isPlaying: true) |
|||
|
|||
// 尝试启用静音音频播放(但即使失败,远程命令仍然可用) |
|||
do { |
|||
// try audioSessionManager.configureForBluetoothMediaButtons() |
|||
startSilentAudio() |
|||
|
|||
// 延时1秒 |
|||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { |
|||
self.setupNowPlaying(isPlaying: false) |
|||
self.stopSilentAudio() |
|||
} |
|||
|
|||
// startUpdatingPlaybackInfo() |
|||
} catch { |
|||
NSLog("\(TAG): 音频设置或播放失败: \(error.localizedDescription)") |
|||
// 继续执行,因为远程命令在某些设备上不需要播放音频也能工作 |
|||
} |
|||
|
|||
NSLog("\(TAG): 蓝牙媒体按钮监听已启动") |
|||
return true |
|||
} |
|||
|
|||
/// 停止监听蓝牙媒体按钮事件 |
|||
@objc func stopButtonListening() -> Bool { |
|||
if !isListening { |
|||
return true |
|||
} |
|||
|
|||
NSLog("\(TAG): 停止监听蓝牙媒体按钮事件") |
|||
|
|||
// 停止播放静音音频 |
|||
stopSilentAudio() |
|||
|
|||
// 停止更新播放信息 |
|||
stopUpdatingPlaybackInfo() |
|||
|
|||
// 取消注册远程命令 |
|||
unregisterRemoteCommands() |
|||
|
|||
// 清除NowPlaying信息 |
|||
DispatchQueue.main.async { |
|||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil |
|||
} |
|||
|
|||
isListening = false |
|||
NSLog("\(TAG): 蓝牙媒体按钮监听已停止") |
|||
return true |
|||
} |
|||
|
|||
// MARK: - 私有方法 |
|||
|
|||
/// 初始化静音音频播放器 |
|||
private func initSilentAudioPlayer() { |
|||
do { |
|||
// 尝试加载sample.mp3文件 |
|||
if let audioPath = Bundle.main.path(forResource: "sample", ofType: "mp3") { |
|||
let audioUrl = URL(fileURLWithPath: audioPath) |
|||
|
|||
silentAudioPlayer = try AVAudioPlayer(contentsOf: audioUrl) |
|||
silentAudioPlayer?.numberOfLoops = -1 // 无限循环播放 |
|||
silentAudioPlayer?.volume = 0.0 // 设置为全音量 |
|||
silentAudioPlayer?.prepareToPlay() // 预加载 |
|||
silentAudioPlayer?.delegate = self |
|||
|
|||
NSLog("\(TAG): 音频播放器使用sample.mp3初始化成功") |
|||
return |
|||
} |
|||
|
|||
NSLog("\(TAG): 未找到sample.mp3文件,使用生成的静音数据") |
|||
|
|||
} catch { |
|||
NSLog("\(TAG): 初始化静音音频播放器失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
|
|||
/// 开始播放静音音频 |
|||
private func startSilentAudio() { |
|||
guard let player = silentAudioPlayer else { |
|||
NSLog("\(TAG): 无法开始播放,播放器未初始化") |
|||
return |
|||
} |
|||
|
|||
if !player.isPlaying { |
|||
let success = player.play() |
|||
if success { |
|||
NSLog("\(TAG): 开始播放静音音频") |
|||
} else { |
|||
NSLog("\(TAG): 播放启动失败") |
|||
} |
|||
} else { |
|||
NSLog("\(TAG): 静音音频已在播放中") |
|||
} |
|||
} |
|||
|
|||
/// 停止播放静音音频 |
|||
private func stopSilentAudio() { |
|||
silentAudioPlayer?.stop() |
|||
NSLog("\(TAG): 停止播放静音音频") |
|||
} |
|||
|
|||
/// 开始更新播放信息 |
|||
private func startUpdatingPlaybackInfo() { |
|||
// 初始设置 |
|||
setupNowPlaying(isPlaying: true) |
|||
|
|||
// 定期更新播放信息 - 恢复定时器,确保持续更新播放状态 |
|||
playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in |
|||
guard let self = self else { return } |
|||
|
|||
// 更新播放时间(模拟) |
|||
self.currentPlaybackTime += 1.0 |
|||
if self.currentPlaybackTime >= self.totalDuration { |
|||
self.currentPlaybackTime = 0.0 |
|||
} |
|||
|
|||
// 更新NowPlaying信息 |
|||
self.setupNowPlaying(isPlaying: true) |
|||
} |
|||
|
|||
NSLog("\(TAG): 开始更新播放信息") |
|||
} |
|||
|
|||
/// 停止更新播放信息 |
|||
private func stopUpdatingPlaybackInfo() { |
|||
playbackTimer?.invalidate() |
|||
playbackTimer = nil |
|||
|
|||
// 清空NowPlaying信息 |
|||
DispatchQueue.main.async { |
|||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil |
|||
} |
|||
|
|||
NSLog("\(TAG): 停止更新播放信息") |
|||
} |
|||
|
|||
/// 设置NowPlaying信息 |
|||
private func setupNowPlaying(isPlaying: Bool) { |
|||
NSLog("\(TAG): 设置NowPlaying信息, isPlaying=\(isPlaying)") |
|||
|
|||
// 创建音频专辑图片 |
|||
let albumArt = UIImage(named: "AppIcon") // 使用应用图标作为专辑图片 |
|||
var nowPlayingInfo: [String: Any] = [ |
|||
MPMediaItemPropertyTitle: "DeepSound 语音助手", |
|||
MPMediaItemPropertyArtist: "DeepSound", |
|||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0, // 1.0表示正在播放 |
|||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentPlaybackTime, |
|||
MPMediaItemPropertyPlaybackDuration: totalDuration |
|||
] |
|||
|
|||
// 如果有专辑图片,添加到NowPlaying信息中 |
|||
if let albumArt = albumArt { |
|||
let artwork = MPMediaItemArtwork(boundsSize: albumArt.size) { size in |
|||
return albumArt |
|||
} |
|||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork |
|||
} |
|||
|
|||
// 设置NowPlaying信息 (在主线程执行) |
|||
DispatchQueue.main.async { |
|||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo |
|||
NSLog("\(self.TAG): NowPlaying信息设置完成") |
|||
} |
|||
} |
|||
|
|||
/// 注册远程命令 |
|||
private func registerRemoteCommands() { |
|||
let commandCenter = MPRemoteCommandCenter.shared() |
|||
|
|||
// 播放/暂停命令 |
|||
commandCenter.togglePlayPauseCommand.isEnabled = true |
|||
commandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
|||
commandCenter.playCommand.isEnabled = true |
|||
commandCenter.playCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
|||
commandCenter.pauseCommand.isEnabled = true |
|||
commandCenter.pauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) |
|||
|
|||
// 下一曲/上一曲命令 |
|||
commandCenter.nextTrackCommand.isEnabled = true |
|||
commandCenter.nextTrackCommand.addTarget(self, action: #selector(handleNextTrackCommand(_:))) |
|||
commandCenter.previousTrackCommand.isEnabled = true |
|||
commandCenter.previousTrackCommand.addTarget(self, action: #selector(handlePreviousTrackCommand(_:))) |
|||
|
|||
// 快进/快退命令 |
|||
commandCenter.seekForwardCommand.isEnabled = true |
|||
commandCenter.seekForwardCommand.addTarget(self, action: #selector(handleSeekForwardCommand(_:))) |
|||
commandCenter.seekBackwardCommand.isEnabled = true |
|||
commandCenter.seekBackwardCommand.addTarget(self, action: #selector(handleSeekBackwardCommand(_:))) |
|||
|
|||
// 停止命令 |
|||
commandCenter.stopCommand.isEnabled = true |
|||
commandCenter.stopCommand.addTarget(self, action: #selector(handleStopCommand(_:))) |
|||
|
|||
|
|||
|
|||
NSLog("\(TAG): 远程命令已注册") |
|||
} |
|||
|
|||
/// 取消注册远程命令 |
|||
private func unregisterRemoteCommands() { |
|||
NSLog("\(TAG): 取消注册远程命令") |
|||
|
|||
// 基本控制命令 |
|||
remoteCommandCenter.playCommand.isEnabled = false |
|||
remoteCommandCenter.playCommand.removeTarget(nil) |
|||
|
|||
remoteCommandCenter.pauseCommand.isEnabled = false |
|||
remoteCommandCenter.pauseCommand.removeTarget(nil) |
|||
|
|||
remoteCommandCenter.togglePlayPauseCommand.isEnabled = false |
|||
remoteCommandCenter.togglePlayPauseCommand.removeTarget(nil) |
|||
|
|||
remoteCommandCenter.stopCommand.isEnabled = false |
|||
remoteCommandCenter.stopCommand.removeTarget(nil) |
|||
|
|||
|
|||
} |
|||
|
|||
/// 发送按钮事件到Flutter |
|||
private func sendButtonEvent(type: String) { |
|||
// 创建事件数据 |
|||
let eventData: [String: Any] = [ |
|||
"type": "mediaButtonEvent", |
|||
"buttonType": type, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
|
|||
// 发送事件到处理器 |
|||
eventHandler?.sendEvent(eventData) |
|||
} |
|||
|
|||
|
|||
// MARK: - 命令处理方法 |
|||
|
|||
/// 处理播放/暂停命令 |
|||
@objc private func handlePlayPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|||
NSLog("\(TAG): 收到播放/暂停命令") |
|||
|
|||
// 发送事件到Flutter |
|||
sendButtonEvent(type: "play_pause") |
|||
|
|||
// 调用VoiceInteractionService处理媒体按钮事件 |
|||
if VoiceInteractionService.shared.isServiceRunning() { |
|||
NSLog("\(TAG): 转发到VoiceInteractionService处理") |
|||
VoiceInteractionService.shared.handleMediaButtonAction() |
|||
} |
|||
|
|||
return .success |
|||
} |
|||
|
|||
/// 处理下一曲命令 |
|||
@objc private func handleNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|||
NSLog("\(TAG): 收到下一曲命令") |
|||
|
|||
// 发送事件到Flutter |
|||
sendButtonEvent(type: "next_track") |
|||
|
|||
// 也可以转发到VoiceInteractionService |
|||
if VoiceInteractionService.shared.isServiceRunning() { |
|||
VoiceInteractionService.shared.handleMediaButtonAction() |
|||
} |
|||
|
|||
return .success |
|||
} |
|||
|
|||
/// 处理上一曲命令 |
|||
@objc private func handlePreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|||
NSLog("\(TAG): 收到上一曲命令") |
|||
|
|||
// 发送事件到Flutter |
|||
sendButtonEvent(type: "previous_track") |
|||
|
|||
// 也可以转发到VoiceInteractionService |
|||
if VoiceInteractionService.shared.isServiceRunning() { |
|||
VoiceInteractionService.shared.handleMediaButtonAction() |
|||
} |
|||
|
|||
return .success |
|||
} |
|||
|
|||
/// 处理快进命令 |
|||
@objc private func handleSeekForwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|||
NSLog("\(TAG): 收到快进命令") |
|||
|
|||
// 发送事件到Flutter |
|||
sendButtonEvent(type: "seek_forward") |
|||
|
|||
return .success |
|||
} |
|||
|
|||
/// 处理快退命令 |
|||
@objc private func handleSeekBackwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|||
NSLog("\(TAG): 收到快退命令") |
|||
|
|||
// 发送事件到Flutter |
|||
sendButtonEvent(type: "seek_backward") |
|||
|
|||
return .success |
|||
} |
|||
|
|||
/// 处理停止命令 |
|||
@objc private func handleStopCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { |
|||
NSLog("\(TAG): 收到停止命令") |
|||
|
|||
// 发送事件到Flutter |
|||
sendButtonEvent(type: "stop") |
|||
|
|||
// 调用VoiceInteractionService处理媒体按钮事件 |
|||
if VoiceInteractionService.shared.isServiceRunning() { |
|||
VoiceInteractionService.shared.handleMediaButtonAction() |
|||
} |
|||
|
|||
return .success |
|||
} |
|||
|
|||
// MARK: - AVAudioPlayerDelegate方法 |
|||
|
|||
/// 音频播放结束回调 |
|||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { |
|||
NSLog("\(TAG): 音频播放结束, 成功: \(flag)") |
|||
|
|||
// 如果仍在监听状态,重新开始播放 |
|||
if isListening && player == silentAudioPlayer { |
|||
player.play() |
|||
} |
|||
} |
|||
|
|||
/// 音频播放错误回调 |
|||
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { |
|||
if let error = error { |
|||
NSLog("\(TAG): 音频解码错误: \(error.localizedDescription)") |
|||
} else { |
|||
NSLog("\(TAG): 音频解码发生未知错误") |
|||
} |
|||
|
|||
// 重新初始化播放器 |
|||
if player == silentAudioPlayer && isListening { |
|||
initSilentAudioPlayer() |
|||
startSilentAudio() |
|||
} |
|||
} |
|||
|
|||
/// 清理和释放资源 |
|||
func dispose() { |
|||
NSLog("\(TAG): 释放BluetoothMediaButtonHelper资源") |
|||
// 停止监听 |
|||
stopButtonListening() |
|||
|
|||
// 释放播放器和计时器资源 |
|||
silentAudioPlayer = nil |
|||
if let timer = playbackTimer { |
|||
timer.invalidate() |
|||
playbackTimer = nil |
|||
} |
|||
|
|||
// 清除引用 |
|||
eventHandler = nil |
|||
|
|||
NSLog("\(TAG): 资源已释放") |
|||
} |
|||
} |
|||
|
|||
extension UInt32 { |
|||
var data: Data { |
|||
var int = self |
|||
return Data(bytes: &int, count: MemoryLayout<UInt32>.size) |
|||
} |
|||
} |
|||
|
|||
extension UInt16 { |
|||
var data: Data { |
|||
var int = self |
|||
return Data(bytes: &int, count: MemoryLayout<UInt16>.size) |
|||
} |
|||
} |
|||
@ -1,243 +0,0 @@ |
|||
import Foundation |
|||
import CoreBluetooth |
|||
import AVFoundation |
|||
import UIKit |
|||
|
|||
/// 蓝牙事件处理协议 |
|||
@objc protocol BluetoothEventHandler: AnyObject { |
|||
func sendEvent(_ event: [String: Any]) |
|||
} |
|||
|
|||
@objc class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { |
|||
// MARK: - 属性 |
|||
|
|||
// 音频会话管理器 |
|||
private let audioSessionManager = AudioSessionManager.shared |
|||
|
|||
// 蓝牙管理器 |
|||
private var centralManager: CBCentralManager? |
|||
|
|||
// 事件处理器 |
|||
private weak var eventHandler: BluetoothEventHandler? |
|||
|
|||
// MARK: - 初始化与设置 |
|||
|
|||
override init() { |
|||
super.init() |
|||
} |
|||
|
|||
// 设置事件处理器 |
|||
@objc func setEventHandler(_ handler: BluetoothEventHandler) { |
|||
eventHandler = handler |
|||
} |
|||
|
|||
|
|||
// MARK: - CBCentralManagerDelegate |
|||
|
|||
func centralManagerDidUpdateState(_ central: CBCentralManager) { |
|||
let stateString = getBluetoothStateString(state: central.state) |
|||
sendBluetoothStateEvent(state: stateString) |
|||
} |
|||
|
|||
// MARK: - 设备监控 |
|||
|
|||
// 获取蓝牙状态字符串 |
|||
private func getBluetoothStateString(state: CBManagerState) -> String { |
|||
switch state { |
|||
case .poweredOn: |
|||
return "on" |
|||
case .poweredOff: |
|||
return "off" |
|||
default: |
|||
return "unknown" |
|||
} |
|||
} |
|||
|
|||
// 发送蓝牙状态事件 |
|||
private func sendBluetoothStateEvent(state: String) { |
|||
let event: [String: Any] = [ |
|||
"type": "bluetoothStateChanged", |
|||
"state": state, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
eventHandler?.sendEvent(event) |
|||
} |
|||
|
|||
// 启动蓝牙设备监控 |
|||
@objc func initialize() { |
|||
if centralManager == nil { |
|||
centralManager = CBCentralManager(delegate: self, queue: nil) |
|||
} |
|||
|
|||
|
|||
|
|||
// 发送当前蓝牙状态 |
|||
if let manager = centralManager { |
|||
let stateString = getBluetoothStateString(state: manager.state) |
|||
sendBluetoothStateEvent(state: stateString) |
|||
} |
|||
// 移除之前的监听器 |
|||
NotificationCenter.default.removeObserver(self) |
|||
// 添加新的监听器 |
|||
audioSessionManager.addRouteChangeListener(self, selector: #selector(handleRouteChange(_:))) |
|||
// BluetoothMediaButtonHelper.shared.startButtonListening() |
|||
} |
|||
|
|||
// 处理音频路由变化 |
|||
@objc private func handleRouteChange(_ notification: Notification) { |
|||
guard let userInfo = notification.userInfo, |
|||
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, |
|||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { |
|||
return |
|||
} |
|||
|
|||
// 获取当前设备 |
|||
let currentDevices = getConnectedAudioDevices() |
|||
|
|||
// 检查原因 |
|||
switch reason { |
|||
case .newDeviceAvailable: |
|||
// 新设备已连接 |
|||
for device in currentDevices { |
|||
// 发送设备连接事件 |
|||
NSLog("发送设备连接事件 - 设备: \(device["name"] ?? "未知设备") [\(device["address"] ?? "未知地址")]") |
|||
sendDeviceConnectedEvent(device: device) |
|||
} |
|||
|
|||
case .oldDeviceUnavailable: |
|||
// 获取断开之前的路由 |
|||
if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { |
|||
// 过滤蓝牙相关设备 |
|||
for output in previousRoute.outputs where isBluetoothOutput(output.portType) { |
|||
let device: [String: String] = [ |
|||
"name": output.portName, |
|||
"address": cleanDeviceAddress(output.uid) |
|||
] |
|||
// 发送设备断开事件 |
|||
sendDeviceDisconnectedEvent(device: device) |
|||
} |
|||
} |
|||
|
|||
default: |
|||
break |
|||
} |
|||
} |
|||
|
|||
// 检查是否为蓝牙输出 |
|||
private func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { |
|||
return portType == .bluetoothA2DP || portType == .bluetoothHFP || portType == .bluetoothLE |
|||
} |
|||
|
|||
// MARK: - 设备事件 |
|||
|
|||
// 发送设备连接事件 |
|||
private func sendDeviceConnectedEvent(device: [String: String]) { |
|||
guard let name = device["name"], let address = device["address"] else { return } |
|||
|
|||
let deviceMap: [String: Any] = [ |
|||
"name": name, |
|||
"address": address, |
|||
"type": "headset" |
|||
] |
|||
|
|||
let event: [String: Any] = [ |
|||
"type": "deviceConnected", |
|||
"device": deviceMap, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
|
|||
eventHandler?.sendEvent(event) |
|||
} |
|||
|
|||
// 发送设备断开事件 |
|||
private func sendDeviceDisconnectedEvent(device: [String: String]) { |
|||
guard let name = device["name"], let address = device["address"] else { return } |
|||
|
|||
let deviceMap: [String: Any] = [ |
|||
"name": name, |
|||
"address": address, |
|||
"type": "headset" |
|||
] |
|||
|
|||
let event: [String: Any] = [ |
|||
"type": "deviceDisconnected", |
|||
"device": deviceMap, |
|||
"timestamp": Int(Date().timeIntervalSince1970 * 1000) |
|||
] |
|||
|
|||
eventHandler?.sendEvent(event) |
|||
} |
|||
|
|||
// MARK: - 公共API |
|||
|
|||
// 获取当前连接的耳机设备 |
|||
@objc func getConnectedHeadsetDevices(_ completion: @escaping ([[String: String]]?, String?) -> Void) { |
|||
// 检查蓝牙状态 |
|||
if centralManager?.state != .poweredOn { |
|||
completion(nil, "蓝牙未启用") |
|||
return |
|||
} |
|||
|
|||
do { |
|||
// 获取设备 |
|||
let devices = getConnectedAudioDevices() |
|||
completion(devices, nil) |
|||
} catch { |
|||
completion(nil, "获取设备失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
|
|||
// 获取当前连接的音频设备 |
|||
private func getConnectedAudioDevices() -> [[String: String]] { |
|||
var result: [[String: String]] = [] |
|||
|
|||
// 获取当前音频路由 |
|||
let outputs = audioSessionManager.getCurrentRoute().outputs |
|||
|
|||
// 过滤蓝牙相关设备 |
|||
for output in outputs where isBluetoothOutput(output.portType) { |
|||
let device: [String: String] = [ |
|||
"name": output.portName, |
|||
"address": cleanDeviceAddress(output.uid) // 处理设备地址,移除后缀 |
|||
] |
|||
result.append(device) |
|||
} |
|||
|
|||
return result |
|||
} |
|||
|
|||
// 释放资源 |
|||
@objc func dispose() { |
|||
audioSessionManager.removeRouteChangeListener(self) |
|||
eventHandler = nil |
|||
} |
|||
|
|||
|
|||
|
|||
// 检查蓝牙是否启用 |
|||
@objc func isBluetoothEnabled() -> Bool { |
|||
return centralManager?.state == .poweredOn |
|||
} |
|||
|
|||
// 清理设备地址,移除后缀 |
|||
private func cleanDeviceAddress(_ address: String) -> String { |
|||
// 检查是否包含常见分隔符 |
|||
if let range = address.range(of: "-") ?? address.range(of: ":") ?? address.range(of: "_") { |
|||
// 只保留分隔符之前的部分 |
|||
return String(address[..<range.lowerBound]) |
|||
} |
|||
|
|||
// iOS设备UID有时会包含额外信息,尝试只保留标准MAC地址格式部分 |
|||
// MAC地址通常是12个十六进制字符,可能带有分隔符 |
|||
let hexPattern = "[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}|[0-9A-Fa-f]{12}" |
|||
if let regex = try? NSRegularExpression(pattern: hexPattern, options: []), |
|||
let match = regex.firstMatch(in: address, options: [], range: NSRange(location: 0, length: address.utf16.count)) { |
|||
let matchRange = match.range |
|||
if let range = Range(matchRange, in: address) { |
|||
return String(address[range]) |
|||
} |
|||
} |
|||
|
|||
return address |
|||
} |
|||
} |
|||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,5 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict/> |
|||
</plist> |
|||
@ -1,565 +0,0 @@ |
|||
import Foundation |
|||
import UIKit |
|||
import AVFoundation |
|||
|
|||
/** |
|||
* 语音交互服务,iOS原生实现 |
|||
* |
|||
* 负责: |
|||
* 1) 监听蓝牙耳机按键 |
|||
* 2) 处理语音识别 |
|||
* 3) 与火山AI服务交互 |
|||
* 4) 文本转语音播放 |
|||
*/ |
|||
@available(iOS 13.0, *) |
|||
class VoiceInteractionService: NSObject { |
|||
// 常量 |
|||
private let TAG = "VoiceInteractionService" |
|||
private let RECOGNITION_TIMEOUT: TimeInterval = 8.0 |
|||
|
|||
// 单例 |
|||
static let shared = VoiceInteractionService() |
|||
|
|||
// 服务状态 |
|||
private var isRunning = false |
|||
private var isActive = false |
|||
private var isRecognitionActive = false |
|||
private var isTimeoutPaused = false |
|||
private var hasSpeechDetected = false |
|||
private var isTtsSpeaking = false |
|||
|
|||
// 事件处理器 |
|||
private weak var eventHandler: VoiceInteractionEventHandler? |
|||
|
|||
// 按键处理 |
|||
private var lastKeyEventTime: TimeInterval = 0 |
|||
private var keyEventCount = 0 |
|||
|
|||
// 活动时间 |
|||
private var lastActivityTime: TimeInterval = 0 |
|||
|
|||
// 当前用户输入 |
|||
private var currentUserInput = "" |
|||
private var lastUserMessage = "" |
|||
private var lastAssistantMessage = "" |
|||
|
|||
// 服务组件 |
|||
private var azureAsrHelper: AzureAsrHelper? |
|||
private var azureTtsHelper: AzureTtsHelper? |
|||
private var volcanoAIService: VolcanoAIService? |
|||
|
|||
|
|||
private var silenceTimer: Timer? |
|||
|
|||
// 系统提示词 |
|||
private let systemPrompt = """ |
|||
你是一个智能语音助手,能够简洁明了地回答用户的问题。 |
|||
请保持回答简短、准确,避免过长的解释。 |
|||
如果用户的问题不清楚,请礼貌地请求澄清。 |
|||
不要使用复杂的术语,除非用户明确要求。 |
|||
用户用语音和你交互。 |
|||
""" |
|||
|
|||
// 初始化方法 |
|||
private override init() { |
|||
super.init() |
|||
NSLog("%@: VoiceInteractionService 初始化中", TAG) |
|||
|
|||
|
|||
} |
|||
|
|||
/** |
|||
* 启动服务 |
|||
* |
|||
* @param apiKey Azure语音服务API密钥 |
|||
* @param region Azure语音服务区域 |
|||
* @param volcanoKey 火山AI服务API密钥 |
|||
* @return 启动是否成功 |
|||
*/ |
|||
func start(azureKey: String, azureRegion: String, volcanoKey: String) -> Bool { |
|||
guard !isRunning else { |
|||
NSLog("%@: 服务已经在运行", TAG) |
|||
return true |
|||
} |
|||
|
|||
NSLog("%@: 启动服务中", TAG) |
|||
|
|||
// 重置状态 |
|||
resetState() |
|||
|
|||
// 初始化各个服务组件 |
|||
initServices(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) |
|||
|
|||
// 启动monitorService定时器 |
|||
silenceTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(monitorService), userInfo: nil, repeats: true) |
|||
NSLog("%@: monitorService定时器已启动", TAG) |
|||
|
|||
// 标记服务为运行状态 |
|||
isRunning = true |
|||
|
|||
NSLog("%@: 服务启动完成,等待蓝牙按键事件", TAG) |
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 停止服务 |
|||
*/ |
|||
func stop() { |
|||
guard isRunning else { |
|||
NSLog("%@: 服务未运行", TAG) |
|||
return |
|||
} |
|||
|
|||
NSLog("%@: 停止服务中", TAG) |
|||
|
|||
// 停止monitorService定时器 |
|||
silenceTimer?.invalidate() |
|||
silenceTimer = nil |
|||
|
|||
// 停止语音识别 |
|||
if isRecognitionActive { |
|||
stopVoiceRecognition() |
|||
} |
|||
|
|||
// 停止TTS |
|||
stopCurrentTTS() |
|||
|
|||
// 释放服务实例 |
|||
azureAsrHelper?.dispose() |
|||
azureTtsHelper?.dispose() |
|||
|
|||
// 更新状态 |
|||
resetState() |
|||
isRunning = false |
|||
|
|||
NSLog("%@: 服务已停止", TAG) |
|||
} |
|||
|
|||
/** |
|||
* 获取服务运行状态 |
|||
*/ |
|||
func isServiceRunning() -> Bool { |
|||
return isRunning |
|||
} |
|||
|
|||
/** |
|||
* 重置状态 |
|||
*/ |
|||
private func resetState() { |
|||
isActive = false |
|||
isRecognitionActive = false |
|||
isTimeoutPaused = false |
|||
hasSpeechDetected = false |
|||
isTtsSpeaking = false |
|||
lastUserMessage = "" |
|||
lastAssistantMessage = "" |
|||
} |
|||
|
|||
/** |
|||
* 初始化服务 |
|||
*/ |
|||
private func initServices(azureKey: String, azureRegion: String, volcanoKey: String) { |
|||
NSLog("%@: 初始化服务组件", TAG) |
|||
|
|||
// 初始化Azure ASR |
|||
azureAsrHelper = AzureAsrHelper() |
|||
if let helper = azureAsrHelper { |
|||
helper.setEventHandler({ [weak self] eventData in |
|||
// 处理ASR事件 |
|||
self?.handleAsrEvent(eventData: eventData) |
|||
}) |
|||
} |
|||
|
|||
// 初始化Azure TTS |
|||
azureTtsHelper = AzureTtsHelper() |
|||
if let helper = azureTtsHelper { |
|||
helper.setEventHandler({ [weak self] eventData in |
|||
// 处理TTS事件 |
|||
self?.handleTtsEvent(eventData: eventData) |
|||
}) |
|||
} |
|||
|
|||
// 初始化火山AI服务 |
|||
volcanoAIService = VolcanoAIService() |
|||
|
|||
// 配置Azure ASR |
|||
if !azureKey.isEmpty && !azureRegion.isEmpty { |
|||
azureAsrHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, supportedLanguages: ["zh-CN"]) |
|||
NSLog("%@: Azure ASR初始化完成", TAG) |
|||
} else { |
|||
NSLog("%@: Azure配置信息不完整,无法初始化Azure ASR", TAG) |
|||
} |
|||
|
|||
// 配置Azure TTS |
|||
if !azureKey.isEmpty && !azureRegion.isEmpty { |
|||
azureTtsHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, language: "zh-CN") |
|||
NSLog("%@: Azure TTS初始化完成", TAG) |
|||
} else { |
|||
NSLog("%@: Azure配置信息不完整,无法初始化Azure TTS", TAG) |
|||
} |
|||
|
|||
// 配置火山AI |
|||
if !volcanoKey.isEmpty { |
|||
volcanoAIService?.initialize(apiKey: volcanoKey) |
|||
NSLog("%@: 火山AI服务初始化完成", TAG) |
|||
} else { |
|||
NSLog("%@: 火山AI配置信息不完整,无法初始化火山AI服务", TAG) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 监控服务状态 |
|||
*/ |
|||
@objc private func monitorService() { |
|||
// 确保服务保持活跃状态 |
|||
if !isActive { |
|||
isActive = true |
|||
} |
|||
|
|||
// 检查语音识别状态 |
|||
if isRecognitionActive { |
|||
let currentTime = Date().timeIntervalSince1970 |
|||
let elapsedTime = currentTime - lastActivityTime |
|||
|
|||
// NSLog("%@: 已过去时间: %@, 是否检测到语音: %@, 是否TTS播放: %@", TAG, String(elapsedTime), String(hasSpeechDetected), String(isTtsSpeaking), String(elapsedTime)) |
|||
|
|||
// 如果超过超时时间没有检测到语音,且不在TTS播放中,暂停语音识别 |
|||
if !hasSpeechDetected && !isTtsSpeaking && elapsedTime >= RECOGNITION_TIMEOUT { |
|||
// NSLog("%@: 超过%@秒未检测到语音,停止识别", TAG, String(RECOGNITION_TIMEOUT)) |
|||
isTimeoutPaused = true |
|||
playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。") |
|||
|
|||
// 停止语音识别并确保资源完全释放 |
|||
stopVoiceRecognition() |
|||
|
|||
// 清理识别状态 |
|||
isRecognitionActive = false |
|||
hasSpeechDetected = false |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
/** |
|||
* 处理ASR事件 |
|||
*/ |
|||
private func handleAsrEvent(eventData: [String: Any]) { |
|||
if let type = eventData["type"] as? String { |
|||
// NSLog("%@: 处理ASR事件: %@", TAG, type) |
|||
|
|||
switch type { |
|||
case "recognizing": |
|||
if let text = eventData["text"] as? String, !text.isEmpty { |
|||
hasSpeechDetected = true |
|||
stopCurrentTTS() |
|||
updateLastActivityTime() |
|||
} else if let data = eventData["data"] as? [String: Any], |
|||
let text = data["text"] as? String, |
|||
!text.isEmpty { |
|||
hasSpeechDetected = true |
|||
stopCurrentTTS() |
|||
updateLastActivityTime() |
|||
} |
|||
|
|||
case "result": |
|||
var text: String? |
|||
|
|||
// 直接读取text或从data中读取 |
|||
if let directText = eventData["text"] as? String, !directText.isEmpty { |
|||
text = directText |
|||
} else if let data = eventData["data"] as? [String: Any], |
|||
let dataText = data["text"] as? String, |
|||
!dataText.isEmpty { |
|||
text = dataText |
|||
} |
|||
|
|||
if let finalText = text { |
|||
updateLastActivityTime() |
|||
|
|||
// 保存用户消息 |
|||
lastUserMessage = finalText |
|||
|
|||
processWithVolcanoAI(finalText) |
|||
} |
|||
|
|||
// 重置状态,继续识别 |
|||
hasSpeechDetected = false |
|||
|
|||
case "sessionStarted": |
|||
updateLastActivityTime() |
|||
|
|||
case "sessionStopped": |
|||
isRecognitionActive = false |
|||
|
|||
case "canceled": |
|||
isRecognitionActive = false |
|||
|
|||
case "error": |
|||
isRecognitionActive = false |
|||
if let message = eventData["message"] as? String { |
|||
NSLog("%@: ASR错误: %@", TAG, message) |
|||
} |
|||
playNotification("语音识别出错") |
|||
|
|||
default: |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理TTS事件 |
|||
*/ |
|||
private func handleTtsEvent(eventData: [String: Any]) { |
|||
if let type = eventData["type"] as? String { |
|||
// NSLog("%@: 处理TTS事件: %@", TAG, type) |
|||
|
|||
switch type { |
|||
case "started": |
|||
isTtsSpeaking = true |
|||
|
|||
case "completed": |
|||
isTtsSpeaking = false |
|||
updateLastActivityTime() |
|||
|
|||
case "canceled": |
|||
isTtsSpeaking = false |
|||
|
|||
case "error": |
|||
isTtsSpeaking = false |
|||
if let message = eventData["message"] as? String { |
|||
NSLog("%@: TTS错误: %@", TAG, message) |
|||
} |
|||
|
|||
default: |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理双击事件 |
|||
*/ |
|||
func handleMediaButtonAction() { |
|||
NSLog("%@: 处理媒体按钮事件", TAG) |
|||
|
|||
// 屏蔽短时间重复响应的问题 |
|||
let currentTime = Date().timeIntervalSince1970 |
|||
let timeDiff = currentTime - lastKeyEventTime |
|||
if timeDiff < 0.5 { |
|||
NSLog("%@: 短时间重复响应,忽略", TAG) |
|||
return |
|||
} |
|||
lastKeyEventTime = currentTime |
|||
|
|||
// 停止当前TTS播放 |
|||
stopCurrentTTS() |
|||
|
|||
// 检查应用是否在后台 |
|||
if UIApplication.shared.applicationState == .background { |
|||
NSLog("%@: 应用在后台,无法开始语音识别", TAG) |
|||
playNotification("请打开应用后重试!") |
|||
return |
|||
} |
|||
// 播放提示音 |
|||
playPrompt("我在!") |
|||
|
|||
// 重置超时暂停标志 |
|||
isTimeoutPaused = false |
|||
|
|||
// 启动或重置语音识别 |
|||
if !isRecognitionActive { |
|||
NSLog("%@: 语音识别未激活,开始启动", TAG) |
|||
|
|||
startVoiceRecognition() |
|||
} else { |
|||
NSLog("%@: 语音识别已激活,更新活动时间", TAG) |
|||
updateLastActivityTime() |
|||
hasSpeechDetected = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 开始语音识别 |
|||
*/ |
|||
private func startVoiceRecognition() { |
|||
if isRecognitionActive { return } |
|||
|
|||
|
|||
|
|||
// 通知Flutter语音识别已启动 |
|||
notifyVoiceRecognitionStarted() |
|||
|
|||
isActive = true |
|||
isRecognitionActive = true |
|||
hasSpeechDetected = false |
|||
updateLastActivityTime() |
|||
|
|||
if let asrHelper = azureAsrHelper { |
|||
let success = asrHelper.startContinuousRecognition() |
|||
if !success { |
|||
isRecognitionActive = false |
|||
NSLog("%@: 启动语音识别失败", TAG) |
|||
playNotification("启动语音识别失败") |
|||
} |
|||
} else { |
|||
isRecognitionActive = false |
|||
NSLog("%@: Azure ASR服务未初始化", TAG) |
|||
playNotification("语音识别服务未初始化") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止语音识别 |
|||
*/ |
|||
private func stopVoiceRecognition() { |
|||
if !isRecognitionActive { return } |
|||
|
|||
NSLog("%@: 停止语音识别", TAG) |
|||
|
|||
if let asrHelper = azureAsrHelper { |
|||
let success = asrHelper.stopContinuousRecognition() |
|||
if !success { |
|||
NSLog("%@: 停止语音识别失败", TAG) |
|||
} |
|||
} |
|||
|
|||
isRecognitionActive = false |
|||
hasSpeechDetected = false |
|||
} |
|||
|
|||
/** |
|||
* 使用火山AI处理语音识别结果 |
|||
*/ |
|||
private func processWithVolcanoAI(_ text: String) { |
|||
// 保存当前用户输入 |
|||
currentUserInput = text |
|||
|
|||
// 在后台线程处理 |
|||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in |
|||
guard let self = self, let volcanoAIService = self.volcanoAIService else { |
|||
return |
|||
} |
|||
|
|||
do { |
|||
// 创建消息 |
|||
let message = volcanoAIService.createUserMessage(content: text) |
|||
let messages = [message] |
|||
|
|||
// 发送请求 |
|||
let response = try volcanoAIService.sendMessage(messages: messages, systemPrompt: self.systemPrompt) |
|||
|
|||
// 保存助手回复 |
|||
self.lastAssistantMessage = response |
|||
|
|||
// 播放AI回复 |
|||
self.speakAIResponse(response) |
|||
|
|||
// 同步聊天记录到Flutter端 |
|||
self.notifyChatHistoryUpdated(userMessage: text, assistantMessage: response) |
|||
} catch { |
|||
NSLog("%@: AI处理出错: %@", self.TAG, error.localizedDescription) |
|||
self.playNotification("AI处理出错") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 播放AI回复 |
|||
*/ |
|||
private func speakAIResponse(_ text: String) { |
|||
isTtsSpeaking = true |
|||
azureTtsHelper?.speakText(text: text) |
|||
} |
|||
|
|||
/** |
|||
* 播放提示音 |
|||
*/ |
|||
private func playPrompt(_ message: String) { |
|||
isTtsSpeaking = true |
|||
azureTtsHelper?.speakText(text: message) |
|||
} |
|||
|
|||
/** |
|||
* 播放通知提示音 |
|||
*/ |
|||
private func playNotification(_ message: String) { |
|||
isTtsSpeaking = true |
|||
azureTtsHelper?.speakText(text: message) |
|||
} |
|||
|
|||
/** |
|||
* 停止当前TTS播放 |
|||
*/ |
|||
private func stopCurrentTTS() { |
|||
if isTtsSpeaking { |
|||
azureTtsHelper?.stopSpeaking() |
|||
isTtsSpeaking = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新最后活动时间 |
|||
*/ |
|||
private func updateLastActivityTime() { |
|||
lastActivityTime = Date().timeIntervalSince1970 |
|||
} |
|||
|
|||
/** |
|||
* 暂停语音交互 |
|||
*/ |
|||
func pauseVoiceInteraction() { |
|||
NSLog("%@: 暂停语音交互", TAG) |
|||
|
|||
// 停止当前语音播放 |
|||
stopCurrentTTS() |
|||
|
|||
// 停止语音识别 |
|||
if isRecognitionActive { |
|||
stopVoiceRecognition() |
|||
} |
|||
|
|||
// 设置状态为暂停,但服务保持运行 |
|||
isTimeoutPaused = true |
|||
} |
|||
|
|||
/** |
|||
* 通知语音识别已开始 |
|||
*/ |
|||
private func notifyVoiceRecognitionStarted() { |
|||
// 发送识别开始事件 |
|||
sendEvent(type: "recognition_started", data: [:]) |
|||
} |
|||
|
|||
/** |
|||
* 通知聊天历史更新 |
|||
*/ |
|||
private func notifyChatHistoryUpdated(userMessage: String, assistantMessage: String) { |
|||
// 发送聊天历史更新事件 |
|||
sendEvent(type: "chat_history_updated", data: [ |
|||
"agentId": "personal_assistant", |
|||
"userMessage": userMessage, |
|||
"assistantMessage": assistantMessage |
|||
]) |
|||
} |
|||
|
|||
/** |
|||
* 设置事件处理器 |
|||
*/ |
|||
func setEventHandler(_ handler: VoiceInteractionEventHandler) { |
|||
self.eventHandler = handler |
|||
} |
|||
|
|||
/** |
|||
* 发送事件到Flutter端 |
|||
*/ |
|||
private func sendEvent(type: String, data: [String: Any] = [:]) { |
|||
var eventData = data |
|||
eventData["type"] = type |
|||
eventData["timestamp"] = Int(Date().timeIntervalSince1970 * 1000) |
|||
|
|||
// 使用事件处理器发送事件 |
|||
eventHandler?.sendEvent(eventData) |
|||
} |
|||
|
|||
} |
|||
@ -1,391 +0,0 @@ |
|||
import Foundation |
|||
|
|||
/** |
|||
* 火山AI服务的iOS原生实现 |
|||
* |
|||
* 参考Android端的VolcanoAIService实现,提供同步和异步的API调用方式 |
|||
*/ |
|||
class VolcanoAIService { |
|||
private let TAG = "VolcanoAIService" |
|||
private let baseUrl = "https://ark.cn-beijing.volces.com/api/v3" |
|||
private let chatEndpoint = "/chat/completions" |
|||
private let session: URLSession |
|||
|
|||
private var apiKey: String = "" |
|||
private var isInitialized = false |
|||
|
|||
init() { |
|||
let config = URLSessionConfiguration.default |
|||
config.timeoutIntervalForRequest = 30.0 |
|||
config.timeoutIntervalForResource = 30.0 |
|||
self.session = URLSession(configuration: config) |
|||
} |
|||
|
|||
/** |
|||
* 初始化火山AI服务 |
|||
* |
|||
* @param apiKey 火山AI API密钥 |
|||
* @return 初始化是否成功 |
|||
*/ |
|||
func initialize(apiKey: String) -> Bool { |
|||
self.apiKey = apiKey |
|||
isInitialized = !apiKey.isEmpty |
|||
|
|||
if !isInitialized { |
|||
NSLog("%@: 初始化失败:API key 不能为空", TAG) |
|||
} else { |
|||
NSLog("%@: 火山AI服务初始化成功", TAG) |
|||
} |
|||
|
|||
return isInitialized |
|||
} |
|||
|
|||
/** |
|||
* 生成个性化问候语 |
|||
* |
|||
* @param agentName 代理名称 |
|||
* @param systemPrompt 系统提示词 |
|||
* @param callback 回调函数,返回生成的问候语 |
|||
*/ |
|||
func generateGreeting(agentName: String, systemPrompt: String, callback: @escaping (String?, Error?) -> Void) { |
|||
let messages: [[String: Any]] = [ |
|||
["role": "system", "content": systemPrompt], |
|||
["role": "user", "content": "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。"] |
|||
] |
|||
|
|||
let messagesData = try? JSONSerialization.data(withJSONObject: messages, options: []) |
|||
let messagesArray = try? JSONSerialization.jsonObject(with: messagesData!, options: []) as? [[String: Any]] |
|||
|
|||
var result = "" |
|||
|
|||
sendMessageStream(messages: messagesArray!, systemPrompt: systemPrompt, streamCallback: StreamCallback( |
|||
onToken: { token in |
|||
result.append(token) |
|||
}, |
|||
onComplete: { |
|||
callback(result, nil) |
|||
}, |
|||
onError: { error in |
|||
callback(nil, error) |
|||
} |
|||
)) |
|||
} |
|||
|
|||
/** |
|||
* 发送消息(非流式输出) |
|||
* |
|||
* @param messages 消息列表 |
|||
* @param systemPrompt 系统提示词 |
|||
* @return 返回AI的回复 |
|||
* @throws VolcanoAIError 如果API调用失败 |
|||
*/ |
|||
func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { |
|||
// 检查是否已初始化 |
|||
if !isInitialized || apiKey.isEmpty { |
|||
throw VolcanoAIError.serviceNotInitialized |
|||
} |
|||
|
|||
var fullMessages: [[String: Any]] = [ |
|||
["role": "system", "content": systemPrompt] |
|||
] |
|||
|
|||
fullMessages.append(contentsOf: messages) |
|||
|
|||
let requestBody: [String: Any] = [ |
|||
"model": "doubao-1-5-lite-32k-250115", |
|||
"messages": fullMessages, |
|||
"temperature": 0.7, |
|||
"max_tokens": 2000, |
|||
"stream": false |
|||
] |
|||
|
|||
guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { |
|||
throw VolcanoAIError.invalidURL |
|||
} |
|||
|
|||
var request = URLRequest(url: url) |
|||
request.httpMethod = "POST" |
|||
request.addValue("application/json", forHTTPHeaderField: "Content-Type") |
|||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
|||
|
|||
do { |
|||
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) |
|||
} catch { |
|||
throw VolcanoAIError.invalidRequestBody |
|||
} |
|||
|
|||
let semaphore = DispatchSemaphore(value: 0) |
|||
var responseData: Data? |
|||
var responseError: Error? |
|||
|
|||
let task = session.dataTask(with: request) { data, response, error in |
|||
if let error = error { |
|||
responseError = VolcanoAIError.networkError(error.localizedDescription) |
|||
semaphore.signal() |
|||
return |
|||
} |
|||
|
|||
guard let httpResponse = response as? HTTPURLResponse else { |
|||
responseError = VolcanoAIError.invalidResponse |
|||
semaphore.signal() |
|||
return |
|||
} |
|||
|
|||
if !(200...299).contains(httpResponse.statusCode) { |
|||
var errorMessage = "Unknown error occurred" |
|||
if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
|||
let error = json["error"] as? [String: Any], |
|||
let message = error["message"] as? String { |
|||
errorMessage = message |
|||
} |
|||
responseError = VolcanoAIError.apiError(errorMessage) |
|||
semaphore.signal() |
|||
return |
|||
} |
|||
|
|||
responseData = data |
|||
semaphore.signal() |
|||
} |
|||
|
|||
task.resume() |
|||
_ = semaphore.wait(timeout: .distantFuture) |
|||
|
|||
if let error = responseError { |
|||
throw error |
|||
} |
|||
|
|||
guard let data = responseData else { |
|||
throw VolcanoAIError.emptyResponse |
|||
} |
|||
|
|||
do { |
|||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], |
|||
let choices = json["choices"] as? [[String: Any]], |
|||
let firstChoice = choices.first, |
|||
let message = firstChoice["message"] as? [String: Any], |
|||
let content = message["content"] as? String else { |
|||
throw VolcanoAIError.invalidResponseFormat |
|||
} |
|||
|
|||
return content |
|||
} catch { |
|||
throw VolcanoAIError.invalidResponseFormat |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送消息(流式输出) |
|||
* |
|||
* @param messages 消息列表 |
|||
* @param systemPrompt 系统提示词 |
|||
* @param streamCallback 回调函数,用于接收流式输出的结果 |
|||
*/ |
|||
func sendMessageStream(messages: [[String: Any]], systemPrompt: String, streamCallback: StreamCallback) { |
|||
// 检查是否已初始化 |
|||
if !isInitialized || apiKey.isEmpty { |
|||
streamCallback.onError(VolcanoAIError.serviceNotInitialized) |
|||
return |
|||
} |
|||
|
|||
var fullMessages: [[String: Any]] = [ |
|||
["role": "system", "content": systemPrompt] |
|||
] |
|||
|
|||
fullMessages.append(contentsOf: messages) |
|||
|
|||
let requestBody: [String: Any] = [ |
|||
"model": "doubao-1-5-lite-32k-250115", |
|||
"messages": fullMessages, |
|||
"temperature": 0.7, |
|||
"max_tokens": 2000, |
|||
"stream": true |
|||
] |
|||
|
|||
guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { |
|||
streamCallback.onError(VolcanoAIError.invalidURL) |
|||
return |
|||
} |
|||
|
|||
var request = URLRequest(url: url) |
|||
request.httpMethod = "POST" |
|||
request.addValue("application/json", forHTTPHeaderField: "Content-Type") |
|||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
|||
request.addValue("text/event-stream", forHTTPHeaderField: "Accept") |
|||
|
|||
do { |
|||
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) |
|||
} catch { |
|||
streamCallback.onError(VolcanoAIError.invalidRequestBody) |
|||
return |
|||
} |
|||
|
|||
let task = session.dataTask(with: request) { data, response, error in |
|||
if let error = error { |
|||
streamCallback.onError(VolcanoAIError.networkError(error.localizedDescription)) |
|||
return |
|||
} |
|||
|
|||
guard let httpResponse = response as? HTTPURLResponse else { |
|||
streamCallback.onError(VolcanoAIError.invalidResponse) |
|||
return |
|||
} |
|||
|
|||
if !(200...299).contains(httpResponse.statusCode) { |
|||
var errorMessage = "Unknown error occurred" |
|||
if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
|||
let error = json["error"] as? [String: Any], |
|||
let message = error["message"] as? String { |
|||
errorMessage = message |
|||
} |
|||
streamCallback.onError(VolcanoAIError.apiError(errorMessage)) |
|||
return |
|||
} |
|||
|
|||
guard let data = data else { |
|||
streamCallback.onError(VolcanoAIError.emptyResponse) |
|||
return |
|||
} |
|||
|
|||
// 处理SSE流数据 |
|||
let responseString = String(data: data, encoding: .utf8) ?? "" |
|||
let lines = responseString.components(separatedBy: "\n") |
|||
|
|||
for line in lines { |
|||
if line.isEmpty { continue } |
|||
|
|||
if line.hasPrefix("data: ") { |
|||
let data = String(line.dropFirst(6)) |
|||
if data == "[DONE]" { |
|||
streamCallback.onComplete() |
|||
break |
|||
} |
|||
|
|||
do { |
|||
if let jsonData = data.data(using: .utf8), |
|||
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], |
|||
let choices = json["choices"] as? [[String: Any]], |
|||
let firstChoice = choices.first, |
|||
let delta = firstChoice["delta"] as? [String: Any], |
|||
let content = delta["content"] as? String { |
|||
streamCallback.onToken(content) |
|||
} |
|||
} catch { |
|||
// 忽略无效的JSON数据 |
|||
continue |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
task.resume() |
|||
} |
|||
|
|||
/** |
|||
* 同步方式发送消息(流式输出) |
|||
* |
|||
* 注意:此方法会阻塞当前线程,请在后台线程中调用 |
|||
* |
|||
* @param messages 消息列表 |
|||
* @param systemPrompt 系统提示词 |
|||
* @return 返回完整的AI回复 |
|||
* @throws VolcanoAIError 如果API调用失败 |
|||
*/ |
|||
func sendMessageStreamSync(messages: [[String: Any]], systemPrompt: String) throws -> String { |
|||
var result = "" |
|||
let semaphore = DispatchSemaphore(value: 0) |
|||
var responseError: Error? |
|||
|
|||
sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: StreamCallback( |
|||
onToken: { token in |
|||
result.append(token) |
|||
}, |
|||
onComplete: { |
|||
semaphore.signal() |
|||
}, |
|||
onError: { error in |
|||
responseError = error |
|||
semaphore.signal() |
|||
} |
|||
)) |
|||
|
|||
// 等待流式输出完成或出错 |
|||
_ = semaphore.wait(timeout: .now() + 60) |
|||
|
|||
if let error = responseError { |
|||
throw error |
|||
} |
|||
|
|||
return result |
|||
} |
|||
|
|||
/** |
|||
* 创建用户消息 |
|||
*/ |
|||
func createUserMessage(content: String) -> [String: Any] { |
|||
return ["role": "user", "content": content] |
|||
} |
|||
|
|||
/** |
|||
* 创建系统消息 |
|||
*/ |
|||
func createSystemMessage(content: String) -> [String: Any] { |
|||
return ["role": "system", "content": content] |
|||
} |
|||
|
|||
/** |
|||
* 创建助手消息 |
|||
*/ |
|||
func createAssistantMessage(content: String) -> [String: Any] { |
|||
return ["role": "assistant", "content": content] |
|||
} |
|||
|
|||
/** |
|||
* 流式输出回调类 |
|||
*/ |
|||
class StreamCallback { |
|||
let onToken: (String) -> Void |
|||
let onComplete: () -> Void |
|||
let onError: (Error) -> Void |
|||
|
|||
init(onToken: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void) { |
|||
self.onToken = onToken |
|||
self.onComplete = onComplete |
|||
self.onError = onError |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 火山AI错误枚举 |
|||
*/ |
|||
enum VolcanoAIError: Error { |
|||
case serviceNotInitialized |
|||
case invalidURL |
|||
case invalidRequestBody |
|||
case networkError(String) |
|||
case invalidResponse |
|||
case emptyResponse |
|||
case invalidResponseFormat |
|||
case apiError(String) |
|||
|
|||
var localizedDescription: String { |
|||
switch self { |
|||
case .serviceNotInitialized: |
|||
return "火山AI服务未初始化或API key为空,请先调用initialize方法" |
|||
case .invalidURL: |
|||
return "无效的URL" |
|||
case .invalidRequestBody: |
|||
return "无效的请求体" |
|||
case .networkError(let message): |
|||
return "网络错误: \(message)" |
|||
case .invalidResponse: |
|||
return "无效的响应" |
|||
case .emptyResponse: |
|||
return "空响应" |
|||
case .invalidResponseFormat: |
|||
return "无效的响应格式" |
|||
case .apiError(let message): |
|||
return "API错误: \(message)" |
|||
} |
|||
} |
|||
} |
|||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,41 @@ |
|||
import 'package:amap_service/amap_service.dart'; |
|||
import 'package:deep_voice/core/utils/logger.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
//音乐播放管理 |
|||
class NavigationManager extends GetxService { |
|||
static NavigationManager get to => Get.find<NavigationManager>(); |
|||
|
|||
// 音乐服务实例 |
|||
final AmapService _amapervice = AmapService(); |
|||
|
|||
/// 初始化 |
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
Logger.info('导航服务初始化'); |
|||
} |
|||
|
|||
/// 释放资源 |
|||
@override |
|||
void onClose() { |
|||
super.onClose(); |
|||
} |
|||
|
|||
Stream<AmapServiceEvent> events() { |
|||
return _amapervice.events; |
|||
} |
|||
|
|||
//导航 |
|||
Future<void> start(String org, String dis) async { |
|||
// await _amapervice.navigation(org, dis); |
|||
} |
|||
//导航 |
|||
Future<void> open( |
|||
String start, |
|||
String end, |
|||
String ntype, |
|||
) async { |
|||
await _amapervice.open(start, end, ntype); |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,261 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/agent_controller.dart'; |
|||
|
|||
/// 构建音乐项目 |
|||
Widget buildMusicItem(BuildContext context, String id, String name, String url, |
|||
String singer, String image) { |
|||
final controller = Get.find<AgentController>(); // 获取控制器 |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
Map<String, String> song = { |
|||
'id': id, |
|||
'url': url, |
|||
'title': name, |
|||
'artist': singer, |
|||
'coverUrl': image, |
|||
}; |
|||
controller.musicStatus(); //刷新状态 |
|||
return Container( |
|||
padding: const EdgeInsets.all(14), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Color(0xFF1E1E1E) : Colors.white, |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.03), |
|||
blurRadius: 3, |
|||
spreadRadius: 1, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Container( |
|||
width: 50, |
|||
height: 50, |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(8), |
|||
image: image.isNotEmpty |
|||
? DecorationImage( |
|||
image: NetworkImage(image), |
|||
fit: BoxFit.cover, |
|||
) |
|||
: null, |
|||
), |
|||
child: image.isEmpty |
|||
? Icon( |
|||
Icons.music_note, |
|||
size: 18, |
|||
color: Theme.of(context).primaryColor, |
|||
) |
|||
: null, |
|||
), |
|||
const SizedBox(width: 12), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
name, |
|||
style: TextStyle( |
|||
fontSize: 13, |
|||
fontWeight: FontWeight.w500, |
|||
color: Colors.grey[800], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
SizedBox(height: 2), |
|||
Text( |
|||
singer, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.grey[600], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Obx(() { |
|||
final isThisPlaying = |
|||
controller.isPlaying.value && controller.currentUrl.value == url; |
|||
|
|||
return IconButton( |
|||
onPressed: () { |
|||
if (controller.isPlaying.value) { |
|||
if (controller.currentUrl.value == url) { |
|||
controller.musicPause(); |
|||
} else { |
|||
controller.musicPlay(song); |
|||
} |
|||
} else { |
|||
if (controller.currentUrl.value == url) { |
|||
controller.musicResume(); |
|||
} else { |
|||
controller.musicPlay(song); |
|||
} |
|||
} |
|||
}, |
|||
icon: Icon( |
|||
isThisPlaying ? Icons.pause_circle : Icons.play_circle, |
|||
color: Theme.of(context).primaryColor, |
|||
), |
|||
padding: EdgeInsets.zero, |
|||
constraints: BoxConstraints(), |
|||
iconSize: 36, |
|||
); |
|||
}), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
/// 构建音乐内容卡片 |
|||
Widget buildMusicContent( |
|||
BuildContext context, List<Map<String, dynamic>> playlist) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'以下是一些推荐的音乐:', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
), |
|||
const SizedBox(height: 12), |
|||
// Container( |
|||
// padding: const EdgeInsets.all(12), |
|||
// decoration: BoxDecoration( |
|||
// color: Colors.white, |
|||
// borderRadius: BorderRadius.circular(12), |
|||
// boxShadow: [ |
|||
// BoxShadow( |
|||
// color: Colors.black.withOpacity(0.03), |
|||
// blurRadius: 3, |
|||
// spreadRadius: 1, |
|||
// offset: const Offset(0, 1), |
|||
// ), |
|||
// ], |
|||
// ), |
|||
// child: |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.music_note, |
|||
size: 16, |
|||
color: Theme.of(context).primaryColor.withOpacity(0.7), |
|||
), |
|||
const SizedBox(width: 6), |
|||
Text( |
|||
'播放列表'.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), |
|||
|
|||
// 动态生成音乐条目 |
|||
...playlist.asMap().entries.take(4).map((entry) { |
|||
final index = entry.key; |
|||
final item = entry.value; |
|||
final id = item["id"]?.toString() ?? ""; |
|||
final name = item["name"] ?? "未知歌曲"; |
|||
final url = item["url"] ?? ""; |
|||
final singer = item["sgener"] ?? "未知歌手"; |
|||
final image = item["image"] ?? ""; |
|||
return Column( |
|||
children: [ |
|||
buildMusicItem(context, id, name, url, singer, image), |
|||
if (index != playlist.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: () {}, |
|||
style: TextButton.styleFrom( |
|||
minimumSize: Size.zero, |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
|||
tapTargetSize: MaterialTapTargetSize.shrinkWrap, |
|||
), |
|||
child: Text( |
|||
'查看更多推荐', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Theme.of(context).primaryColor, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
//), |
|||
//const SizedBox(height: 10), |
|||
// Row( |
|||
// mainAxisSize: MainAxisSize.min, |
|||
// children: [ |
|||
// Expanded( |
|||
// child: OutlinedButton.icon( |
|||
// onPressed: () {}, |
|||
// icon: Icon(Icons.play_arrow, size: 16), |
|||
// label: Text('播放全部'), |
|||
// style: OutlinedButton.styleFrom( |
|||
// foregroundColor: Theme.of(context).primaryColor, |
|||
// side: BorderSide( |
|||
// color: Theme.of(context).primaryColor.withOpacity(0.5)), |
|||
// padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
// shape: RoundedRectangleBorder( |
|||
// borderRadius: BorderRadius.circular(16), |
|||
// ), |
|||
// textStyle: TextStyle(fontSize: 12), |
|||
// ), |
|||
// ), |
|||
// ), |
|||
// SizedBox(width: 8), |
|||
// Expanded( |
|||
// child: OutlinedButton.icon( |
|||
// onPressed: () {}, |
|||
// icon: Icon(Icons.shuffle, size: 16), |
|||
// label: Text('随机播放'), |
|||
// style: OutlinedButton.styleFrom( |
|||
// foregroundColor: Colors.grey[700], |
|||
// side: BorderSide(color: Colors.grey[400]!), |
|||
// padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
// shape: RoundedRectangleBorder( |
|||
// borderRadius: BorderRadius.circular(16), |
|||
// ), |
|||
// textStyle: TextStyle(fontSize: 12), |
|||
// ), |
|||
// ), |
|||
// ), |
|||
// ], |
|||
// ), |
|||
], |
|||
); |
|||
} |
|||
@ -0,0 +1,157 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/agent_controller.dart'; |
|||
|
|||
String formatDistance(String distanceStr) { |
|||
final distance = double.tryParse(distanceStr); |
|||
if (distance == null) return distanceStr; // 如果无法解析,则返回原始字符串 |
|||
|
|||
if (distance >= 1000) { |
|||
final km = distance / 1000; |
|||
return '${km.toStringAsFixed(1)} 公里'; |
|||
} else { |
|||
return '${distance.toStringAsFixed(0)} 米'; |
|||
} |
|||
} |
|||
|
|||
String formatDuration(String secondsStr) { |
|||
int totalSeconds = int.tryParse(secondsStr) ?? 0; |
|||
Duration duration = Duration(seconds: totalSeconds); |
|||
|
|||
int hours = duration.inHours; |
|||
int minutes = duration.inMinutes.remainder(60); |
|||
int seconds = duration.inSeconds.remainder(60); |
|||
|
|||
List<String> parts = []; |
|||
if (hours > 0) parts.add('$hours小时'); |
|||
if (minutes > 0) parts.add('$minutes分钟'); |
|||
if (seconds > 0 || parts.isEmpty) parts.add('$seconds秒'); |
|||
|
|||
return parts.join(''); |
|||
} |
|||
|
|||
//导航卡片 |
|||
Widget navigationCard( |
|||
BuildContext context, { |
|||
required String start, |
|||
required String end, |
|||
required String destination, |
|||
required List<Map<String, dynamic>> modes, |
|||
Map<String, dynamic>? navigation, |
|||
double height = 300, |
|||
}) { |
|||
int currentIndex = 0; |
|||
final controller = Get.find<AgentController>(); // 获取控制器 |
|||
return Container( |
|||
height: height, |
|||
decoration: BoxDecoration( |
|||
borderRadius: BorderRadius.circular(16), |
|||
color: Colors.white, |
|||
boxShadow: const [ |
|||
BoxShadow( |
|||
blurRadius: 8, |
|||
color: Colors.black12, |
|||
offset: Offset(0, 4), |
|||
), |
|||
], |
|||
), |
|||
child: StatefulBuilder( |
|||
builder: (context, setInnerState) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
// 路线图片 |
|||
ClipRRect( |
|||
borderRadius: |
|||
const BorderRadius.vertical(top: Radius.circular(16)), |
|||
child: AnimatedSwitcher( |
|||
duration: const Duration(milliseconds: 300), |
|||
child: Image.network( |
|||
modes[currentIndex]["image"] as String, |
|||
key: ValueKey<String>(modes[currentIndex]["ntype"] as String), |
|||
height: 160, |
|||
width: double.infinity, |
|||
fit: BoxFit.cover, |
|||
), |
|||
), |
|||
), |
|||
|
|||
// 导航方式切换按钮 |
|||
Padding( |
|||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|||
children: List.generate(modes.length, (index) { |
|||
final isSelected = index == currentIndex; |
|||
return GestureDetector( |
|||
onTap: modes[index]['enable'] as bool |
|||
? () { |
|||
setInnerState(() => currentIndex = index); |
|||
} |
|||
: null, |
|||
child: SizedBox( |
|||
width: 50, |
|||
child: Text( |
|||
(modes[index]["ntype"] as String).tr, |
|||
textAlign: TextAlign.center, |
|||
style: TextStyle( |
|||
color: modes[index]['enable'] |
|||
? isSelected |
|||
? Colors.blue |
|||
: Colors.black87 |
|||
: Colors.grey, |
|||
fontSize: 12, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
); |
|||
}), |
|||
), |
|||
), |
|||
|
|||
// 距离和耗时 |
|||
Padding( |
|||
padding: const EdgeInsets.symmetric(horizontal: 8), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text('$destination', |
|||
style: const TextStyle( |
|||
fontSize: 12, color: Colors.black)), |
|||
Text( |
|||
'${formatDistance(modes[currentIndex]["distance"])}-约${formatDuration(modes[currentIndex]["duration"])}', |
|||
style: const TextStyle( |
|||
fontSize: 15, color: Colors.blue)), |
|||
if (navigation != null) |
|||
Text( |
|||
"导航中... ${navigation["distanceText"]}进入${navigation["nextRoad"]}", |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: |
|||
const Color.fromARGB(255, 164, 164, 164))) |
|||
], |
|||
), |
|||
CircleAvatar( |
|||
backgroundColor: Colors.blue, |
|||
radius: 18, |
|||
child: IconButton( |
|||
icon: const Icon(Icons.turn_left, |
|||
color: Colors.white, size: 20), |
|||
onPressed: () { |
|||
controller.openNavigation(start, end, ""); |
|||
}, |
|||
), |
|||
) |
|||
], |
|||
), |
|||
), |
|||
], |
|||
); |
|||
}, |
|||
), |
|||
); |
|||
} |
|||
@ -0,0 +1,211 @@ |
|||
import 'package:flutter/material.dart'; |
|||
|
|||
/// 构建旅游内容卡片 |
|||
Widget buildTravelContent( |
|||
BuildContext context, List<Map<String, dynamic>> travellist) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
const Text( |
|||
'以下是一些推搜索结果:', |
|||
style: TextStyle( |
|||
color: Colors.black87, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
), |
|||
const SizedBox(height: 12), |
|||
Container( |
|||
padding: const EdgeInsets.all(12), |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.03), |
|||
blurRadius: 3, |
|||
spreadRadius: 1, |
|||
offset: const Offset(0, 1), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
// Row( |
|||
// children: [ |
|||
// Icon( |
|||
// Icons.place, |
|||
// size: 16, |
|||
// color: Color(0xFFE11D48), |
|||
// ), |
|||
// const SizedBox(width: 6), |
|||
// Text( |
|||
// '热门景点 · 东京', |
|||
// style: TextStyle( |
|||
// fontSize: 13, |
|||
// fontWeight: FontWeight.w600, |
|||
// color: Colors.grey[700], |
|||
// ), |
|||
// ), |
|||
// ], |
|||
// ), |
|||
// const SizedBox(height: 10), |
|||
|
|||
// 动态生成音乐条目 |
|||
...travellist.asMap().entries.map((entry) { |
|||
final index = entry.key; |
|||
final item = entry.value; |
|||
final name = item["name"] ?? "未知歌曲"; //名字 |
|||
final addr = item["addr"] ?? ""; //地址 |
|||
final tag = item["tag"] ?? "未知歌手"; //标签 |
|||
final rating = item["rating"] ?? ""; //评分 |
|||
final distance = item["distance"] ?? ""; //距离 |
|||
final images = item["images"] ?? []; // 图片列表 |
|||
return Column( |
|||
children: [ |
|||
buildLocationItem( |
|||
context, |
|||
name, |
|||
addr, |
|||
), |
|||
if (index != travellist.length - 1) |
|||
Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
|||
], |
|||
); |
|||
}).toList(), |
|||
// // 景点列表 |
|||
// _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), |
|||
// Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
|||
// _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), |
|||
// Divider(height: 1, thickness: 1, color: Colors.grey[100]), |
|||
// _buildLocationItem('浅草寺', '东京最古老的寺庙', context), |
|||
|
|||
const SizedBox(height: 8), |
|||
Center( |
|||
child: TextButton( |
|||
onPressed: () {}, |
|||
style: TextButton.styleFrom( |
|||
minimumSize: Size.zero, |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), |
|||
tapTargetSize: MaterialTapTargetSize.shrinkWrap, |
|||
), |
|||
child: Text( |
|||
'查看更多景点', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Theme.of(context).primaryColor, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
const SizedBox(height: 10), |
|||
Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.map, size: 16), |
|||
label: Text('查看地图'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Theme.of(context).primaryColor, |
|||
side: BorderSide( |
|||
color: Theme.of(context).primaryColor.withOpacity(0.5)), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8), |
|||
Expanded( |
|||
child: OutlinedButton.icon( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.bookmark_border, size: 16), |
|||
label: Text('保存到收藏'), |
|||
style: OutlinedButton.styleFrom( |
|||
foregroundColor: Colors.grey[700], |
|||
side: BorderSide(color: Colors.grey[400]!), |
|||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(16), |
|||
), |
|||
textStyle: TextStyle(fontSize: 12), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建地点项目 |
|||
Widget buildLocationItem( |
|||
BuildContext context, String name, String description) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Padding( |
|||
padding: const EdgeInsets.symmetric(vertical: 8), |
|||
child: Row( |
|||
children: [ |
|||
Container( |
|||
width: 38, |
|||
height: 38, |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Color(0xFF1A365D) : Colors.blue[50], |
|||
borderRadius: BorderRadius.circular(8), |
|||
), |
|||
child: Icon( |
|||
Icons.place, |
|||
size: 18, |
|||
color: Color(0xFFE11D48), |
|||
), |
|||
), |
|||
const SizedBox(width: 12), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
name, |
|||
style: TextStyle( |
|||
fontSize: 13, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode |
|||
? Colors.white.withOpacity(0.9) |
|||
: Colors.grey[800], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
SizedBox(height: 2), |
|||
Text( |
|||
description, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|||
), |
|||
overflow: TextOverflow.ellipsis, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
IconButton( |
|||
onPressed: () {}, |
|||
icon: Icon(Icons.arrow_forward, color: Colors.grey[400]), |
|||
padding: EdgeInsets.zero, |
|||
constraints: BoxConstraints(), |
|||
iconSize: 20, |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
@ -0,0 +1,313 @@ |
|||
import 'package:common_utils/common_utils.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
|
|||
/// 构建天气内容卡片 |
|||
Widget buildWeatherContent(BuildContext context, String city, |
|||
Map<String, dynamic> now, List<Map<String, dynamic>> days) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'查询天气情况:', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87, |
|||
fontSize: 14, |
|||
height: 1.4, |
|||
fontWeight: FontWeight.w400, |
|||
), |
|||
), |
|||
const SizedBox(height: 12), |
|||
Container( |
|||
padding: const EdgeInsets.all(16), |
|||
decoration: BoxDecoration( |
|||
gradient: isDarkMode |
|||
? LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [Color(0xFF1E40AF), Color(0xFF1E3A8A)], |
|||
) |
|||
: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], |
|||
), |
|||
borderRadius: BorderRadius.circular(12), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1), |
|||
blurRadius: 8, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
"${city} ${now["date"]}", |
|||
style: const TextStyle( |
|||
fontSize: 16, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
SizedBox(height: 4), |
|||
Text( |
|||
now["weatherCondition"], |
|||
style: TextStyle( |
|||
fontSize: 14, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
SizedBox(height: 8), |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
"${now["temperature"]}°", |
|||
style: const TextStyle( |
|||
fontSize: 36, |
|||
fontWeight: FontWeight.bold, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
SizedBox(width: 8), |
|||
Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'最高: ${now["maxTemperature"]}°', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
Text( |
|||
'最低: ${now["minTemperature"]}°', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
const Icon( |
|||
Icons.wb_sunny, |
|||
size: 48, |
|||
color: Colors.white, |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 16), |
|||
Container( |
|||
height: 1, |
|||
color: Colors.white.withOpacity(isDarkMode ? 0.15 : 0.2), |
|||
), |
|||
SizedBox(height: 16), |
|||
const Text( |
|||
'未来预报', |
|||
style: TextStyle( |
|||
fontSize: 14, |
|||
fontWeight: FontWeight.w600, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
SizedBox(height: 8), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: List.generate( |
|||
days.length, |
|||
(index) { |
|||
String dateStr = days[index]['date']; |
|||
DateTime? date = DateUtil.getDateTime(dateStr); |
|||
IconData weatherIcon = |
|||
getWeatherIcon(days[index]['weatherCondition']); |
|||
String weekday = DateUtil.getWeekday(date, |
|||
languageCode: "zh", short: true); |
|||
String temp = "${days[index]['temperature']}°"; |
|||
return _buildForecastDay( |
|||
weekday, |
|||
weatherIcon, |
|||
temp, |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
SizedBox(height: 16), |
|||
Container( |
|||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode |
|||
? Colors.white.withOpacity(0.15) |
|||
: Colors.white.withOpacity(0.2), |
|||
borderRadius: BorderRadius.circular(8), |
|||
), |
|||
child: const Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.info_outline, |
|||
size: 16, |
|||
color: Colors.white, |
|||
), |
|||
SizedBox(width: 8), |
|||
Flexible( |
|||
child: Text( |
|||
'今日紫外线指数较高,外出请做好防晒', |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 构建单日天气预报 |
|||
Widget _buildForecastDay(String day, IconData icon, String temp) { |
|||
return Column( |
|||
children: [ |
|||
Text( |
|||
day, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
color: Colors.white.withOpacity(0.9), |
|||
), |
|||
), |
|||
SizedBox(height: 4), |
|||
Icon( |
|||
icon, |
|||
size: 20, |
|||
color: Colors.white, |
|||
), |
|||
SizedBox(height: 4), |
|||
Text( |
|||
temp, |
|||
style: TextStyle( |
|||
fontSize: 12, |
|||
fontWeight: FontWeight.w500, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
/// 获取天气图标 |
|||
IconData getWeatherIcon(String description) { |
|||
description = description.trim(); |
|||
|
|||
if (["晴", "少云", "晴间多云"].contains(description)) { |
|||
return Icons.wb_sunny; |
|||
} |
|||
|
|||
if (["多云", "阴"].contains(description)) { |
|||
return Icons.cloud; |
|||
} |
|||
|
|||
if (description.contains("雾") || |
|||
["雾", "浓雾", "强浓雾", "轻雾", "大雾", "特强浓雾", "霾", "中度霾", "重度霾", "严重霾"] |
|||
.contains(description)) { |
|||
return Icons.blur_on; |
|||
} |
|||
|
|||
if (description.contains("风") || |
|||
[ |
|||
"有风", |
|||
"平静", |
|||
"微风", |
|||
"和风", |
|||
"清风", |
|||
"强风/劲风", |
|||
"疾风", |
|||
"大风", |
|||
"烈风", |
|||
"风暴", |
|||
"狂爆风", |
|||
"飓风", |
|||
"热带风暴" |
|||
].contains(description)) { |
|||
return Icons.air; |
|||
} |
|||
|
|||
if (description.contains("雨") || |
|||
[ |
|||
"阵雨", |
|||
"雷阵雨", |
|||
"雷阵雨并伴有冰雹", |
|||
"小雨", |
|||
"中雨", |
|||
"大雨", |
|||
"暴雨", |
|||
"大暴雨", |
|||
"特大暴雨", |
|||
"强阵雨", |
|||
"强雷阵雨", |
|||
"极端降雨", |
|||
"毛毛雨/细雨", |
|||
"雨", |
|||
"小雨-中雨", |
|||
"中雨-大雨", |
|||
"大雨-暴雨", |
|||
"暴雨-大暴雨", |
|||
"大暴雨-特大暴雨" |
|||
].contains(description)) { |
|||
return Icons.grain; // 或使用 community icon 包:weather_icons 中的 rain |
|||
} |
|||
|
|||
if (description.contains("雪") || |
|||
[ |
|||
"雨雪天气", |
|||
"雨夹雪", |
|||
"阵雨夹雪", |
|||
"冻雨", |
|||
"雪", |
|||
"阵雪", |
|||
"小雪", |
|||
"中雪", |
|||
"大雪", |
|||
"暴雪", |
|||
"小雪-中雪", |
|||
"中雪-大雪", |
|||
"大雪-暴雪" |
|||
].contains(description)) { |
|||
return Icons.ac_unit; |
|||
} |
|||
|
|||
if (["浮尘", "扬沙", "沙尘暴", "强沙尘暴"].contains(description)) { |
|||
return Icons.filter_drama; |
|||
} |
|||
|
|||
if (["龙卷风"].contains(description)) { |
|||
return Icons.waves; |
|||
} |
|||
|
|||
if (["热"].contains(description)) { |
|||
return Icons.local_fire_department; |
|||
} |
|||
|
|||
if (["冷"].contains(description)) { |
|||
return Icons.ac_unit; |
|||
} |
|||
|
|||
// 默认或未知天气 |
|||
return Icons.help_outline; |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
|
|||
class JieliTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<JieliTestController>( |
|||
() => JieliTestController(), |
|||
); |
|||
} |
|||
} |
|||
@ -1,323 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
import 'package:flutter/material.dart' hide ConnectionState; |
|||
import 'package:get/get.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
/// 杰理测试控制器 |
|||
class JieliTestController extends GetxController { |
|||
// Jieli插件实例 |
|||
late Jieli jieli; |
|||
|
|||
// 状态变量 |
|||
var isScanning = false.obs; |
|||
var deviceInfo = '未获取'.obs; |
|||
var connectStatus = '未连接'.obs; |
|||
var currentDevice = Rx<BleDevice?>(null); |
|||
var scannedDevices = RxList<BleDevice>([]); |
|||
var wakeupReceived = false.obs; |
|||
var audioDataSize = 0.obs; |
|||
var receivedFrames = 0.obs; |
|||
var audioDataReceiving = false.obs; |
|||
var volumeCommandResult = ''.obs; |
|||
|
|||
// 事件订阅 |
|||
StreamSubscription? _scanSubscription; |
|||
StreamSubscription? _connectionSubscription; |
|||
StreamSubscription? _wakeupSubscription; |
|||
StreamSubscription? _audioSubscription; |
|||
StreamSubscription? _commandResultSubscription; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initializeJieli(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_cancelAllSubscriptions(); |
|||
_disposeJieli(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 取消所有订阅 |
|||
void _cancelAllSubscriptions() { |
|||
_scanSubscription?.cancel(); |
|||
_connectionSubscription?.cancel(); |
|||
_wakeupSubscription?.cancel(); |
|||
_audioSubscription?.cancel(); |
|||
_commandResultSubscription?.cancel(); |
|||
} |
|||
|
|||
/// 初始化杰理SDK |
|||
void _initializeJieli() { |
|||
try { |
|||
jieli = Jieli(); |
|||
_setupEventListeners(); |
|||
} catch (e) { |
|||
// 初始化错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListeners() { |
|||
// 监听扫描结果 |
|||
_scanSubscription = jieli.scanResults.listen((devices) { |
|||
scannedDevices.assignAll(devices); |
|||
}); |
|||
|
|||
// 监听连接状态 |
|||
_connectionSubscription = jieli.connectionState.listen((state) { |
|||
switch (state) { |
|||
case ConnectionState.connected: |
|||
connectStatus.value = '已连接'; |
|||
break; |
|||
case ConnectionState.disconnected: |
|||
connectStatus.value = '已断开'; |
|||
currentDevice.value = null; |
|||
break; |
|||
case ConnectionState.connecting: |
|||
connectStatus.value = '连接中...'; |
|||
break; |
|||
case ConnectionState.disconnecting: |
|||
connectStatus.value = '断开中...'; |
|||
break; |
|||
case ConnectionState.error: |
|||
connectStatus.value = '连接错误'; |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 监听唤醒信号 |
|||
_wakeupSubscription = jieli.wakeupStream.listen((wakeup) { |
|||
wakeupReceived.value = true; |
|||
Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); |
|||
}); |
|||
|
|||
// 监听音频数据 |
|||
_audioSubscription = jieli.audioDataStream.listen((data) { |
|||
// 更新音频数据统计 |
|||
audioDataSize.value += data.length; |
|||
receivedFrames.value++; |
|||
audioDataReceiving.value = true; |
|||
}); |
|||
|
|||
// 监听命令结果 |
|||
_commandResultSubscription = jieli.commandResultStream.listen((result) { |
|||
// 处理音量调整命令结果 |
|||
if (result.commandType == Jieli.commandTypeVolume) { |
|||
volumeCommandResult.value = result.success ? '音量调整成功' : '音量调整失败'; |
|||
Get.snackbar( |
|||
result.success ? '音量调整成功' : '音量调整失败', |
|||
result.success ? '设备已更新音量' : '设备未能更新音量', |
|||
snackPosition: SnackPosition.BOTTOM, |
|||
backgroundColor: result.success ? Colors.green.withOpacity(0.7) : Colors.red.withOpacity(0.7), |
|||
colorText: Colors.white, |
|||
duration: Duration(seconds: 2), |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 检查蓝牙状态 |
|||
Future<bool> checkBluetoothStatus() async { |
|||
try { |
|||
final isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
return await jieli.requestBluetoothEnable(); |
|||
} |
|||
return true; |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描设备 |
|||
Future<void> startScan() async { |
|||
if (isScanning.value) return; |
|||
|
|||
final bluetoothReady = await checkBluetoothStatus(); |
|||
if (!bluetoothReady) { |
|||
Get.snackbar('蓝牙未启用', '请启用蓝牙后再试'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isScanning.value = true; |
|||
|
|||
// 清空之前的设备列表 |
|||
scannedDevices.clear(); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
final success = await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
if (!success) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); |
|||
} |
|||
|
|||
// 10秒后自动更新UI状态 |
|||
Future.delayed(Duration(milliseconds: 10000), () { |
|||
isScanning.value = false; |
|||
}); |
|||
|
|||
} catch (e) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 停止扫描 |
|||
Future<void> stopScan() async { |
|||
if (!isScanning.value) return; |
|||
|
|||
try { |
|||
await jieli.stopScan(); |
|||
isScanning.value = false; |
|||
} catch (e) { |
|||
// 停止扫描错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<void> connectToDevice(BleDevice device) async { |
|||
try { |
|||
// 先断开当前连接 |
|||
if (currentDevice.value != null) { |
|||
await disconnectDevice(); |
|||
} |
|||
|
|||
// 更新UI状态 |
|||
connectStatus.value = '连接中...'; |
|||
|
|||
// 重置音频和唤醒相关的状态 |
|||
wakeupReceived.value = false; |
|||
audioDataSize.value = 0; |
|||
receivedFrames.value = 0; |
|||
audioDataReceiving.value = false; |
|||
|
|||
// 连接设备 |
|||
final success = await jieli.connect(device.id); |
|||
|
|||
if (success) { |
|||
// 连接成功,更新当前设备 |
|||
currentDevice.value = device; |
|||
deviceInfo.value = '设备名称: ${device.name}\n设备ID: ${device.id}\nRSSI: ${device.rssi} dBm'; |
|||
} else { |
|||
deviceInfo.value = '连接失败'; |
|||
} |
|||
} catch (e) { |
|||
deviceInfo.value = '连接错误: ${e.toString()}'; |
|||
} |
|||
} |
|||
|
|||
/// 断开设备连接 |
|||
Future<void> disconnectDevice() async { |
|||
if (currentDevice.value == null) return; |
|||
|
|||
try { |
|||
connectStatus.value = '断开中...'; |
|||
|
|||
final success = await jieli.disconnect(); |
|||
|
|||
if (success) { |
|||
deviceInfo.value = '未连接设备'; |
|||
} |
|||
|
|||
} catch (e) { |
|||
// 断开连接错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<void> sendStartRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStartRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送开始录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送开始录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<void> sendStopRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStopRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送停止录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送停止录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<void> increaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.increaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送增加音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<void> decreaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.decreaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送减小音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
void _disposeJieli() { |
|||
try { |
|||
jieli.dispose(); |
|||
} catch (e) { |
|||
// 释放资源错误处理 |
|||
} |
|||
} |
|||
} |
|||
@ -1,568 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
class JieliTestView extends GetView<JieliTestController> { |
|||
const JieliTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0.5, |
|||
title: Text( |
|||
'杰理蓝牙测试', |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
size: 20.sp, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
), |
|||
body: Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|||
children: [ |
|||
_buildStatusCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildActionButtons(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDeviceInfo(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDevicesList(isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'连接状态: ', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
controller.connectStatus.value, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: _getStatusColor(controller.connectStatus.value), |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Obx(() => _buildStatusChip( |
|||
label: '唤醒信号', |
|||
isActive: controller.wakeupReceived.value, |
|||
activeColor: Colors.orange, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
Obx(() => _buildStatusChip( |
|||
label: '音频数据', |
|||
isActive: controller.audioDataReceiving.value, |
|||
activeColor: Colors.green, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.audioDataReceiving.value |
|||
? Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'音频数据统计:', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
'已接收 ${(controller.audioDataSize.value / 1024).toStringAsFixed(2)} KB', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'共 ${controller.receivedFrames.value} 帧', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusChip({ |
|||
required String label, |
|||
required bool isActive, |
|||
required Color activeColor, |
|||
required bool isDarkMode, |
|||
}) { |
|||
return Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
decoration: BoxDecoration( |
|||
color: isActive |
|||
? activeColor |
|||
: (isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: isActive ? Colors.white : Colors.grey, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
SizedBox(width: 6.w), |
|||
Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isActive |
|||
? Colors.white |
|||
: (isDarkMode ? Colors.white70 : Colors.black54), |
|||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildActionButtons(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: Obx(() => ElevatedButton.icon( |
|||
onPressed: controller.isScanning.value |
|||
? controller.stopScan |
|||
: controller.startScan, |
|||
icon: Icon( |
|||
controller.isScanning.value ? Icons.stop : Icons.bluetooth_searching, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
controller.isScanning.value ? '停止扫描' : '扫描设备', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: controller.isScanning.value |
|||
? Colors.red[600] |
|||
: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
)), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStartRecordingCommand, |
|||
icon: Icon( |
|||
Icons.mic, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'开始录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStopRecordingCommand, |
|||
icon: Icon( |
|||
Icons.stop_circle, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'停止录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.increaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_up, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'增加音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.decreaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_down, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'减小音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.orange[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.volumeCommandResult.value.isNotEmpty |
|||
? Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), |
|||
decoration: BoxDecoration( |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[100] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[100] |
|||
: Colors.red[100]), |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Text( |
|||
'音量控制: ${controller.volumeCommandResult.value}', |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[900] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[900] |
|||
: Colors.red[900]), |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildDeviceInfo(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备信息:', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Container( |
|||
padding: EdgeInsets.all(8.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.black : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Obx(() => Text( |
|||
controller.deviceInfo.value, |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
height: 1.4, |
|||
), |
|||
)), |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => controller.currentDevice.value != null |
|||
? ElevatedButton.icon( |
|||
onPressed: controller.disconnectDevice, |
|||
icon: Icon( |
|||
Icons.bluetooth_disabled, |
|||
size: 16.sp, |
|||
), |
|||
label: Text( |
|||
'断开连接', |
|||
style: TextStyle(fontSize: 13.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildDevicesList(bool isDarkMode) { |
|||
return Expanded( |
|||
child: Container( |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'扫描到的设备', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
'${controller.scannedDevices.length}个设备', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
Divider(height: 1, thickness: 1, color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
Expanded( |
|||
child: Obx(() => controller.scannedDevices.isEmpty |
|||
? Center( |
|||
child: controller.isScanning.value |
|||
? Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
CircularProgressIndicator(), |
|||
SizedBox(height: 16.h), |
|||
Text( |
|||
'正在扫描...', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: Text( |
|||
'未扫描到设备', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
) |
|||
: ListView.separated( |
|||
padding: EdgeInsets.symmetric(vertical: 8.h), |
|||
itemCount: controller.scannedDevices.length, |
|||
separatorBuilder: (context, index) => Divider( |
|||
height: 1, |
|||
thickness: 1, |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200], |
|||
), |
|||
itemBuilder: (context, index) { |
|||
final device = controller.scannedDevices[index]; |
|||
return ListTile( |
|||
dense: true, |
|||
title: Text( |
|||
device.name.isEmpty ? '未知设备' : device.name, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
subtitle: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备ID: ${device.id}', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'RSSI: ${device.rssi} dBm', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
trailing: Obx(() => controller.currentDevice.value?.id == device.id |
|||
? Chip( |
|||
label: Text( |
|||
'已连接', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
backgroundColor: Colors.green, |
|||
padding: EdgeInsets.symmetric(horizontal: 8.w), |
|||
) |
|||
: ElevatedButton( |
|||
onPressed: () => controller.connectToDevice(device), |
|||
child: Text( |
|||
'连接', |
|||
style: TextStyle(fontSize: 12.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(6.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
isThreeLine: true, |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Color _getStatusColor(String status) { |
|||
switch (status) { |
|||
case '连接中...': |
|||
return Colors.orange; |
|||
case '已连接': |
|||
return Colors.green; |
|||
case '连接失败': |
|||
return Colors.red; |
|||
case '已断开': |
|||
return Colors.grey; |
|||
default: |
|||
return Colors.grey; |
|||
} |
|||
} |
|||
} |
|||
@ -1,12 +1,107 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:webview_flutter/webview_flutter.dart'; |
|||
|
|||
class MindMapTab extends StatelessWidget { |
|||
import '../controllers/meeting_details_controller.dart'; |
|||
|
|||
class MindMapTab extends StatefulWidget { |
|||
const MindMapTab({super.key}); |
|||
|
|||
@override |
|||
State<MindMapTab> createState() => _MindMapTabState(); |
|||
} |
|||
|
|||
class _MindMapTabState extends State<MindMapTab> |
|||
with AutomaticKeepAliveClientMixin { |
|||
late final WebViewController _controller; |
|||
final _meetingDetailsController = Get.find<MeetingDetailsController>(); |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
String html = ''' |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
|
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>MindMap</title> |
|||
<script> |
|||
window.markmap = { |
|||
autoLoader: { |
|||
toolbar: true, |
|||
}, |
|||
}; |
|||
</script> |
|||
<script src="https://cdn.jsdelivr.net/npm/markmap-autoloader@latest"></script> |
|||
<style> |
|||
html, |
|||
body { |
|||
width: 100%; |
|||
height: 100%; |
|||
margin: 0; |
|||
padding: 0; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.markmap { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.markmap>svg { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
|
|||
.markmap>.mm-toolbar { |
|||
top: 20px !important; |
|||
bottom: initial !important; |
|||
} |
|||
|
|||
.markmap>.mm-toolbar>.mm-toolbar-brand { |
|||
display: none; |
|||
} |
|||
|
|||
.markmap>.mm-toolbar>.mm-toolbar-item:nth-child(2) { |
|||
margin-left: 0; |
|||
} |
|||
</style> |
|||
</head> |
|||
|
|||
<body> |
|||
<div class="markmap"> |
|||
<script type="text/template"> |
|||
--- |
|||
markmap: |
|||
maxWidth: 300 |
|||
colorFreezeLevel: 2 |
|||
initialExpandLevel: 3 |
|||
--- |
|||
${_meetingDetailsController.meetingDetails.value.summary} |
|||
</script> |
|||
</div> |
|||
</body> |
|||
|
|||
</html> |
|||
'''; |
|||
_controller = WebViewController() |
|||
..setJavaScriptMode(JavaScriptMode.unrestricted) |
|||
..setBackgroundColor(Colors.transparent) |
|||
..loadHtmlString(html); |
|||
} |
|||
|
|||
@override |
|||
bool get wantKeepAlive => true; |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return SingleChildScrollView( |
|||
child: Text(''), |
|||
super.build(context); |
|||
return WebViewWidget( |
|||
controller: _controller, |
|||
); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,573 @@ |
|||
import 'package:common_utils/common_utils.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_easyloading/flutter_easyloading.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
|
|||
import '../../../data/services/db/sqflite_api.dart'; |
|||
import '../controllers/meeting_details_controller.dart'; |
|||
|
|||
class SpeakerAllBottomSheet extends StatefulWidget { |
|||
const SpeakerAllBottomSheet({super.key}); |
|||
|
|||
@override |
|||
State<SpeakerAllBottomSheet> createState() => _SpeakerAllBottomSheetState(); |
|||
} |
|||
|
|||
class _SpeakerAllBottomSheetState extends State<SpeakerAllBottomSheet> { |
|||
final _controller = Get.find<MeetingDetailsController>(); |
|||
final GetStorage _storage = GetStorage(); |
|||
|
|||
List _dataList = []; |
|||
|
|||
List _historySpeakerList = []; |
|||
List _speakerList = []; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_controller.playerController.pausePlayer(); |
|||
_controller.speakerAllStarttime.value = -1; |
|||
_controller.speakerAllEndtime.value = -1; |
|||
_getSpeakerList(); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_controller.playerController.pausePlayer(); |
|||
_controller.speakerAllStarttime.value = -1; |
|||
_controller.speakerAllEndtime.value = -1; |
|||
super.dispose(); |
|||
} |
|||
|
|||
void _getSpeakerList() { |
|||
_historySpeakerList = _storage.read("speaker_list") ?? []; |
|||
_speakerList = List.from(_historySpeakerList); |
|||
List textList = _controller.textList; |
|||
List dataList = []; |
|||
for (var item in textList) { |
|||
String speaker = item['speaker']; |
|||
Map? foundElement = dataList.firstWhere( |
|||
(element) => element['speaker'] == speaker, |
|||
orElse: () => null, |
|||
); |
|||
if (foundElement != null) { |
|||
int time = item['endtime'] - item['starttime']; |
|||
foundElement['totaltime'] = foundElement['totaltime'] + time; |
|||
if (!foundElement['isOmit']) { |
|||
if (foundElement['list'].length < 2) { |
|||
foundElement['list'].add(Map.from(item)); |
|||
} else { |
|||
foundElement['isOmit'] = true; |
|||
} |
|||
} |
|||
} else { |
|||
dataList.add({ |
|||
'speaker': speaker, |
|||
'speakerText': TextEditingController(), |
|||
'isShow': false, |
|||
'totaltime': item['endtime'] - item['starttime'], |
|||
'isOmit': false, |
|||
'list': [Map.from(item)], |
|||
}); |
|||
} |
|||
if (speaker.isNotEmpty && |
|||
!RegExp(r'^Speaker \d+$').hasMatch(speaker) && |
|||
!_speakerList.contains(speaker)) { |
|||
_speakerList.add(speaker); |
|||
} |
|||
} |
|||
setState(() { |
|||
_dataList = dataList; |
|||
}); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Padding( |
|||
padding: EdgeInsets.only( |
|||
left: 12.w, |
|||
right: 12.w, |
|||
bottom: MediaQuery.of(context).viewInsets.bottom, |
|||
), |
|||
child: Column( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
padding: EdgeInsets.symmetric(vertical: 10.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'命名发言者', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.black87, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
GestureDetector( |
|||
onTap: () => Get.back(), |
|||
child: Container( |
|||
width: 26.w, |
|||
height: 26.w, |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.circular(26.r), |
|||
), |
|||
child: Icon( |
|||
Icons.close, |
|||
size: 18.w, |
|||
color: Colors.grey[400], |
|||
), |
|||
), |
|||
) |
|||
], |
|||
), |
|||
), |
|||
Divider( |
|||
height: 1, |
|||
color: Colors.grey[300], |
|||
), |
|||
Container( |
|||
constraints: BoxConstraints( |
|||
maxHeight: |
|||
1.sh - 240.w - MediaQuery.of(context).viewInsets.bottom, |
|||
), |
|||
child: ListView.builder( |
|||
itemCount: _dataList.length, |
|||
shrinkWrap: true, |
|||
itemBuilder: (BuildContext context, int index) { |
|||
Map item = _dataList[index]; |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Container( |
|||
margin: EdgeInsets.only(top: 15.w), |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: item['isShow'] && _speakerList.isNotEmpty |
|||
? BorderRadius.vertical( |
|||
top: Radius.circular(12.r), |
|||
) |
|||
: BorderRadius.circular(12.r), |
|||
), |
|||
child: Focus( |
|||
onFocusChange: (bool hasFocus) { |
|||
setState(() { |
|||
item['isShow'] = hasFocus; |
|||
}); |
|||
}, |
|||
child: TextField( |
|||
controller: item['speakerText'], |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.black87, |
|||
letterSpacing: 0, |
|||
height: 1.4, |
|||
), |
|||
decoration: InputDecoration( |
|||
hintText: item['speaker'], |
|||
hintStyle: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.grey[400], |
|||
letterSpacing: 0, |
|||
height: 1.4, |
|||
), |
|||
isDense: true, |
|||
fillColor: Colors.transparent, |
|||
border: InputBorder.none, |
|||
contentPadding: EdgeInsets.symmetric( |
|||
horizontal: 10.w, |
|||
vertical: 12.w, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
if (item['isShow'] && _speakerList.isNotEmpty) |
|||
Container( |
|||
padding: EdgeInsets.only(left: 10.w), |
|||
clipBehavior: Clip.hardEdge, |
|||
decoration: BoxDecoration( |
|||
color: Colors.white, |
|||
borderRadius: BorderRadius.vertical( |
|||
bottom: Radius.circular(12.r), |
|||
), |
|||
border: Border( |
|||
top: BorderSide( |
|||
width: 1, |
|||
color: Colors.grey[300]!, |
|||
), |
|||
), |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Padding( |
|||
padding: EdgeInsets.only(top: 10.w, bottom: 5.w), |
|||
child: Text( |
|||
'最近使用过的名称', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.grey[600]!, |
|||
), |
|||
), |
|||
), |
|||
Container( |
|||
constraints: BoxConstraints( |
|||
maxHeight: 150.h, |
|||
), |
|||
child: ListView.builder( |
|||
itemCount: _speakerList.length, |
|||
shrinkWrap: true, |
|||
itemBuilder: (BuildContext context, int idx) { |
|||
return GestureDetector( |
|||
onTap: () { |
|||
item['speakerText'].text = |
|||
_speakerList[idx]; |
|||
FocusScope.of(context).unfocus(); |
|||
}, |
|||
behavior: HitTestBehavior.translucent, |
|||
child: SizedBox( |
|||
height: 42.w, |
|||
child: Align( |
|||
alignment: Alignment.centerLeft, |
|||
child: Text( |
|||
_speakerList[idx], |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: Colors.black87, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric( |
|||
horizontal: 10.w, |
|||
vertical: 8.w, |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Text( |
|||
'总计', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.grey, |
|||
), |
|||
), |
|||
5.horizontalSpace, |
|||
Text( |
|||
'${item['totaltime'] / 1000}s', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.black87, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
...List.generate( |
|||
item['list'].length, |
|||
(int idx) { |
|||
Map data = item['list'][idx]; |
|||
return Obx(() { |
|||
bool isActive = false; |
|||
if (_controller.speakerAllStarttime.value == |
|||
data['starttime'] && |
|||
_controller.speakerAllEndtime.value == |
|||
data['endtime']) { |
|||
isActive = true; |
|||
} |
|||
return GestureDetector( |
|||
behavior: HitTestBehavior.translucent, |
|||
onTap: () { |
|||
if (isActive) { |
|||
_controller.playerController.pausePlayer(); |
|||
_controller.speakerAllStarttime.value = -1; |
|||
_controller.speakerAllEndtime.value = -1; |
|||
} else { |
|||
_controller.speakerAllStarttime.value = |
|||
data['starttime']; |
|||
_controller.speakerAllEndtime.value = |
|||
data['endtime']; |
|||
_controller.playerController |
|||
.seekTo(data['starttime']); |
|||
_controller.playerController.startPlayer(); |
|||
} |
|||
}, |
|||
child: Padding( |
|||
padding: EdgeInsets.symmetric(horizontal: 10.w), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
isActive |
|||
? Icons.pause_circle |
|||
: Icons.play_circle, |
|||
color: Colors.red[400], |
|||
size: 15.w, |
|||
), |
|||
2.horizontalSpace, |
|||
Text( |
|||
DateUtil.formatDateMs( |
|||
data['starttime'], |
|||
format: 'HH:mm:ss', |
|||
isUtc: true, |
|||
), |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric( |
|||
horizontal: 2.w), |
|||
child: Text( |
|||
'-', |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
), |
|||
Text( |
|||
DateUtil.formatDateMs( |
|||
data['endtime'], |
|||
format: 'HH:mm:ss', |
|||
isUtc: true, |
|||
), |
|||
style: TextStyle( |
|||
color: Colors.grey, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
5.horizontalSpace, |
|||
Text( |
|||
'${(data['endtime'] - data['starttime']) / 1000}s', |
|||
style: TextStyle( |
|||
color: Colors.black54, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
Padding( |
|||
padding: |
|||
EdgeInsets.symmetric(vertical: 8.w), |
|||
child: Text( |
|||
data['content'], |
|||
overflow: TextOverflow.ellipsis, |
|||
maxLines: 2, |
|||
style: TextStyle( |
|||
color: isActive |
|||
? Colors.red |
|||
: Colors.grey[700], |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
}); |
|||
}, |
|||
), |
|||
if (item['isOmit']) |
|||
Padding( |
|||
padding: |
|||
EdgeInsets.only(left: 10.w, top: 5.w, bottom: 8.w), |
|||
child: Row( |
|||
children: List.generate( |
|||
6, |
|||
(int idx) => Container( |
|||
width: 3.w, |
|||
height: 3.w, |
|||
margin: EdgeInsets.only(right: 3.w), |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[700], |
|||
borderRadius: BorderRadius.circular(3.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.only(top: 7.w), |
|||
child: _separator(), |
|||
), |
|||
], |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.symmetric(vertical: 5.w), |
|||
child: Text( |
|||
'总结的发言者将自动更新', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: Colors.grey[600], |
|||
), |
|||
), |
|||
), |
|||
Padding( |
|||
padding: EdgeInsets.only(bottom: 20.w), |
|||
child: Row( |
|||
children: [ |
|||
Expanded( |
|||
child: GestureDetector( |
|||
onTap: () => Get.back(), |
|||
child: Container( |
|||
height: 48.w, |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: Colors.grey[300], |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Text( |
|||
'取消', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.black, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
10.horizontalSpace, |
|||
Expanded( |
|||
child: GestureDetector( |
|||
onTap: _save, |
|||
child: Container( |
|||
height: 48.w, |
|||
alignment: Alignment.center, |
|||
decoration: BoxDecoration( |
|||
color: Colors.black, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Text( |
|||
'确认', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _separator() { |
|||
return LayoutBuilder( |
|||
builder: (BuildContext context, BoxConstraints constraints) { |
|||
final boxWidth = constraints.constrainWidth(); |
|||
const dashWidth = 2.0; |
|||
const dashHeight = 1.0; |
|||
final dashCount = (boxWidth / (2 * dashWidth)).floor(); |
|||
return Flex( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
direction: Axis.horizontal, |
|||
children: List.generate(dashCount, (_) { |
|||
return SizedBox( |
|||
width: dashWidth, |
|||
height: dashHeight, |
|||
child: DecoratedBox( |
|||
decoration: BoxDecoration(color: Colors.grey[300]), |
|||
), |
|||
); |
|||
}), |
|||
); |
|||
}, |
|||
); |
|||
} |
|||
|
|||
void _save() async { |
|||
EasyLoading.show(); |
|||
List textList = _controller.textList; |
|||
List newTextList = []; |
|||
List editSpeakerList = []; |
|||
List personnelList = []; |
|||
String personnel = ''; |
|||
bool isLoopEnd = false; |
|||
for (var element in textList) { |
|||
final dataMap = Map<String, dynamic>.from(element); |
|||
for (var data in _dataList) { |
|||
String speaker = data['speakerText'].text; |
|||
if (speaker.isNotEmpty && speaker != data['speaker']) { |
|||
if (!isLoopEnd) { |
|||
personnelList.add({ |
|||
'original': data['speaker'], |
|||
'speaker': speaker, |
|||
}); |
|||
} |
|||
if (dataMap['speaker'] == data['speaker']) { |
|||
dataMap['speaker'] = speaker; |
|||
editSpeakerList.add({ |
|||
'id': dataMap['id'], |
|||
'speaker': dataMap['speaker'], |
|||
}); |
|||
} |
|||
if (!RegExp(r'^Speaker \d+$').hasMatch(speaker) && |
|||
!_historySpeakerList.contains(speaker)) { |
|||
_historySpeakerList.insert(0, speaker); |
|||
} |
|||
} |
|||
} |
|||
if (!personnel.contains(dataMap['speaker'])) { |
|||
personnel += '[${dataMap['speaker']}] '; |
|||
} |
|||
newTextList.add(dataMap); |
|||
isLoopEnd = true; |
|||
} |
|||
if (editSpeakerList.isNotEmpty) { |
|||
var result = await SqfliteApi.editMeetingSpeaker(editSpeakerList); |
|||
if (result != null) { |
|||
bool isEditSummary = false; |
|||
String summaryText = _controller.meetingDetails.value.summary; |
|||
_controller.textList.value = newTextList; |
|||
for (var element in personnelList) { |
|||
if (summaryText.contains('[${element['original']}]')) { |
|||
summaryText = summaryText.replaceAll( |
|||
'[${element['original']}]', '[${element['speaker']}]'); |
|||
isEditSummary = true; |
|||
} |
|||
} |
|||
if (isEditSummary) { |
|||
SqfliteApi.editMeeting(_controller.id, { |
|||
'summary': summaryText, |
|||
'personnel': personnel, |
|||
}); |
|||
_controller.meetingDetails.value.summary = summaryText; |
|||
} else { |
|||
SqfliteApi.editMeeting(_controller.id, {'personnel': personnel}); |
|||
} |
|||
_controller.meetingDetails.value.personnel = personnel; |
|||
_controller.meetingDetails.refresh(); |
|||
_storage.write("speaker_list", _historySpeakerList); |
|||
Get.back(); |
|||
} |
|||
} else { |
|||
Get.back(); |
|||
} |
|||
EasyLoading.dismiss(); |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/navigation_controller.dart'; |
|||
|
|||
class NavigationBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<NavigationController>(() => NavigationController()); |
|||
} |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
import 'package:get/get.dart'; |
|||
|
|||
class NavigationController extends GetxController { |
|||
static NavigationController get to => Get.find(); //单例访问模式 |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import 'package:deep_voice/modules/navigation/controllers/navigation_controller.dart'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
class NavigationView extends GetView<NavigationController> { |
|||
const NavigationView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Scaffold( |
|||
appBar: AppBar(title: const Text('地图展示')), |
|||
body: Stack( |
|||
children: [], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,324 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
import '../controllers/settings_controller.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'dart:ui'; |
|||
|
|||
class PermissionsView extends GetView<SettingsController> { |
|||
const PermissionsView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0.5, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
size: 20.w, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
title: Text( |
|||
'权限管理'.tr, |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
), |
|||
body: FutureBuilder<Map<Permission, PermissionStatus>>( |
|||
future: controller.checkAllPermissions(), |
|||
builder: (context, snapshot) { |
|||
if (snapshot.connectionState == ConnectionState.waiting) { |
|||
return const Center(child: CircularProgressIndicator()); |
|||
} |
|||
|
|||
if (snapshot.hasError) { |
|||
return Center(child: Text('获取权限状态失败: ${snapshot.error}'.tr)); |
|||
} |
|||
|
|||
final permissionStatuses = snapshot.data ?? {}; |
|||
|
|||
return ListView( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), |
|||
children: [ |
|||
// 通讯权限组 |
|||
_buildPermissionSection('通讯权限', Icons.phone_android, [ |
|||
Permission.phone, |
|||
Permission.sms |
|||
], permissionStatuses, isDarkMode), |
|||
|
|||
SizedBox(height: 20.h), |
|||
|
|||
// 日历权限组 |
|||
_buildPermissionSection('日历权限', Icons.calendar_today, [ |
|||
Permission.calendarFullAccess |
|||
], permissionStatuses, isDarkMode), |
|||
|
|||
SizedBox(height: 20.h), |
|||
|
|||
// 位置权限组 |
|||
_buildPermissionSection('位置权限', Icons.location_on, [ |
|||
Permission.location |
|||
], permissionStatuses, isDarkMode), |
|||
], |
|||
); |
|||
}, |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建权限组区域 |
|||
Widget _buildPermissionSection(String title, IconData icon, List<Permission> permissions, |
|||
Map<Permission, PermissionStatus> statuses, bool isDarkMode) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
// 组标题 |
|||
Padding( |
|||
padding: EdgeInsets.only(left: 4.w, bottom: 8.h), |
|||
child: Text( |
|||
title.tr, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.blue[400] : Colors.blue[600], |
|||
letterSpacing: 1, |
|||
), |
|||
), |
|||
), |
|||
|
|||
// 权限项容器 |
|||
Container( |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: isDarkMode |
|||
? Colors.black.withOpacity(0.2) |
|||
: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: ClipRRect( |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
child: BackdropFilter( |
|||
filter: isDarkMode |
|||
? ImageFilter.blur(sigmaX: 5, sigmaY: 5) |
|||
: ImageFilter.blur(sigmaX: 0, sigmaY: 0), |
|||
child: Column( |
|||
children: _buildPermissionItems(permissions, statuses, isDarkMode), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 构建权限项列表 |
|||
List<Widget> _buildPermissionItems(List<Permission> permissions, |
|||
Map<Permission, PermissionStatus> statuses, bool isDarkMode) { |
|||
final List<Widget> items = []; |
|||
|
|||
// 获取描述文本 |
|||
String getDescription(Permission permission) { |
|||
switch (permission) { |
|||
case Permission.phone: |
|||
return '用于拨打电话功能'; |
|||
case Permission.sms: |
|||
return '用于发送短信功能'; |
|||
case Permission.calendarWriteOnly: |
|||
return '用于创建日历事件和提醒'; |
|||
case Permission.calendarFullAccess: |
|||
return '用于读取和管理日历事件及提醒'; |
|||
case Permission.location: |
|||
return '用于获取当前位置信息'; |
|||
default: |
|||
return '应用功能需要'; |
|||
} |
|||
} |
|||
|
|||
// 获取名称 |
|||
String getName(Permission permission) { |
|||
switch (permission) { |
|||
case Permission.phone: |
|||
return '电话'; |
|||
case Permission.sms: |
|||
return '短信'; |
|||
case Permission.calendarWriteOnly: |
|||
return '日历写入'; |
|||
case Permission.calendarFullAccess: |
|||
return '日历完整访问'; |
|||
case Permission.location: |
|||
return '位置信息'; |
|||
default: |
|||
return permission.toString(); |
|||
} |
|||
} |
|||
|
|||
for (int i = 0; i < permissions.length; i++) { |
|||
final permission = permissions[i]; |
|||
final status = statuses[permission] ?? PermissionStatus.denied; |
|||
|
|||
items.add( |
|||
_buildPermissionItem( |
|||
name: getName(permission), |
|||
description: getDescription(permission), |
|||
status: status, |
|||
permission: permission, |
|||
isDarkMode: isDarkMode, |
|||
), |
|||
); |
|||
|
|||
// 添加分隔线,最后一个不加 |
|||
if (i < permissions.length - 1) { |
|||
items.add(Divider( |
|||
height: 1, |
|||
color: isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[200], |
|||
)); |
|||
} |
|||
} |
|||
|
|||
return items; |
|||
} |
|||
|
|||
// 构建单个权限项 |
|||
Widget _buildPermissionItem({ |
|||
required String name, |
|||
required String description, |
|||
required PermissionStatus status, |
|||
required Permission permission, |
|||
required bool isDarkMode, |
|||
}) { |
|||
return Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
name.tr, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
description.tr, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.grey[600], |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
_buildStatusText(status, isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
Switch( |
|||
value: status.isGranted, |
|||
onChanged: (value) { |
|||
if (value) { |
|||
_handlePermissionAction(permission, status); |
|||
} else if (status.isGranted) { |
|||
controller.openSettings(); |
|||
} |
|||
}, |
|||
activeColor: Colors.blue[600], |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 状态文本 |
|||
Widget _buildStatusText(PermissionStatus status, bool isDarkMode) { |
|||
String text; |
|||
Color color; |
|||
|
|||
if (status.isGranted) { |
|||
text = '已授权'.tr; |
|||
color = Colors.green; |
|||
} else if (status.isDenied) { |
|||
text = '未授权'.tr; |
|||
color = Colors.red; |
|||
} else if (status.isPermanentlyDenied) { |
|||
text = '需要在系统设置中开启'.tr; |
|||
color = Colors.grey; |
|||
} else if (status.isLimited) { |
|||
text = '部分授权'.tr; |
|||
color = Colors.orange; |
|||
} else { |
|||
text = '未知状态'.tr; |
|||
color = Colors.grey; |
|||
} |
|||
|
|||
return Text( |
|||
text, |
|||
style: TextStyle( |
|||
color: color, |
|||
fontSize: 12.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 处理权限请求 |
|||
Future<void> _handlePermissionAction(Permission permission, PermissionStatus status) async { |
|||
if (status.isPermanentlyDenied) { |
|||
// 打开应用设置 |
|||
controller.openSettings(); |
|||
return; |
|||
} |
|||
|
|||
if (!status.isGranted) { |
|||
// 请求权限 |
|||
final result = await controller.requestPermission(permission); |
|||
|
|||
if (result.isGranted) { |
|||
Get.snackbar('成功'.tr, '权限已授予'.tr, snackPosition: SnackPosition.BOTTOM); |
|||
} else if (result.isPermanentlyDenied) { |
|||
Get.dialog( |
|||
AlertDialog( |
|||
title: Text('需要权限'.tr), |
|||
content: Text('此权限需要在设置中手动开启'.tr), |
|||
actions: [ |
|||
TextButton( |
|||
onPressed: () => Get.back(), |
|||
child: Text('取消'.tr), |
|||
), |
|||
TextButton( |
|||
onPressed: () { |
|||
Get.back(); |
|||
controller.openSettings(); |
|||
}, |
|||
child: Text('去设置'.tr), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 刷新界面 |
|||
Get.forceAppUpdate(); |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue