Files
QieKan-3.0/app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
T
2026-01-17 06:39:29 +08:00

647 lines
24 KiB
Kotlin

package io.legado.app.service
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.offline.DefaultDownloaderFactory
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.Downloader
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
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.data.appDb
import io.legado.app.data.entities.HttpTTS
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.BookHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.exoplayer.InputStreamDataSource
import io.legado.app.help.http.okHttpClient
import io.legado.app.model.ReadAloud
import io.legado.app.model.ReadBook
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.utils.FileUtils
import io.legado.app.utils.MD5Utils
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.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Response
import org.mozilla.javascript.WrappedException
import splitties.init.appCtx
import java.io.File
import java.io.InputStream
import java.net.ConnectException
import java.net.SocketTimeoutException
import kotlin.coroutines.coroutineContext
/**
* 在线朗读
* (已修改:支持跨章节预缓存 + 自定义清理时间)
*/
@SuppressLint("UnsafeOptInUsageError")
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 val cache by lazy {
SimpleCache(
File(cacheDir, "httpTTS_cache"),
LeastRecentlyUsedCacheEvictor(128 * 1024 * 1024),
StandaloneDatabaseProvider(appCtx)
)
}
private val cacheDataSinkFactory by lazy {
CacheDataSink.Factory()
.setCache(cache)
}
private val loadErrorHandlingPolicy by lazy {
CustomLoadErrorHandlingPolicy()
}
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()
cache.release()
Coroutine.async {
removeCacheFile()
}
}
override fun play() {
pageChanged = false
exoPlayer.stop()
if (!requestFocus()) return
if (contentList.isEmpty()) {
AppLog.putDebug("朗读列表为空")
ReadBook.readAloud()
} else {
super.play()
if (AppConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
downloadAndPlayAudios()
}
}
}
override fun playStop() {
exoPlayer.stop()
playIndexJob?.cancel()
}
private fun updateNextPos() {
readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
paragraphStartPos = 0
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")
// 1. 处理当前正在播放的章节
contentList.forEachIndexed { index, content ->
ensureActive()
if (index < nowSpeak) return@forEachIndexed
var text = content
if (paragraphStartPos > 0 && index == nowSpeak) {
text = text.substring(paragraphStartPos)
}
val fileName = md5SpeakFileName(text)
val speakText = text.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$text")
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) {
exoPlayer.addMediaItem(mediaItem)
}
}
// 2. 触发预下载 (新逻辑)
preDownloadAudios(httpTts)
}
}.onError {
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
}
}
// ================================================================
// 修改点:强力预缓存逻辑 (循环查库,跨章节)
// ================================================================
private suspend fun preDownloadAudios(httpTts: HttpTTS) {
val book = ReadBook.book ?: return
// 获取当前章节索引
val currentIdx = ReadBook.textChapter?.chapterIndex ?: return
// 获取用户设置的预加载数量 (例如 50)
val limit = AppConfig.audioPreDownloadNum
// 循环:从下一章开始,往后找 limit 个章节
for (i in 1..limit) {
currentCoroutineContext().ensureActive()
// 计算目标章节索引
val targetIndex = currentIdx + i
// 查数据库获取章节信息 (如果超出了总章数会返回空,就停止)
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
// 获取章节内容 (这里假设已缓存文本,或者尝试读取)
val contentString = BookHelp.getContent(book, chapter)
// 如果内容为空 (还没下载正文),就跳过,不强行下载正文以免阻塞
if (contentString.isNullOrEmpty()) continue
// 模拟 TextChapter 的分割逻辑,转成段落列表
val contentList = contentString.split("\n").filter { it.isNotEmpty() }
// 逐段检查并下载音频
contentList.forEach { content ->
currentCoroutineContext().ensureActive()
// 注意:跨章节时 MD5 需要基于 targetIndex 对应的 title (虽然 MD5Utils.md5Encode16(chapter.title) 可能和 TextChapter 不完全一致,但通常足够)
// 为了保险,我们手动拼装 MD5 文件名逻辑
val titleMd5 = MD5Utils.md5Encode16(chapter.title)
val contentMd5 = MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content")
val fileName = "${titleMd5}_${contentMd5}"
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
createSilentSound(fileName)
} else if (!hasSpeakFile(fileName)) {
runCatching {
val inputStream = getSpeakStream(httpTts, speakText)
if (inputStream != null) {
createSpeakFile(fileName, inputStream)
} else {
createSilentSound(fileName)
}
}
}
}
}
}
private fun downloadAndPlayAudiosStream() {
exoPlayer.clearMediaItems()
downloadTask?.cancel()
downloadTask = execute {
downloadTaskActiveLock.withLock {
ensureActive()
val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
val downloaderChannel = Channel<Downloader>()
launch {
for (downloader in downloaderChannel) {
downloader.download(null)
}
}
contentList.forEachIndexed { index, content ->
ensureActive()
if (index < nowSpeak) return@forEachIndexed
var text = content
if (paragraphStartPos > 0 && index == nowSpeak) {
text = text.substring(paragraphStartPos)
}
val speakText = text.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$speakText")
}
val fileName = md5SpeakFileName(text)
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
val downloader = createDownloader(dataSourceFactory, fileName)
downloaderChannel.send(downloader)
val mediaSource = createMediaSource(dataSourceFactory, fileName)
launch(Main) {
exoPlayer.addMediaSource(mediaSource)
}
}
preDownloadAudiosStream(httpTts, downloaderChannel)
}
}.onError {
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
}
}
// ================================================================
// 修改点:流式预缓存逻辑 (循环查库,跨章节)
// ================================================================
private suspend fun preDownloadAudiosStream(
httpTts: HttpTTS,
downloaderChannel: Channel<Downloader>
) {
val book = ReadBook.book ?: return
val currentIdx = ReadBook.textChapter?.chapterIndex ?: return
val limit = AppConfig.audioPreDownloadNum
for (i in 1..limit) {
currentCoroutineContext().ensureActive()
val targetIndex = currentIdx + i
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
val contentString = BookHelp.getContent(book, chapter)
if (contentString.isNullOrEmpty()) continue
val contentList = contentString.split("\n").filter { it.isNotEmpty() }
contentList.forEach { content ->
currentCoroutineContext().ensureActive()
val titleMd5 = MD5Utils.md5Encode16(chapter.title)
val contentMd5 = MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content")
val fileName = "${titleMd5}_${contentMd5}"
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
val downloader = createDownloader(dataSourceFactory, fileName)
downloaderChannel.send(downloader)
}
}
}
private fun createDataSourceFactory(
httpTts: HttpTTS,
speakText: String
): CacheDataSource.Factory {
val upstreamFactory = DataSource.Factory {
InputStreamDataSource {
if (speakText.isEmpty()) {
null
} else {
kotlin.runCatching {
runBlocking(lifecycleScope.coroutineContext[Job]!!) {
getSpeakStream(httpTts, speakText)
}
}.onFailure {
when (it) {
is InterruptedException,
is CancellationException -> Unit
else -> pauseReadAloud()
}
}.getOrThrow()
} ?: resources.openRawResource(R.raw.silent_sound)
}
}
val factory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(upstreamFactory)
.setCacheWriteDataSinkFactory(cacheDataSinkFactory)
return factory
}
private fun createDownloader(factory: CacheDataSource.Factory, fileName: String): Downloader {
val uri = fileName.toUri()
val request = DownloadRequest.Builder(fileName, uri).build()
return DefaultDownloaderFactory(factory, okHttpClient.dispatcher.executorService)
.createDownloader(request)
}
private fun createMediaSource(factory: DataSource.Factory, fileName: String): MediaSource {
return DefaultMediaSourceFactory(this)
.setDataSourceFactory(factory)
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
.createMediaSource(MediaItem.fromUri(fileName))
}
private suspend fun getSpeakStream(
httpTts: HttpTTS,
speakText: String
): InputStream? {
while (true) {
try {
val analyzeUrl = AnalyzeUrl(
httpTts.url,
speakText = speakText,
speakSpeed = speechRate,
source = httpTts,
readTimeout = 300 * 1000L,
coroutineContext = currentCoroutineContext()
)
var response = analyzeUrl.getResponseAwait()
currentCoroutineContext().ensureActive()
val checkJs = httpTts.loginCheckJs
if (checkJs?.isNotBlank() == true) {
response = analyzeUrl.evalJS(checkJs, response) as Response
}
response.headers["Content-Type"]?.let { contentType ->
val contentType = contentType.substringBefore(";")
val ct = httpTts.contentType
if (contentType == "application/json" || contentType.startsWith("text/")) {
throw NoStackTraceException(response.body.string())
} else if (ct?.isNotBlank() == true) {
if (!contentType.matches(ct.toRegex())) {
throw NoStackTraceException(
"TTS服务器返回错误:" + response.body.string()
)
}
}
}
currentCoroutineContext().ensureActive()
response.body.byteStream().let { stream ->
downloadErrorNo = 0
return stream
}
} catch (e: Exception) {
when (e) {
is CancellationException -> throw e
is ScriptException, is WrappedException -> {
AppLog.put("js错误\n${e.localizedMessage}", e, true)
e.printOnDebug()
throw e
}
is SocketTimeoutException, is ConnectException -> {
downloadErrorNo++
if (downloadErrorNo > 5) {
val msg = "tts超时或连接错误超过5次\n${e.localizedMessage}"
AppLog.put(msg, e, true)
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, e, true)
throw e
} else {
AppLog.put("TTS下载音频出错,使用无声音频代替。\n朗读文本:$speakText")
break
}
}
}
}
}
return null
}
private fun md5SpeakFileName(content: String, textChapter: TextChapter? = this.textChapter): 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() > AppConfig.audioCacheCleanTime)
|| 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 = lifecycleScope.launch {
upTtsProgress(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 (pageIndex + 1 < textChapter.pageSize
&& readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)
) {
pageIndex++
ReadBook.moveToNextPage()
upTtsProgress(readAloudNumber + i.toInt())
}
delay(sleep)
}
}
}
/**
* 更新朗读速度
*/
override fun upSpeechRate(reset: Boolean) {
downloadTask?.cancel()
exoPlayer.stop()
speechRate = AppConfig.speechRatePlay + 5
if (AppConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
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
updateNextPos()
exoPlayer.stop()
exoPlayer.clearMediaItems()
}
}
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
when (reason) {
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED -> {
if (!timeline.isEmpty && exoPlayer.playbackState == Player.STATE_IDLE) {
exoPlayer.prepare()
}
}
else -> {}
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
playErrorNo = 0
}
updateNextPos()
upPlayPos()
}
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
AppLog.put("朗读错误\n${contentList[nowSpeak]}", error)
deleteCurrentSpeakFile()
playErrorNo++
if (playErrorNo >= 5) {
toastOnUi("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})")
AppLog.put("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})", error)
pauseReadAloud()
} else {
if (exoPlayer.hasNextMediaItem()) {
exoPlayer.seekToNextMediaItem()
exoPlayer.prepare()
} else {
exoPlayer.clearMediaItems()
updateNextPos()
}
}
}
private fun deleteCurrentSpeakFile() {
if (AppConfig.streamReadAloudAudio) {
return
}
val mediaItem = exoPlayer.currentMediaItem ?: return
val filePath = mediaItem.localConfiguration!!.uri.path!!
File(filePath).delete()
}
override fun aloudServicePendingIntent(actionStr: String): PendingIntent? {
return servicePendingIntent<HttpReadAloudService>(actionStr)
}
class CustomLoadErrorHandlingPolicy : DefaultLoadErrorHandlingPolicy(0) {
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
return C.TIME_UNSET
}
}
}