package io.legado.app.service import android.app.PendingIntent import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import com.script.ScriptException import io.legado.app.R import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.EventBus import io.legado.app.data.entities.HttpTTS import io.legado.app.exception.ConcurrentException import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.postEvent import io.legado.app.utils.printOnDebug import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Response import org.mozilla.javascript.WrappedException import java.io.File import java.io.InputStream import java.net.ConnectException import java.net.SocketTimeoutException /** * 在线朗读 */ class HttpReadAloudService : BaseReadAloudService(), Player.Listener { private val exoPlayer: ExoPlayer by lazy { ExoPlayer.Builder(this).build() } private val ttsFolderPath: String by lazy { cacheDir.absolutePath + File.separator + "httpTTS" + File.separator } private var speechRate: Int = AppConfig.speechRatePlay + 5 private var downloadTask: Coroutine<*>? = null private var playIndexJob: Job? = null private var downloadErrorNo: Int = 0 private var playErrorNo = 0 private val downloadTaskActiveLock = Mutex() override fun onCreate() { super.onCreate() exoPlayer.addListener(this) } override fun onDestroy() { super.onDestroy() downloadTask?.cancel() exoPlayer.release() Coroutine.async { removeCacheFile() } } override fun play() { pageChanged = false exoPlayer.stop() if (!requestFocus()) return if (contentList.isEmpty()) { AppLog.putDebug("朗读列表为空") ReadBook.readAloud() } else { super.play() downloadAndPlayAudios() } } override fun playStop() { exoPlayer.stop() } private fun playNext() { readAloudNumber += contentList[nowSpeak].length + 1 if (nowSpeak < contentList.lastIndex) { nowSpeak++ } else { nextChapter() } } private fun downloadAndPlayAudios() { exoPlayer.clearMediaItems() downloadTask?.cancel() downloadTask = execute { downloadTaskActiveLock.withLock { ensureActive() val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null") contentList.forEachIndexed { index, content -> ensureActive() if (index < nowSpeak) return@forEachIndexed val fileName = md5SpeakFileName(content) val speakText = content.replace(AppPattern.notReadAloudRegex, "") if (speakText.isEmpty()) { AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$content") createSilentSound(fileName) } else if (!hasSpeakFile(fileName)) { runCatching { val inputStream = getSpeakStream(httpTts, speakText) if (inputStream != null) { createSpeakFile(fileName, inputStream) } else { createSilentSound(fileName) } }.onFailure { when (it) { is CancellationException -> Unit else -> pauseReadAloud() } return@execute } } val file = getSpeakFileAsMd5(fileName) val mediaItem = MediaItem.fromUri(Uri.fromFile(file)) launch(Main) { if (exoPlayer.playbackState == Player.STATE_ENDED) { exoPlayer.stop() exoPlayer.clearMediaItems() } exoPlayer.addMediaItem(mediaItem) if (!exoPlayer.isPlaying) { exoPlayer.playWhenReady = !pause exoPlayer.prepare() } } } } }.onError { toastOnUi("朗读出错:${it.localizedMessage}") AppLog.put("朗读下载出错\n${it.localizedMessage}", it) } } private suspend fun getSpeakStream(httpTts: HttpTTS, speakText: String): InputStream? { while (true) { try { val analyzeUrl = AnalyzeUrl( httpTts.url, speakText = speakText, speakSpeed = speechRate, source = httpTts, headerMapF = httpTts.getHeaderMap(true) ) var response = analyzeUrl.getResponseAwait() ensureActive() val checkJs = httpTts.loginCheckJs if (checkJs?.isNotBlank() == true) { response = analyzeUrl.evalJS(checkJs, response) as Response } response.headers["Content-Type"]?.let { contentType -> val ct = httpTts.contentType if (contentType == "application/json") { throw NoStackTraceException(response.body!!.string()) } else if (ct?.isNotBlank() == true) { if (!contentType.matches(ct.toRegex())) { throw NoStackTraceException("TTS服务器返回错误:" + response.body!!.string()) } } } ensureActive() response.body!!.byteStream().let { stream -> downloadErrorNo = 0 return stream } } catch (e: Exception) { when (e) { is CancellationException -> throw e is ConcurrentException -> delay(e.waitTime.toLong()) is ScriptException, is WrappedException -> { AppLog.put("js错误\n${e.localizedMessage}", e) toastOnUi("js错误\n${e.localizedMessage}") e.printOnDebug() throw e } is SocketTimeoutException, is ConnectException -> { downloadErrorNo++ if (downloadErrorNo > 5) { val msg = "tts超时或连接错误超过5次\n${e.localizedMessage}" AppLog.put(msg, e) toastOnUi(msg) throw e } } else -> { downloadErrorNo++ val msg = "tts下载错误\n${e.localizedMessage}" AppLog.put(msg, e) e.printOnDebug() if (downloadErrorNo > 5) { val msg1 = "TTS服务器连续5次错误,已暂停阅读。" AppLog.put(msg1) toastOnUi(msg1) throw e } else { AppLog.put("TTS下载音频出错,使用无声音频代替。\n朗读文本:$speakText") break } } } } } return null } private fun md5SpeakFileName(content: String): String { return MD5Utils.md5Encode16(textChapter?.title ?: "") + "_" + MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content") } private fun createSilentSound(fileName: String) { val file = createSpeakFile(fileName) file.writeBytes(resources.openRawResource(R.raw.silent_sound).readBytes()) } private fun hasSpeakFile(name: String): Boolean { return FileUtils.exist("${ttsFolderPath}$name.mp3") } private fun getSpeakFileAsMd5(name: String): File { return File("${ttsFolderPath}$name.mp3") } private fun createSpeakFile(name: String): File { return FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3") } private fun createSpeakFile(name: String, inputStream: InputStream) { FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3").outputStream().use { out -> inputStream.use { it.copyTo(out) } } } /** * 移除缓存文件 */ private fun removeCacheFile() { val titleMd5 = MD5Utils.md5Encode16(textChapter?.title ?: "") FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach { val isSilentSound = it.length() == 2160L if ((!it.name.startsWith(titleMd5) && System.currentTimeMillis() - it.lastModified() > 600000) || isSilentSound ) { FileUtils.delete(it.absolutePath) } } } override fun pauseReadAloud(abandonFocus: Boolean) { super.pauseReadAloud(abandonFocus) kotlin.runCatching { playIndexJob?.cancel() exoPlayer.pause() } } override fun resumeReadAloud() { super.resumeReadAloud() kotlin.runCatching { if (pageChanged) { play() } else { exoPlayer.play() upPlayPos() } } } private fun upPlayPos() { playIndexJob?.cancel() val textChapter = textChapter ?: return playIndexJob = launch { postEvent(EventBus.TTS_PROGRESS, readAloudNumber + 1) if (exoPlayer.duration <= 0) { return@launch } val speakTextLength = contentList[nowSpeak].length if (speakTextLength <= 0) { return@launch } val sleep = exoPlayer.duration / speakTextLength val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration for (i in start..contentList[nowSpeak].length) { if (readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)) { pageIndex++ ReadBook.moveToNextPage() postEvent(EventBus.TTS_PROGRESS, readAloudNumber + i) } delay(sleep) } } } /** * 更新朗读速度 */ override fun upSpeechRate(reset: Boolean) { downloadTask?.cancel() exoPlayer.stop() speechRate = AppConfig.speechRatePlay + 5 downloadAndPlayAudios() } override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) when (playbackState) { Player.STATE_IDLE -> { // 空闲 } Player.STATE_BUFFERING -> { // 缓冲中 } Player.STATE_READY -> { // 准备好 if (pause) return exoPlayer.play() upPlayPos() } Player.STATE_ENDED -> { // 结束 playErrorNo = 0 playNext() } } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) return upPlayPos() playNext() } override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error) AppLog.put("朗读错误\n${contentList[nowSpeak]}", error) playErrorNo++ if (playErrorNo >= 5) { toastOnUi("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})") AppLog.put("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})", error) ReadAloud.pause(this) } else { playNext() } } override fun aloudServicePendingIntent(actionStr: String): PendingIntent? { return servicePendingIntent(actionStr) } }