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:
dongliang
2026-05-08 15:12:19 +09:30
parent 461732f38f
commit c0643d41df
11 changed files with 5 additions and 672 deletions

View File

@@ -24,8 +24,7 @@ import javax.inject.Singleton
@Singleton
class FiseVibrationController @Inject constructor(
@ApplicationContext private val context: Context,
private val configManager: VibrationConfigManager,
private val edgeTtsManager: com.xiaoqu.watch.service.manager.EdgeTtsManager
private val configManager: VibrationConfigManager
) : VibrationController {
/** 系统振动器 */
@@ -99,11 +98,9 @@ class FiseVibrationController @Inject constructor(
}
}
// 音频:系统级开关 + 用户级开关 + 有音频文件 + TTS 未在播放
if (voiceOk && !edgeTtsManager.isPlaying) {
// 音频:系统级开关 + 用户级开关 + 有音频文件
if (voiceOk) {
playAudio(pattern.audioResId, configManager.voiceVolume)
} else if (voiceOk && edgeTtsManager.isPlaying) {
Timber.d("振动: TTS 播放中,跳过提示音")
}
}

View File

@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
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: 缓存已清除")
}
}

View File

@@ -28,7 +28,6 @@ import com.xiaoqu.watch.ui.punch.PunchResult
import com.xiaoqu.watch.ui.punch.PunchViewModel
import com.xiaoqu.watch.ui.widget.StatusBarView
import com.xiaoqu.watch.util.DateUtil
import com.xiaoqu.watch.service.manager.EdgeTtsManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
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 notificationManager: com.xiaoqu.watch.service.manager.NotificationManager
@Inject lateinit var vibrationConfigManager: com.xiaoqu.watch.device.sensor.VibrationConfigManager
@Inject lateinit var edgeTtsManager: EdgeTtsManager
/** 考勤打卡 ViewModel */
private val punchViewModel: PunchViewModel by viewModels()
@@ -85,8 +83,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
private var debugTapCount = 0
private var lastTapTime = 0L
// ===== TTS 语音测试 =====
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
return FragmentHomeBinding.inflate(inflater, container, false)
}
@@ -183,8 +179,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
it.onBackKeyPressed = null
it.notificationBanner.onClick = null
}
// 停止 TTS 播放
edgeTtsManager.stop()
}
// ===== 打卡面板 =====
@@ -554,26 +548,11 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
if (debugTapCount >= 6) {
debugTapCount = 0
Timber.d("TTS 语音测试开始")
testTts()
Timber.d("调试模式已开启")
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 和系统状态事件 */

View File

@@ -13,7 +13,6 @@ import com.xiaoqu.watch.databinding.FragmentTaskDetailBinding
import com.xiaoqu.watch.network.ApiResult
import com.xiaoqu.watch.network.api.TaskApi
import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.service.manager.EdgeTtsManager
import com.xiaoqu.watch.service.manager.NfcTaskManager
import com.xiaoqu.watch.ui.common.BaseFragment
import com.xiaoqu.watch.ui.widget.QuTipDialog
@@ -31,7 +30,6 @@ class TaskDetailFragment : BaseFragment<FragmentTaskDetailBinding>() {
@Inject lateinit var taskApi: TaskApi
@Inject lateinit var nfcTaskManager: NfcTaskManager
@Inject lateinit var edgeTtsManager: EdgeTtsManager
/** 当前任务数据 */
private var taskDetail: TaskDetail? = null
@@ -55,9 +53,6 @@ class TaskDetailFragment : BaseFragment<FragmentTaskDetailBinding>() {
findNavController().popBackStack()
}
// TTS 播放按钮
binding.btnTts.setOnClickListener { toggleTts() }
// 从导航参数获取任务 ID
val taskId = arguments?.getLong("taskId", 0) ?: 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()
}
}

View File

@@ -19,7 +19,6 @@ import com.xiaoqu.watch.databinding.FragmentTaskListBinding
import com.xiaoqu.watch.network.ApiResult
import com.xiaoqu.watch.network.api.TaskApi
import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.service.manager.EdgeTtsManager
import com.xiaoqu.watch.service.manager.NfcTaskManager
import com.xiaoqu.watch.ui.common.BaseFragment
import com.xiaoqu.watch.ui.widget.QuTipDialog
@@ -43,7 +42,6 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
@Inject lateinit var taskApi: TaskApi
@Inject lateinit var nfcTaskManager: NfcTaskManager
@Inject lateinit var edgeTtsManager: EdgeTtsManager
/** 任务 ID 列表queryTaskIds 返回) */
@@ -119,17 +117,12 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
false // 不拦截,让 ScrollView 继续处理滚动
}
// TTS 播放按钮
binding.btnTts.setOnClickListener { toggleTts() }
// 加载任务列表
fetchTaskIds()
}
override fun onDestroyView() {
stopVoice()
edgeTtsManager.stop()
edgeTtsManager.onComplete = null
super.onDestroyView()
}
@@ -244,7 +237,6 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
/** 下一个任务(左滑,内容从右滑入) */
private fun nextTask() {
if (taskIndex < taskList.size - 1) {
stopTts() // 切换任务时停止播报
taskIndex++
animateSwitch(fromRight = true)
}
@@ -253,7 +245,6 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
/** 上一个任务(右滑,内容从左滑入) */
private fun prevTask() {
if (taskIndex > 0) {
stopTts() // 切换任务时停止播报
taskIndex--
animateSwitch(fromRight = false)
}
@@ -1015,97 +1006,4 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
binding.btnAction.visibility = View.GONE
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
)
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -101,18 +101,6 @@
</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
android:id="@+id/btnAction"

View File

@@ -364,19 +364,6 @@
</LinearLayout>
</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>
<!-- ===== 底部操作按钮(高度缩小,字体加大) ===== -->