revert: 回滚 TTS 语音播报功能
Edge TTS (speech.platform.bing.com) 在手表网络环境下无法连接,
回滚所有 TTS 相关改动,恢复到 fec1e80 的状态。
后续待确定可用的 TTS 方案后再重新实现。
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,8 +24,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class FiseVibrationController @Inject constructor(
|
class FiseVibrationController @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val configManager: VibrationConfigManager,
|
private val configManager: VibrationConfigManager
|
||||||
private val edgeTtsManager: com.xiaoqu.watch.service.manager.EdgeTtsManager
|
|
||||||
) : VibrationController {
|
) : VibrationController {
|
||||||
|
|
||||||
/** 系统振动器 */
|
/** 系统振动器 */
|
||||||
@@ -99,11 +98,9 @@ class FiseVibrationController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 音频:系统级开关 + 用户级开关 + 有音频文件 + TTS 未在播放
|
// 音频:系统级开关 + 用户级开关 + 有音频文件
|
||||||
if (voiceOk && !edgeTtsManager.isPlaying) {
|
if (voiceOk) {
|
||||||
playAudio(pattern.audioResId, configManager.voiceVolume)
|
playAudio(pattern.audioResId, configManager.voiceVolume)
|
||||||
} else if (voiceOk && edgeTtsManager.isPlaying) {
|
|
||||||
Timber.d("振动: TTS 播放中,跳过提示音")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
package com.xiaoqu.watch.service.manager
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.media.MediaPlayer
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import okhttp3.*
|
|
||||||
import okhttp3.ConnectionSpec
|
|
||||||
import okhttp3.TlsVersion
|
|
||||||
import okio.ByteString
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edge TTS 语音合成管理器
|
|
||||||
* 使用微软 Edge 浏览器的免费 TTS 服务,支持高质量中文语音合成。
|
|
||||||
* 通过 WebSocket 连接,发送文本,接收 MP3 音频并播放。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class EdgeTtsManager @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TTS 专用 OkHttpClient(不带业务拦截器)
|
|
||||||
* 业务拦截器(SignatureInterceptor、UnbindInterceptor)会干扰 WebSocket 连接
|
|
||||||
*/
|
|
||||||
private val ttsClient: OkHttpClient = run {
|
|
||||||
// Android 8.1 TLS 兼容:强制启用 TLS 1.2 + 现代密码套件
|
|
||||||
val spec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
|
|
||||||
.tlsVersions(TlsVersion.TLS_1_2)
|
|
||||||
.build()
|
|
||||||
val compatSpec = ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS)
|
|
||||||
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1)
|
|
||||||
.build()
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.connectionSpecs(listOf(spec, compatSpec, ConnectionSpec.CLEARTEXT))
|
|
||||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "EdgeTTS"
|
|
||||||
|
|
||||||
/** WebSocket 基础地址 */
|
|
||||||
private const val WSS_BASE = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"
|
|
||||||
|
|
||||||
/** 固定的信任客户端 Token */
|
|
||||||
private const val TRUSTED_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"
|
|
||||||
|
|
||||||
/** GEC 版本号 */
|
|
||||||
private const val GEC_VERSION = "1-143.0.3650.75"
|
|
||||||
|
|
||||||
/** Windows 纪元偏移(Unix epoch → Windows file time epoch) */
|
|
||||||
private const val WINDOWS_EPOCH_OFFSET = 11644473600L
|
|
||||||
|
|
||||||
/** 默认中文女声(晓晓,微软神经网络语音,音质最好) */
|
|
||||||
const val VOICE_XIAOXIAO = "zh-CN-XiaoxiaoNeural"
|
|
||||||
|
|
||||||
/** 中文男声(云希) */
|
|
||||||
const val VOICE_YUNXI = "zh-CN-YunxiNeural"
|
|
||||||
|
|
||||||
/** 音频输出格式:24kHz 48kbps MP3,体积小质量够用 */
|
|
||||||
private const val OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"
|
|
||||||
|
|
||||||
/** 缓存目录名 */
|
|
||||||
private const val CACHE_DIR = "tts_cache"
|
|
||||||
|
|
||||||
/** 缓存最大条数 */
|
|
||||||
private const val MAX_CACHE_SIZE = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前 MediaPlayer */
|
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
|
||||||
|
|
||||||
/** 是否正在播放 */
|
|
||||||
var isPlaying: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** 时钟偏移(秒),用于 GEC Token 生成 */
|
|
||||||
private var clockSkewSeconds: Long = 0
|
|
||||||
|
|
||||||
/** 播放完成回调 */
|
|
||||||
var onComplete: (() -> Unit)? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合成并播放语音
|
|
||||||
* @param text 要朗读的文本
|
|
||||||
* @param voice 语音类型,默认晓晓
|
|
||||||
* @param onError 错误回调
|
|
||||||
*/
|
|
||||||
fun speak(text: String, voice: String = VOICE_XIAOXIAO, onError: ((String) -> Unit)? = null) {
|
|
||||||
if (text.isBlank()) return
|
|
||||||
|
|
||||||
// 停止当前播放
|
|
||||||
stop()
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
// 检查缓存
|
|
||||||
val cacheFile = getCacheFile(text, voice)
|
|
||||||
if (cacheFile.exists()) {
|
|
||||||
Timber.d("$TAG: 命中缓存 ${cacheFile.name}")
|
|
||||||
playAudio(cacheFile)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Edge TTS API
|
|
||||||
val audioData = synthesize(text, voice)
|
|
||||||
if (audioData != null && audioData.isNotEmpty()) {
|
|
||||||
// 保存到缓存
|
|
||||||
saveToCacheDir(cacheFile, audioData)
|
|
||||||
playAudio(cacheFile)
|
|
||||||
} else {
|
|
||||||
Timber.w("$TAG: 合成返回空数据")
|
|
||||||
withContext(Dispatchers.Main) { onError?.invoke("语音合成失败") }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "$TAG: 语音合成异常")
|
|
||||||
withContext(Dispatchers.Main) { onError?.invoke("语音合成异常: ${e.message}") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止播放 */
|
|
||||||
fun stop() {
|
|
||||||
try {
|
|
||||||
mediaPlayer?.apply {
|
|
||||||
if (isPlaying) stop()
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
mediaPlayer = null
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过 WebSocket 调用 Edge TTS 合成语音
|
|
||||||
* @return MP3 音频字节数组,失败返回 null
|
|
||||||
*/
|
|
||||||
private suspend fun synthesize(text: String, voice: String): ByteArray? {
|
|
||||||
return suspendCancellableCoroutine { continuation ->
|
|
||||||
val connectionId = UUID.randomUUID().toString().replace("-", "")
|
|
||||||
val requestId = UUID.randomUUID().toString().replace("-", "")
|
|
||||||
val gecToken = generateGecToken()
|
|
||||||
|
|
||||||
val url = "$WSS_BASE?TrustedClientToken=$TRUSTED_TOKEN" +
|
|
||||||
"&ConnectionId=$connectionId" +
|
|
||||||
"&Sec-MS-GEC=$gecToken" +
|
|
||||||
"&Sec-MS-GEC-Version=$GEC_VERSION"
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0")
|
|
||||||
.header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val audioBuffer = ByteArrayOutputStream()
|
|
||||||
var resumed = false
|
|
||||||
|
|
||||||
val ws = ttsClient.newWebSocket(request, object : WebSocketListener() {
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
||||||
Timber.d("$TAG: WebSocket 已连接")
|
|
||||||
|
|
||||||
// 1. 发送 speech.config
|
|
||||||
val configMsg = buildConfigMessage()
|
|
||||||
webSocket.send(configMsg)
|
|
||||||
|
|
||||||
// 2. 发送 SSML 合成请求
|
|
||||||
val ssmlMsg = buildSsmlMessage(requestId, text, voice)
|
|
||||||
webSocket.send(ssmlMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
||||||
// 文本帧:解析 Path
|
|
||||||
if (text.contains("Path:turn.end")) {
|
|
||||||
// 合成完成
|
|
||||||
Timber.d("$TAG: 合成完成,音频大小 ${audioBuffer.size()} 字节")
|
|
||||||
webSocket.close(1000, "done")
|
|
||||||
if (!resumed) {
|
|
||||||
resumed = true
|
|
||||||
continuation.resumeWith(Result.success(audioBuffer.toByteArray()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
|
||||||
// 二进制帧:提取音频数据
|
|
||||||
val data = bytes.toByteArray()
|
|
||||||
val audioPayload = parseBinaryFrame(data)
|
|
||||||
if (audioPayload != null) {
|
|
||||||
audioBuffer.write(audioPayload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
Timber.e(t, "$TAG: WebSocket 连接失败, response=${response?.code}, url=$url")
|
|
||||||
// 尝试从错误响应中修正时钟偏移
|
|
||||||
response?.header("Date")?.let { adjustClockSkew(it) }
|
|
||||||
if (!resumed) {
|
|
||||||
resumed = true
|
|
||||||
continuation.resumeWith(Result.success(null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
||||||
if (!resumed) {
|
|
||||||
resumed = true
|
|
||||||
continuation.resumeWith(Result.success(audioBuffer.toByteArray()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
continuation.invokeOnCancellation {
|
|
||||||
ws.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建 speech.config 消息 */
|
|
||||||
private fun buildConfigMessage(): String {
|
|
||||||
val timestamp = formatTimestamp()
|
|
||||||
return "X-Timestamp:$timestamp\r\n" +
|
|
||||||
"Content-Type:application/json; charset=utf-8\r\n" +
|
|
||||||
"Path:speech.config\r\n" +
|
|
||||||
"\r\n" +
|
|
||||||
"""{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},"outputFormat":"$OUTPUT_FORMAT"}}}}"""
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建 SSML 合成请求消息 */
|
|
||||||
private fun buildSsmlMessage(requestId: String, text: String, voice: String): String {
|
|
||||||
val timestamp = formatTimestamp()
|
|
||||||
val escapedText = text
|
|
||||||
.replace("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("\"", """)
|
|
||||||
.replace("'", "'")
|
|
||||||
|
|
||||||
return "X-RequestId:$requestId\r\n" +
|
|
||||||
"Content-Type:application/ssml+xml\r\n" +
|
|
||||||
"X-Timestamp:${timestamp}Z\r\n" +
|
|
||||||
"Path:ssml\r\n" +
|
|
||||||
"\r\n" +
|
|
||||||
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='zh-CN'>" +
|
|
||||||
"<voice name='$voice'>" +
|
|
||||||
"<prosody pitch='+0Hz' rate='+0%' volume='+0%'>$escapedText</prosody>" +
|
|
||||||
"</voice></speak>"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析二进制帧,提取音频数据
|
|
||||||
* 格式:[2字节头部长度][头部内容][音频数据]
|
|
||||||
*/
|
|
||||||
private fun parseBinaryFrame(data: ByteArray): ByteArray? {
|
|
||||||
if (data.size < 2) return null
|
|
||||||
val headerLength = ((data[0].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF)
|
|
||||||
val audioStart = 2 + headerLength
|
|
||||||
if (audioStart >= data.size) return null
|
|
||||||
|
|
||||||
// 验证是音频帧
|
|
||||||
val headerStr = String(data, 2, headerLength, Charsets.US_ASCII)
|
|
||||||
if (!headerStr.contains("Path:audio")) return null
|
|
||||||
|
|
||||||
return data.copyOfRange(audioStart, data.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 生成 Sec-MS-GEC Token(基于时间的 SHA256 哈希) */
|
|
||||||
private fun generateGecToken(): String {
|
|
||||||
var ticks = (System.currentTimeMillis() / 1000.0) + clockSkewSeconds
|
|
||||||
ticks += WINDOWS_EPOCH_OFFSET
|
|
||||||
ticks -= ticks % 300 // 对齐到 5 分钟
|
|
||||||
ticks *= 10_000_000 // 转换为 100 纳秒间隔
|
|
||||||
|
|
||||||
val strToHash = "${ticks.toLong()}$TRUSTED_TOKEN"
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
.digest(strToHash.toByteArray(Charsets.US_ASCII))
|
|
||||||
return digest.joinToString("") { "%02X".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化时间戳 */
|
|
||||||
private fun formatTimestamp(): String {
|
|
||||||
val sdf = SimpleDateFormat("EEE MMM dd yyyy HH:mm:ss 'GMT+0000 (Coordinated Universal Time)'", Locale.US)
|
|
||||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
|
||||||
return sdf.format(Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从服务器响应修正时钟偏移 */
|
|
||||||
private fun adjustClockSkew(serverDateHeader: String) {
|
|
||||||
try {
|
|
||||||
val sdf = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US)
|
|
||||||
val serverTime = sdf.parse(serverDateHeader)?.time ?: return
|
|
||||||
clockSkewSeconds = (serverTime - System.currentTimeMillis()) / 1000
|
|
||||||
Timber.d("$TAG: 时钟偏移修正为 ${clockSkewSeconds}s")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 播放音频文件 */
|
|
||||||
private suspend fun playAudio(file: File) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
try {
|
|
||||||
stop()
|
|
||||||
val player = MediaPlayer()
|
|
||||||
player.setDataSource(file.absolutePath)
|
|
||||||
player.setOnCompletionListener {
|
|
||||||
isPlaying = false
|
|
||||||
onComplete?.invoke()
|
|
||||||
Timber.d("$TAG: 播放完成")
|
|
||||||
}
|
|
||||||
player.setOnErrorListener { _, what, extra ->
|
|
||||||
Timber.e("$TAG: 播放错误 what=$what extra=$extra")
|
|
||||||
isPlaying = false
|
|
||||||
true
|
|
||||||
}
|
|
||||||
player.prepare()
|
|
||||||
player.start()
|
|
||||||
mediaPlayer = player
|
|
||||||
isPlaying = true
|
|
||||||
Timber.d("$TAG: 开始播放")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "$TAG: 播放异常")
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 缓存管理 =====
|
|
||||||
|
|
||||||
/** 获取缓存文件路径(基于文本+语音的 MD5) */
|
|
||||||
private fun getCacheFile(text: String, voice: String): File {
|
|
||||||
val cacheDir = File(context.cacheDir, CACHE_DIR).also { it.mkdirs() }
|
|
||||||
val key = MessageDigest.getInstance("MD5")
|
|
||||||
.digest("$voice:$text".toByteArray())
|
|
||||||
.joinToString("") { "%02x".format(it) }
|
|
||||||
return File(cacheDir, "$key.mp3")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存到缓存目录,超过上限时清理最旧的 */
|
|
||||||
private fun saveToCacheDir(file: File, data: ByteArray) {
|
|
||||||
file.writeBytes(data)
|
|
||||||
// 清理超出上限的旧缓存
|
|
||||||
val cacheDir = file.parentFile ?: return
|
|
||||||
val files = cacheDir.listFiles()?.sortedBy { it.lastModified() } ?: return
|
|
||||||
if (files.size > MAX_CACHE_SIZE) {
|
|
||||||
files.take(files.size - MAX_CACHE_SIZE).forEach { it.delete() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除所有缓存 */
|
|
||||||
fun clearCache() {
|
|
||||||
File(context.cacheDir, CACHE_DIR).deleteRecursively()
|
|
||||||
Timber.d("$TAG: 缓存已清除")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,6 @@ import com.xiaoqu.watch.ui.punch.PunchResult
|
|||||||
import com.xiaoqu.watch.ui.punch.PunchViewModel
|
import com.xiaoqu.watch.ui.punch.PunchViewModel
|
||||||
import com.xiaoqu.watch.ui.widget.StatusBarView
|
import com.xiaoqu.watch.ui.widget.StatusBarView
|
||||||
import com.xiaoqu.watch.util.DateUtil
|
import com.xiaoqu.watch.util.DateUtil
|
||||||
import com.xiaoqu.watch.service.manager.EdgeTtsManager
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -52,7 +51,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
@Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager
|
@Inject lateinit var bluetoothScanManager: com.xiaoqu.watch.service.manager.BluetoothScanManager
|
||||||
@Inject lateinit var notificationManager: com.xiaoqu.watch.service.manager.NotificationManager
|
@Inject lateinit var notificationManager: com.xiaoqu.watch.service.manager.NotificationManager
|
||||||
@Inject lateinit var vibrationConfigManager: com.xiaoqu.watch.device.sensor.VibrationConfigManager
|
@Inject lateinit var vibrationConfigManager: com.xiaoqu.watch.device.sensor.VibrationConfigManager
|
||||||
@Inject lateinit var edgeTtsManager: EdgeTtsManager
|
|
||||||
|
|
||||||
/** 考勤打卡 ViewModel */
|
/** 考勤打卡 ViewModel */
|
||||||
private val punchViewModel: PunchViewModel by viewModels()
|
private val punchViewModel: PunchViewModel by viewModels()
|
||||||
@@ -85,8 +83,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
private var debugTapCount = 0
|
private var debugTapCount = 0
|
||||||
private var lastTapTime = 0L
|
private var lastTapTime = 0L
|
||||||
|
|
||||||
// ===== TTS 语音测试 =====
|
|
||||||
|
|
||||||
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
|
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
|
||||||
return FragmentHomeBinding.inflate(inflater, container, false)
|
return FragmentHomeBinding.inflate(inflater, container, false)
|
||||||
}
|
}
|
||||||
@@ -183,8 +179,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
it.onBackKeyPressed = null
|
it.onBackKeyPressed = null
|
||||||
it.notificationBanner.onClick = null
|
it.notificationBanner.onClick = null
|
||||||
}
|
}
|
||||||
// 停止 TTS 播放
|
|
||||||
edgeTtsManager.stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 打卡面板 =====
|
// ===== 打卡面板 =====
|
||||||
@@ -554,26 +548,11 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
|||||||
|
|
||||||
if (debugTapCount >= 6) {
|
if (debugTapCount >= 6) {
|
||||||
debugTapCount = 0
|
debugTapCount = 0
|
||||||
Timber.d("TTS 语音测试开始")
|
Timber.d("调试模式已开启")
|
||||||
testTts()
|
Toast.makeText(requireContext(), "调试模式已开启", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Edge TTS 语音测试:通过微软 Edge TTS 合成中文语音
|
|
||||||
* 测试内容:调用 Edge TTS API → 接收 MP3 → 播放
|
|
||||||
* 结果通过 Logcat 和 Toast 反馈
|
|
||||||
*/
|
|
||||||
private fun testTts() {
|
|
||||||
Toast.makeText(requireContext(), "Edge TTS 测试中...", Toast.LENGTH_SHORT).show()
|
|
||||||
edgeTtsManager.speak(
|
|
||||||
text = "您有3条新任务待处理,请及时查看",
|
|
||||||
onError = { msg ->
|
|
||||||
Toast.makeText(requireContext(), "TTS 失败: $msg", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 事件监听 =====
|
// ===== 事件监听 =====
|
||||||
|
|
||||||
/** 监听 MQTT 和系统状态事件 */
|
/** 监听 MQTT 和系统状态事件 */
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import com.xiaoqu.watch.databinding.FragmentTaskDetailBinding
|
|||||||
import com.xiaoqu.watch.network.ApiResult
|
import com.xiaoqu.watch.network.ApiResult
|
||||||
import com.xiaoqu.watch.network.api.TaskApi
|
import com.xiaoqu.watch.network.api.TaskApi
|
||||||
import com.xiaoqu.watch.network.safeApiCall
|
import com.xiaoqu.watch.network.safeApiCall
|
||||||
import com.xiaoqu.watch.service.manager.EdgeTtsManager
|
|
||||||
import com.xiaoqu.watch.service.manager.NfcTaskManager
|
import com.xiaoqu.watch.service.manager.NfcTaskManager
|
||||||
import com.xiaoqu.watch.ui.common.BaseFragment
|
import com.xiaoqu.watch.ui.common.BaseFragment
|
||||||
import com.xiaoqu.watch.ui.widget.QuTipDialog
|
import com.xiaoqu.watch.ui.widget.QuTipDialog
|
||||||
@@ -31,7 +30,6 @@ class TaskDetailFragment : BaseFragment<FragmentTaskDetailBinding>() {
|
|||||||
|
|
||||||
@Inject lateinit var taskApi: TaskApi
|
@Inject lateinit var taskApi: TaskApi
|
||||||
@Inject lateinit var nfcTaskManager: NfcTaskManager
|
@Inject lateinit var nfcTaskManager: NfcTaskManager
|
||||||
@Inject lateinit var edgeTtsManager: EdgeTtsManager
|
|
||||||
|
|
||||||
/** 当前任务数据 */
|
/** 当前任务数据 */
|
||||||
private var taskDetail: TaskDetail? = null
|
private var taskDetail: TaskDetail? = null
|
||||||
@@ -55,9 +53,6 @@ class TaskDetailFragment : BaseFragment<FragmentTaskDetailBinding>() {
|
|||||||
findNavController().popBackStack()
|
findNavController().popBackStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTS 播放按钮
|
|
||||||
binding.btnTts.setOnClickListener { toggleTts() }
|
|
||||||
|
|
||||||
// 从导航参数获取任务 ID
|
// 从导航参数获取任务 ID
|
||||||
val taskId = arguments?.getLong("taskId", 0) ?: 0
|
val taskId = arguments?.getLong("taskId", 0) ?: 0
|
||||||
if (taskId > 0) {
|
if (taskId > 0) {
|
||||||
@@ -289,99 +284,4 @@ class TaskDetailFragment : BaseFragment<FragmentTaskDetailBinding>() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== TTS 语音播报 =====
|
|
||||||
|
|
||||||
/** 切换播放/停止 */
|
|
||||||
private fun toggleTts() {
|
|
||||||
if (edgeTtsManager.isPlaying) {
|
|
||||||
stopTts()
|
|
||||||
} else {
|
|
||||||
startTts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 开始播报当前任务内容 */
|
|
||||||
private fun startTts() {
|
|
||||||
val detail = taskDetail ?: return
|
|
||||||
|
|
||||||
// 拼接播报文本:有值的字段才读,空字段跳过
|
|
||||||
val parts = mutableListOf<String>()
|
|
||||||
if (detail.name.isNotBlank()) parts.add("任务:${detail.displayName}")
|
|
||||||
if (detail.positionText.isNotBlank()) parts.add("地点:${detail.positionText}")
|
|
||||||
if (detail.sendTime.isNotBlank()) parts.add("派单时间:${formatTimeForTts(detail.sendTime)}")
|
|
||||||
when {
|
|
||||||
!detail.preFinishTime.isNullOrEmpty() -> parts.add("要求完成:${formatTimeForTts(detail.preFinishTime!!)}")
|
|
||||||
!detail.expireTime.isNullOrEmpty() -> parts.add("截止时间:${formatTimeForTts(detail.expireTime!!)}")
|
|
||||||
}
|
|
||||||
if (detail.point > 0) parts.add("积分:${detail.point.toInt()}分")
|
|
||||||
val note = when {
|
|
||||||
!detail.taskRequire.isNullOrEmpty() -> detail.taskRequire
|
|
||||||
detail.content.isNotEmpty() -> detail.content
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (!note.isNullOrBlank()) parts.add("备注:$note")
|
|
||||||
|
|
||||||
if (parts.isEmpty()) return
|
|
||||||
|
|
||||||
val text = parts.joinToString(",")
|
|
||||||
Timber.d("TTS 播报: $text")
|
|
||||||
|
|
||||||
// 更新按钮状态为播放中
|
|
||||||
updateTtsButton(playing = true)
|
|
||||||
|
|
||||||
// 播放完成回调:恢复按钮
|
|
||||||
edgeTtsManager.onComplete = {
|
|
||||||
activity?.runOnUiThread { updateTtsButton(playing = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeTtsManager.speak(text) { errorMsg ->
|
|
||||||
Timber.w("TTS 播报失败: $errorMsg")
|
|
||||||
updateTtsButton(playing = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止播报 */
|
|
||||||
private fun stopTts() {
|
|
||||||
edgeTtsManager.stop()
|
|
||||||
updateTtsButton(playing = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将时间字符串转为适合朗读的中文格式
|
|
||||||
* "04.28 09:10" → "4月28日9点10分"
|
|
||||||
* "2026-04-28 09:10:00" → "4月28日9点10分"
|
|
||||||
*/
|
|
||||||
private fun formatTimeForTts(time: String): String {
|
|
||||||
try {
|
|
||||||
val shortPattern = Regex("""(\d{1,2})\.(\d{1,2})\s+(\d{1,2}):(\d{2})""")
|
|
||||||
shortPattern.find(time)?.let { match ->
|
|
||||||
val (month, day, hour, minute) = match.destructured
|
|
||||||
return "${month.toInt()}月${day.toInt()}日${hour.toInt()}点${minute}分"
|
|
||||||
}
|
|
||||||
val longPattern = Regex("""(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})""")
|
|
||||||
longPattern.find(time)?.let { match ->
|
|
||||||
val (_, month, day, hour, minute) = match.destructured
|
|
||||||
return "${month.toInt()}月${day.toInt()}日${hour.toInt()}点${minute}分"
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
return time
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新 TTS 按钮的 UI 状态 */
|
|
||||||
private fun updateTtsButton(playing: Boolean) {
|
|
||||||
binding.btnTts.setImageResource(
|
|
||||||
if (playing) R.drawable.ic_speaker_stop else R.drawable.ic_speaker
|
|
||||||
)
|
|
||||||
binding.btnTts.setBackgroundResource(
|
|
||||||
if (playing) R.drawable.bg_tts_button_active else R.drawable.bg_tts_button
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
// 离开页面自动停止播放
|
|
||||||
edgeTtsManager.stop()
|
|
||||||
edgeTtsManager.onComplete = null
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import com.xiaoqu.watch.databinding.FragmentTaskListBinding
|
|||||||
import com.xiaoqu.watch.network.ApiResult
|
import com.xiaoqu.watch.network.ApiResult
|
||||||
import com.xiaoqu.watch.network.api.TaskApi
|
import com.xiaoqu.watch.network.api.TaskApi
|
||||||
import com.xiaoqu.watch.network.safeApiCall
|
import com.xiaoqu.watch.network.safeApiCall
|
||||||
import com.xiaoqu.watch.service.manager.EdgeTtsManager
|
|
||||||
import com.xiaoqu.watch.service.manager.NfcTaskManager
|
import com.xiaoqu.watch.service.manager.NfcTaskManager
|
||||||
import com.xiaoqu.watch.ui.common.BaseFragment
|
import com.xiaoqu.watch.ui.common.BaseFragment
|
||||||
import com.xiaoqu.watch.ui.widget.QuTipDialog
|
import com.xiaoqu.watch.ui.widget.QuTipDialog
|
||||||
@@ -43,7 +42,6 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
|
|||||||
|
|
||||||
@Inject lateinit var taskApi: TaskApi
|
@Inject lateinit var taskApi: TaskApi
|
||||||
@Inject lateinit var nfcTaskManager: NfcTaskManager
|
@Inject lateinit var nfcTaskManager: NfcTaskManager
|
||||||
@Inject lateinit var edgeTtsManager: EdgeTtsManager
|
|
||||||
|
|
||||||
|
|
||||||
/** 任务 ID 列表(queryTaskIds 返回) */
|
/** 任务 ID 列表(queryTaskIds 返回) */
|
||||||
@@ -119,17 +117,12 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
|
|||||||
false // 不拦截,让 ScrollView 继续处理滚动
|
false // 不拦截,让 ScrollView 继续处理滚动
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTS 播放按钮
|
|
||||||
binding.btnTts.setOnClickListener { toggleTts() }
|
|
||||||
|
|
||||||
// 加载任务列表
|
// 加载任务列表
|
||||||
fetchTaskIds()
|
fetchTaskIds()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
stopVoice()
|
stopVoice()
|
||||||
edgeTtsManager.stop()
|
|
||||||
edgeTtsManager.onComplete = null
|
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +237,6 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
|
|||||||
/** 下一个任务(左滑,内容从右滑入) */
|
/** 下一个任务(左滑,内容从右滑入) */
|
||||||
private fun nextTask() {
|
private fun nextTask() {
|
||||||
if (taskIndex < taskList.size - 1) {
|
if (taskIndex < taskList.size - 1) {
|
||||||
stopTts() // 切换任务时停止播报
|
|
||||||
taskIndex++
|
taskIndex++
|
||||||
animateSwitch(fromRight = true)
|
animateSwitch(fromRight = true)
|
||||||
}
|
}
|
||||||
@@ -253,7 +245,6 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
|
|||||||
/** 上一个任务(右滑,内容从左滑入) */
|
/** 上一个任务(右滑,内容从左滑入) */
|
||||||
private fun prevTask() {
|
private fun prevTask() {
|
||||||
if (taskIndex > 0) {
|
if (taskIndex > 0) {
|
||||||
stopTts() // 切换任务时停止播报
|
|
||||||
taskIndex--
|
taskIndex--
|
||||||
animateSwitch(fromRight = false)
|
animateSwitch(fromRight = false)
|
||||||
}
|
}
|
||||||
@@ -1015,97 +1006,4 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
|
|||||||
binding.btnAction.visibility = View.GONE
|
binding.btnAction.visibility = View.GONE
|
||||||
binding.tvTitle.text = statusTitle()
|
binding.tvTitle.text = statusTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== TTS 语音播报 =====
|
|
||||||
|
|
||||||
/** 切换播放/停止 */
|
|
||||||
private fun toggleTts() {
|
|
||||||
if (edgeTtsManager.isPlaying) {
|
|
||||||
stopTts()
|
|
||||||
} else {
|
|
||||||
startTts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 开始播报当前任务内容 */
|
|
||||||
private fun startTts() {
|
|
||||||
val detail = currentDetail ?: return
|
|
||||||
|
|
||||||
// 拼接播报文本:有值的字段才读,空字段跳过
|
|
||||||
val parts = mutableListOf<String>()
|
|
||||||
if (detail.name.isNotBlank()) parts.add("任务:${detail.displayName}")
|
|
||||||
if (detail.positionText.isNotBlank()) parts.add("地点:${detail.positionText}")
|
|
||||||
if (detail.sendTime.isNotBlank()) parts.add("派单时间:${formatTimeForTts(detail.sendTime)}")
|
|
||||||
// 要求完成时间 / 截止时间(优先要求,和页面显示逻辑一致)
|
|
||||||
when {
|
|
||||||
!detail.preFinishTime.isNullOrEmpty() -> parts.add("要求完成:${formatTimeForTts(detail.preFinishTime!!)}")
|
|
||||||
!detail.expireTime.isNullOrEmpty() -> parts.add("截止时间:${formatTimeForTts(detail.expireTime!!)}")
|
|
||||||
}
|
|
||||||
if (detail.point > 0) parts.add("积分:${detail.point.toInt()}分")
|
|
||||||
// 备注:优先 taskRequire,没有则用 content(和页面显示逻辑一致)
|
|
||||||
val note = when {
|
|
||||||
!detail.taskRequire.isNullOrEmpty() -> detail.taskRequire
|
|
||||||
detail.content.isNotEmpty() -> detail.content
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (!note.isNullOrBlank()) parts.add("备注:$note")
|
|
||||||
|
|
||||||
if (parts.isEmpty()) return
|
|
||||||
|
|
||||||
val text = parts.joinToString(",")
|
|
||||||
Timber.d("TTS 播报: $text")
|
|
||||||
|
|
||||||
// 更新按钮状态为播放中
|
|
||||||
updateTtsButton(playing = true)
|
|
||||||
|
|
||||||
// 播放完成回调:恢复按钮
|
|
||||||
edgeTtsManager.onComplete = {
|
|
||||||
activity?.runOnUiThread { updateTtsButton(playing = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeTtsManager.speak(text) { errorMsg ->
|
|
||||||
Timber.w("TTS 播报失败: $errorMsg")
|
|
||||||
activity?.runOnUiThread { updateTtsButton(playing = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止播报 */
|
|
||||||
private fun stopTts() {
|
|
||||||
edgeTtsManager.stop()
|
|
||||||
updateTtsButton(playing = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将时间字符串转为适合朗读的中文格式
|
|
||||||
* "04.28 09:10" → "4月28日9点10分"
|
|
||||||
* "2026-04-28 09:10:00" → "4月28日9点10分"
|
|
||||||
* 无法解析时返回原文
|
|
||||||
*/
|
|
||||||
private fun formatTimeForTts(time: String): String {
|
|
||||||
try {
|
|
||||||
// 匹配 "MM.dd HH:mm" 格式
|
|
||||||
val shortPattern = Regex("""(\d{1,2})\.(\d{1,2})\s+(\d{1,2}):(\d{2})""")
|
|
||||||
shortPattern.find(time)?.let { match ->
|
|
||||||
val (month, day, hour, minute) = match.destructured
|
|
||||||
return "${month.toInt()}月${day.toInt()}日${hour.toInt()}点${minute}分"
|
|
||||||
}
|
|
||||||
// 匹配 "yyyy-MM-dd HH:mm:ss" 或 "yyyy-MM-dd HH:mm" 格式
|
|
||||||
val longPattern = Regex("""(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})""")
|
|
||||||
longPattern.find(time)?.let { match ->
|
|
||||||
val (_, month, day, hour, minute) = match.destructured
|
|
||||||
return "${month.toInt()}月${day.toInt()}日${hour.toInt()}点${minute}分"
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
return time
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新 TTS 按钮的 UI 状态 */
|
|
||||||
private fun updateTtsButton(playing: Boolean) {
|
|
||||||
binding.btnTts.setImageResource(
|
|
||||||
if (playing) R.drawable.ic_speaker_stop else R.drawable.ic_speaker
|
|
||||||
)
|
|
||||||
binding.btnTts.setBackgroundResource(
|
|
||||||
if (playing) R.drawable.bg_tts_button_active else R.drawable.bg_tts_button
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- TTS 悬浮按钮背景:半透明圆形 -->
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="oval">
|
|
||||||
<solid android:color="#80FFFFFF" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- TTS 悬浮按钮背景(播放中):主题色半透明圆形 -->
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="oval">
|
|
||||||
<solid android:color="#803B9EFF" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- 喇叭图标(语音播报按钮)24×24dp -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<!-- 喇叭主体 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M3,9v6h4l5,5V4L7,9H3z" />
|
|
||||||
<!-- 声波 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- 停止图标(语音播报停止状态)24×24dp -->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<!-- 喇叭主体 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M3,9v6h4l5,5V4L7,9H3z" />
|
|
||||||
<!-- 叉号(静音标记) -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M4.27,3L3,4.27 7.73,9H3v6h4l5,5v-6.73l4.25,4.25c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,21 21,19.73l-9,-9L4.27,3z" />
|
|
||||||
</vector>
|
|
||||||
@@ -101,18 +101,6 @@
|
|||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<!-- TTS 语音播报悬浮按钮:右侧垂直居中,48dp 触摸区 -->
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/btnTts"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_gravity="center_vertical|end"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:padding="8dp"
|
|
||||||
android:src="@drawable/ic_speaker"
|
|
||||||
android:background="@drawable/bg_tts_button"
|
|
||||||
android:contentDescription="语音播报" />
|
|
||||||
|
|
||||||
<!-- 底部固定操作按钮 -->
|
<!-- 底部固定操作按钮 -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnAction"
|
android:id="@+id/btnAction"
|
||||||
|
|||||||
@@ -364,19 +364,6 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<!-- TTS 语音播报悬浮按钮:右侧垂直居中,48dp 触摸区 -->
|
|
||||||
<ImageView
|
|
||||||
android:id="@+id/btnTts"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:layout_gravity="center_vertical|end"
|
|
||||||
android:layout_marginEnd="4dp"
|
|
||||||
android:padding="8dp"
|
|
||||||
android:src="@drawable/ic_speaker"
|
|
||||||
android:background="@drawable/bg_tts_button"
|
|
||||||
android:contentDescription="语音播报" />
|
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
||||||
<!-- ===== 底部操作按钮(高度缩小,字体加大) ===== -->
|
<!-- ===== 底部操作按钮(高度缩小,字体加大) ===== -->
|
||||||
|
|||||||
Reference in New Issue
Block a user